fix(apps): follow up uninstall feedback and Windows CLI PATH (#8262)

* fix(windows): clarify uninstall results and restore CLI PATH

* fix(apps): handle diagnostic failures and canonical Windows CLI paths
This commit is contained in:
funkpopo authored and GitHub committed 2026-10-14 11:36:28 +08:00
1 parent 4958863adf
commit 74b98ca940
8 files changed
+381 -70

No files matched your search

+165 -48
View File
@@ -2063,13 +2063,15 @@ fn extend_mise_node_search_paths(paths: &mut Vec<std::path::PathBuf>, home: &Pat
/// duplicates are removed.
///
/// See `env_checker::check_system_env` for the same set of registry keys; here
/// we read only the `Path` value.
/// referenced variables also fall back to these keys if absent from the process.
#[cfg(target_os = "windows")]
fn effective_path_string() -> String {
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
use winreg::RegKey;
let process = std::env::var("PATH").unwrap_or_default();
let process = std::env::var_os("PATH")
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_default();
let user = RegKey::predef(HKEY_CURRENT_USER)
.open_subkey("Environment")
.and_then(|k| k.get_value::<String, &str>("Path"))
@@ -2112,12 +2114,43 @@ fn prepend_search_dir_to_path(dir: &Path, current_path: &std::ffi::OsStr) -> std
/// Expand `%VAR%` environment-variable references. The registry `Path` value is
/// `REG_EXPAND_SZ`, and the `String` returned by `winreg` is not auto-expanded.
/// Variables such as `%LOCALAPPDATA%` / `%USERPROFILE%` / `%SystemRoot%` are
/// still defined in a process that lost its user PATH (Winlogon injects them
/// from the user profile), so expanding each via `std::env::var` is safe.
/// Undefined variables are preserved verbatim (no characters dropped).
/// MSI launches can also lose user-defined variables such as `%PNPM_HOME%`.
/// Prefer process overrides, then the user and machine registry values. Registry
/// values may reference other variables; unknown/cyclic references stay literal.
#[cfg(target_os = "windows")]
fn expand_env_chars(raw: &str) -> String {
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
use winreg::RegKey;
let user = RegKey::predef(HKEY_CURRENT_USER)
.open_subkey("Environment")
.ok();
let machine = RegKey::predef(HKEY_LOCAL_MACHINE)
.open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment")
.ok();
expand_env_chars_with_lookup(
raw,
&|name| std::env::var(name).ok(),
&|name| {
user.as_ref()
.and_then(|key| key.get_value::<String, _>(name).ok())
.or_else(|| {
machine
.as_ref()
.and_then(|key| key.get_value::<String, _>(name).ok())
})
},
&mut Vec::new(),
)
}
#[cfg(target_os = "windows")]
fn expand_env_chars_with_lookup(
raw: &str,
process: &impl Fn(&str) -> Option<String>,
registry: &impl Fn(&str) -> Option<String>,
expanding: &mut Vec<String>,
) -> String {
let mut out = String::with_capacity(raw.len());
let mut rest = raw;
while let Some(open) = rest.find('%') {
@@ -2134,19 +2167,31 @@ fn expand_env_chars(raw: &str) -> String {
let name = &after[..close];
let is_ident =
!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if is_ident {
match std::env::var(name) {
Ok(val) => out.push_str(&val),
Err(_) => {
out.push('%');
out.push_str(name);
out.push('%');
let value = if is_ident {
process(name).or_else(|| {
// Bound nesting even for an unusually long registry chain.
if expanding.len() >= 32
|| expanding.iter().any(|var| var.eq_ignore_ascii_case(name))
{
return None;
}
}
let raw = registry(name)?;
expanding.push(name.to_owned());
let value =
expand_env_chars_with_lookup(&raw, process, registry, expanding);
expanding.pop();
Some(value)
})
} else {
out.push('%');
out.push_str(name);
out.push('%');
None
};
match value {
Some(value) => out.push_str(&value),
None => {
out.push('%');
out.push_str(name);
out.push('%');
}
}
rest = &after[close + 1..];
}
@@ -3893,6 +3938,9 @@ pub(crate) fn run_detected_tool_command_with_timeout(
let dir = tool_path
.parent()
.ok_or_else(|| format!("Invalid {tool} executable path"))?;
#[cfg(target_os = "windows")]
let current_path = effective_path_string();
#[cfg(not(target_os = "windows"))]
let current_path = std::env::var_os("PATH")
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_default();
@@ -3937,38 +3985,9 @@ fn run_windows_tool_command_capture(
extra_env: &[(&str, String)],
working_dir: &Path,
) -> Result<std::process::Output, String> {
use std::process::{Command, Stdio};
let mut cmd = if is_windows_command_script(tool_path) {
let path = tool_path.to_string_lossy();
let args = args
.iter()
.map(|arg| windows_cmd_double_quote_arg(arg))
.collect::<Vec<_>>()
.join(" ");
let command = format!(
"call {}{}",
win_quote_path_for_batch(&path),
if args.is_empty() {
String::new()
} else {
format!(" {args}")
}
);
let mut cmd = Command::new("cmd");
cmd.args(["/D", "/S", "/C"])
.raw_arg(&command)
.env("PATH", new_path)
.creation_flags(CREATE_NO_WINDOW);
cmd
} else {
let mut cmd = Command::new(tool_path);
cmd.args(args)
.env("PATH", new_path)
.creation_flags(CREATE_NO_WINDOW);
cmd
};
use std::process::Stdio;
let mut cmd = build_windows_tool_command(tool_path, args, new_path);
apply_extra_env(&mut cmd, extra_env);
cmd.current_dir(working_dir)
.stdout(Stdio::piped())
@@ -8809,6 +8828,65 @@ mod tests {
assert_eq!(expand_env_chars(r"C:\path\%%\tail"), r"C:\path\%%\tail");
}
#[cfg(target_os = "windows")]
#[test]
fn expand_env_chars_recovers_missing_user_variables_and_nested_references() {
let process = |name: &str| match name.to_ascii_uppercase().as_str() {
"USERPROFILE" => Some(r"C:\Users\tester".into()),
"OVERRIDE" => Some(r"D:\runtime".into()),
_ => None,
};
let registry = |name: &str| match name.to_ascii_uppercase().as_str() {
"PNPM_HOME" => Some(r"%LOCALAPPDATA%\pnpm".into()),
"LOCALAPPDATA" => Some(r"%USERPROFILE%\AppData\Local".into()),
"OVERRIDE" => Some(r"D:\registry".into()),
"CYCLE_A" => Some("%CYCLE_B%".into()),
"CYCLE_B" => Some("%cycle_a%".into()),
_ => None,
};
let expanded = expand_env_chars_with_lookup(
r"%pnpm_home%;%OVERRIDE%;%MISSING%;%CYCLE_A%;%PNPM_HOME%;unfinished%",
&process,
&registry,
&mut Vec::new(),
);
assert_eq!(
expanded,
r"C:\Users\tester\AppData\Local\pnpm;D:\runtime;%MISSING%;%cycle_a%;C:\Users\tester\AppData\Local\pnpm;unfinished%"
);
}
#[cfg(target_os = "windows")]
#[test]
fn effective_path_keeps_process_entries_with_non_unicode_path() {
use std::os::windows::ffi::OsStringExt;
const CHILD: &str = "CC_SWITCH_TEST_NON_UNICODE_PATH";
if std::env::var_os(CHILD).is_some() {
assert!(std::env::var("PATH").is_err());
let effective = effective_path_string();
assert!(effective.starts_with(r"C:\cc-switch-path-test;"));
assert!(effective.contains(r";D:\cc-switch-path-test"));
return;
}
// Use a subprocess so other tests never observe a modified process PATH.
let mut path: Vec<u16> = r"C:\cc-switch-path-test;C:\".encode_utf16().collect();
path.push(0xD800); // Unpaired UTF-16 surrogate.
path.extend(r";D:\cc-switch-path-test".encode_utf16());
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"commands::misc::tests::effective_path_keeps_process_entries_with_non_unicode_path",
"--nocapture",
])
.env(CHILD, "1")
.env("PATH", std::ffi::OsString::from_wide(&path))
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
}
#[cfg(target_os = "windows")]
#[test]
fn build_tool_search_paths_includes_standalone_installer_dirs() {
@@ -8940,6 +9018,45 @@ mod tests {
assert_eq!(preferred.as_deref(), Some(cmd.as_path()));
}
#[cfg(target_os = "windows")]
#[test]
fn windows_tool_capture_accepts_canonicalized_batch_paths() {
let root = tempfile::tempdir().unwrap();
let bin = root.path().join("模型 tools & helpers");
std::fs::create_dir(&bin).unwrap();
std::fs::write(
bin.join("cc_switch_capture_helper.cmd"),
"@echo helper-found\r\n@exit /b 23\r\n",
)
.unwrap();
let path = format!("{};{}", bin.display(), effective_path_string());
for extension in ["cmd", "bat"] {
let entry = bin.join(format!("opencode.{extension}"));
std::fs::write(
&entry,
"@echo off\r\necho arg=%~1\r\necho env=%CC_SWITCH_CAPTURE_TEST%\r\necho captured-stderr 1>&2\r\ncmd /D /C cc_switch_capture_helper.cmd\r\nexit /b %errorlevel%\r\n",
)
.unwrap();
let canonical = std::fs::canonicalize(entry).unwrap();
assert!(canonical.to_string_lossy().starts_with(r"\\?\"));
let output = run_windows_tool_command_capture(
&canonical,
&["models"],
&path,
CommandDeadline::from_timeout(Some(std::time::Duration::from_secs(10))),
&[("CC_SWITCH_CAPTURE_TEST", "preserved".into())],
root.path(),
)
.unwrap();
let stdout = decode_command_output(&output.stdout);
assert_eq!(output.status.code(), Some(23), "{output:?}");
assert!(stdout.contains("arg=models"), "{stdout}");
assert!(stdout.contains("env=preserved"), "{stdout}");
assert!(stdout.contains("helper-found"), "{stdout}");
assert!(decode_command_output(&output.stderr).contains("captured-stderr"));
}
}
#[cfg(target_os = "windows")]
#[test]
fn windows_shell_compatible_path_strips_verbatim_prefixes() {
@@ -14,6 +14,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import {
hermesUninstallMayLeaveFiles,
toolDisplayName,
type ToolName,
type WslShellPreference,
@@ -94,6 +95,11 @@ export function ToolUninstallConfirmDialog({
{t("settings.toolUninstallNativeHint")}
</p>
)}
{hermesUninstallMayLeaveFiles(plan) && (
<p role="note" className="text-caption text-fg-2">
{t("settings.toolUninstallHermesWindowsHint")}
</p>
)}
</>
) : (
<p
+41 -18
View File
@@ -49,6 +49,10 @@ export const TOOL_NAMES = [
export type ToolName = (typeof TOOL_NAMES)[number];
export type ToolLifecycleAction = "install" | "update" | "uninstall";
export function hermesUninstallMayLeaveFiles(plan: ToolUninstallPlan) {
return isWindows() && plan.tool === "hermes" && !plan.wsl_distro;
}
interface PendingUpgrade {
toolNames: ToolName[];
plans: ToolInstallationReport[];
@@ -530,18 +534,18 @@ export function useToolManagement() {
const diagnoseToolSilently = useCallback(async (toolName: ToolName) => {
try {
const [report] = await settingsApi.probeToolInstallations([toolName]);
// 没拿到报告代表状态未知,不能把它当成「没有其他安装」。
if (!report) return false;
// 同一份报告也是行上的路径 / 来源 / 「另有 N 处安装」:装完、升级完就刷新,
// 不用等手动「检查更新」
if (report) {
updateToolManagementState({
installReports: {
...(toolManagementState.installReports ?? {}),
[toolName]: report,
},
});
}
updateToolManagementState({
installReports: {
...(toolManagementState.installReports ?? {}),
[toolName]: report,
},
});
setToolDiagnostics((prev) => {
if (report?.is_conflict) {
if (report.is_conflict) {
return { ...prev, [toolName]: report.installs };
}
// 无冲突:清掉残留;无旧结果则返回同引用,避免无谓 re-render。
@@ -550,11 +554,13 @@ export function useToolManagement() {
delete next[toolName];
return next;
});
return true;
} catch (error) {
console.error(
`[useToolManagement] Auto-diagnose failed for ${toolName}`,
error,
);
return false;
}
}, []);
@@ -610,7 +616,7 @@ export function useToolManagement() {
const isBatch = toolNames.length > 1;
// 每个工具独立调用后端,一个失败不会中断其它工具。
// soft=true 表示"命令成功执行但结果仍需用户介入"(版本没变/装上却跑不起来),
// soft=true 表示"命令成功执行但结果仍需用户介入"(版本没变/无法运行/卸载待核实),
// 与命令本身报错(soft=false)区别对待:前者不算硬失败,toast 降级为 warning。
const failures: {
toolName: ToolName;
@@ -658,16 +664,31 @@ export function useToolManagement() {
);
const tool = refreshed.find((t) => t.name === toolName);
if (action === "uninstall") {
await diagnoseToolSilently(toolName);
if (!tool) {
throw new Error(t("settings.toolUninstallCheckFailed"));
const diagnosed = await diagnoseToolSilently(toolName);
const warnings: string[] = [];
if (!tool || !diagnosed) {
warnings.push(t("settings.toolUninstallCheckFailed"));
}
if (tool.version || tool.installed_but_broken) {
if (
uninstallPlan &&
hermesUninstallMayLeaveFiles(uninstallPlan)
) {
warnings.push(t("settings.toolUninstallHermesWindowsHint"));
}
if (tool?.version || tool?.installed_but_broken) {
toast.info(t("settings.toolUninstallRemaining"), {
description: TOOL_DISPLAY_NAMES[toolName],
closeButton: true,
});
}
if (warnings.length > 0) {
failures.push({
toolName,
detail: warnings.join("\n"),
soft: true,
});
return;
}
succeeded += 1;
return;
}
@@ -777,12 +798,14 @@ export function useToolManagement() {
failures.every((f) => f.soft && f.kind === "versionUnchanged");
if (succeeded === 0 && hardFailures.length === 0) {
// 命令均成功执行、但结果需要用户介入(版本没变 / 装上却跑不起来)
// 命令均成功执行、但结果需要用户介入(版本没变 / 无法运行 / 卸载待核实)
// → 降级为 warning 并解释原因。
toast.warning(
allSoftVersionUnchanged
? t("settings.toolActionVersionUnchangedTitle")
: t("settings.toolActionInstalledNotRunnable"),
action === "uninstall"
? t("settings.toolUninstallWarningTitle")
: allSoftVersionUnchanged
? t("settings.toolActionVersionUnchangedTitle")
: t("settings.toolActionInstalledNotRunnable"),
{
description: failureDescription || undefined,
closeButton: true,
+3 -1
View File
@@ -955,11 +955,13 @@
"toolUninstall": "Uninstall",
"toolUninstallConfirmTitle": "Uninstall {{tool}}?",
"toolUninstallConfirmHint": "This removes the active installation and keeps configuration and session data. Other installations, if any, will remain.",
"toolUninstallPreparing": "Checking the installation and uninstalling…",
"toolUninstallPreparing": "Checking the installation…",
"toolUninstallManualRequired": "Cannot uninstall automatically. Please uninstall this app manually.",
"toolUninstallPath": "Installation to remove",
"toolUninstallNativeHint": "Only the selected executable and its launcher will be removed. Older versions left by the installer will remain.",
"toolUninstallFailed": "Uninstall failed",
"toolUninstallWarningTitle": "Uninstall command completed; check the result",
"toolUninstallHermesWindowsHint": "On Windows, Hermes may leave some program files behind because its running Python is locked during uninstall. After the command finishes, check the original installation directory for leftover files; keep configuration and session data when cleaning up.",
"toolUninstallRemaining": "The active installation was removed; other installations remain",
"toolUninstallCheckFailed": "Could not verify the installation status after uninstalling. Click “Check for updates” to try again.",
"toolUpdate": "Update",
+3 -1
View File
@@ -955,11 +955,13 @@
"toolUninstall": "アンインストール",
"toolUninstallConfirmTitle": "{{tool}} をアンインストールしますか?",
"toolUninstallConfirmHint": "現在使用中のインストールを削除し、設定とセッションデータは保持します。ほかの場所にもインストールされている場合、それらは保持されます。",
"toolUninstallPreparing": "インストールを確認し、アンインストールしています…",
"toolUninstallPreparing": "インストールを確認しています…",
"toolUninstallManualRequired": "自動でアンインストールできません。このアプリを手動でアンインストールしてください。",
"toolUninstallPath": "削除するインストールのパス",
"toolUninstallNativeHint": "選択した実行ファイルと起動用ファイルのみを削除します。インストーラーが保存した他の旧バージョンは残ります。",
"toolUninstallFailed": "アンインストールに失敗しました",
"toolUninstallWarningTitle": "アンインストールコマンドが完了しました。結果を確認してください",
"toolUninstallHermesWindowsHint": "Windows では、アンインストール中に実行中の Python がロックされるため、Hermes のプログラムファイルが一部残る場合があります。コマンド完了後に元のインストール先を確認し、残ったファイルを削除する際は設定とセッションデータを保持してください。",
"toolUninstallRemaining": "現在のインストールを削除しましたが、ほかのインストールが残っています",
"toolUninstallCheckFailed": "アンインストール後の状態を確認できませんでした。「更新を確認」をクリックして再試行してください。",
"toolUpdate": "更新",
+3 -1
View File
@@ -956,11 +956,13 @@
"toolUninstall": "解除安裝",
"toolUninstallConfirmTitle": "解除安裝 {{tool}}?",
"toolUninstallConfirmHint": "將解除安裝目前使用的安裝,保留設定和工作階段資料。如果存在多處安裝,其他安裝將保留。",
"toolUninstallPreparing": "正在檢查安裝並解除安裝…",
"toolUninstallPreparing": "正在檢查安裝…",
"toolUninstallManualRequired": "無法自動解除安裝,請手動解除安裝此應用程式。",
"toolUninstallPath": "將解除安裝的路徑",
"toolUninstallNativeHint": "僅刪除目前選取的執行檔及其啟動入口,安裝程式保留的其他舊版本仍會保留。",
"toolUninstallFailed": "解除安裝失敗",
"toolUninstallWarningTitle": "解除安裝指令已完成,請檢查結果",
"toolUninstallHermesWindowsHint": "Windows 上 Hermes 解除安裝時,可能因執行中的 Python 被鎖定而留下部分程式檔案。指令結束後請檢查原安裝目錄中的殘留;清理時請保留設定和工作階段資料。",
"toolUninstallRemaining": "目前的安裝已移除,仍偵測到其他安裝",
"toolUninstallCheckFailed": "解除安裝後無法確認安裝狀態,請點選「檢查更新」重試。",
"toolUpdate": "更新",
+3 -1
View File
@@ -955,11 +955,13 @@
"toolUninstall": "卸载",
"toolUninstallConfirmTitle": "卸载 {{tool}}?",
"toolUninstallConfirmHint": "将卸载当前使用的安装,保留配置和会话数据。如果存在多处安装,其他安装将保留。",
"toolUninstallPreparing": "正在检查安装并卸载…",
"toolUninstallPreparing": "正在检查安装…",
"toolUninstallManualRequired": "无法自动卸载,请手动卸载此应用。",
"toolUninstallPath": "将卸载的安装路径",
"toolUninstallNativeHint": "仅删除当前选中的可执行文件及其启动入口,其他旧版本仍会保留。",
"toolUninstallFailed": "卸载失败",
"toolUninstallWarningTitle": "卸载命令已完成,请检查结果",
"toolUninstallHermesWindowsHint": "Windows 上 Hermes 卸载时可能因正在运行的 Python 被锁定而留下部分程序文件。命令结束后请检查原安装目录中的残留;清理时请保留配置和会话数据。",
"toolUninstallRemaining": "当前安装已卸载,仍检测到其他安装",
"toolUninstallCheckFailed": "卸载后无法确认安装状态,请点击“检查更新”重试。",
"toolUpdate": "升级",
+157
View File
@@ -829,6 +829,163 @@ describe("AppsPage concurrent CLI upgrades", () => {
}
});
it.each(["rejected", "empty"])(
"warns when uninstall succeeds but installation diagnostics are %s",
async (result) => {
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await renderApps();
mocks.runToolLifecycleAction.mockImplementationOnce(async () => {
missing.add("claude");
if (result === "rejected") {
mocks.probeToolInstallations.mockRejectedValueOnce(
new Error("diagnostics failed"),
);
} else {
mocks.probeToolInstallations.mockResolvedValueOnce([]);
}
});
fireEvent.click(
card("Claude Code").getByRole("button", {
name: "settings.toolUninstall",
}),
);
fireEvent.click(
await within(screen.getByRole("dialog")).findByRole("button", {
name: "settings.toolUninstall",
}),
);
await waitFor(() =>
expect(mocks.warning).toHaveBeenCalledWith(
"settings.toolUninstallWarningTitle",
{
description: "settings.toolUninstallCheckFailed",
closeButton: true,
},
),
);
expect(mocks.error).not.toHaveBeenCalled();
expect(mocks.success).not.toHaveBeenCalled();
expect(
card("Claude Code").getByRole("button", {
name: "settings.toolInstall",
}),
).toBeEnabled();
} finally {
errorLog.mockRestore();
}
},
);
it.each(["rejected", "empty"])(
"warns when uninstall succeeds but the status refresh is %s",
async (result) => {
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await renderApps();
mocks.runToolLifecycleAction.mockImplementationOnce(async () => {
if (result === "rejected") {
mocks.getToolVersions.mockRejectedValueOnce(
new Error("refresh failed"),
);
} else {
mocks.getToolVersions.mockResolvedValueOnce([]);
}
});
fireEvent.click(
card("Claude Code").getByRole("button", {
name: "settings.toolUninstall",
}),
);
fireEvent.click(
await within(screen.getByRole("dialog")).findByRole("button", {
name: "settings.toolUninstall",
}),
);
await waitFor(() =>
expect(mocks.warning).toHaveBeenCalledWith(
"settings.toolUninstallWarningTitle",
{
description: "settings.toolUninstallCheckFailed",
closeButton: true,
},
),
);
expect(mocks.error).not.toHaveBeenCalled();
expect(mocks.success).not.toHaveBeenCalled();
expect(
card("Claude Code").getByRole("button", {
name: "settings.toolUninstall",
}),
).toBeEnabled();
} finally {
errorLog.mockRestore();
}
},
);
it.each([
["Windows NT 10.0", null, true],
["Windows NT 10.0", "Ubuntu", false],
["X11; Linux x86_64", null, false],
])(
"warns about Hermes leftovers only on native Windows (%s, %s)",
async (userAgent, distro, warn) => {
const platform = vi
.spyOn(navigator, "userAgent", "get")
.mockReturnValue(userAgent);
try {
mocks.prepareToolUninstall.mockResolvedValueOnce({
...uninstallPlan("hermes"),
command: "hermes uninstall --yes",
wsl_distro: distro,
});
mocks.runToolLifecycleAction.mockImplementationOnce(async () => {
missing.add("hermes");
});
await renderApps();
fireEvent.click(
card("Hermes").getByRole("button", {
name: "settings.toolUninstall",
}),
);
const dialog = within(screen.getByRole("dialog"));
const confirm = await dialog.findByRole("button", {
name: "settings.toolUninstall",
});
expect(
dialog.queryByText("settings.toolUninstallHermesWindowsHint") !==
null,
).toBe(warn);
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
fireEvent.click(confirm);
await waitFor(() =>
expect(
card("Hermes").getByRole("button", {
name: "settings.toolInstall",
}),
).toBeEnabled(),
);
if (warn) {
expect(mocks.warning).toHaveBeenCalledWith(
"settings.toolUninstallWarningTitle",
{
description: "settings.toolUninstallHermesWindowsHint",
closeButton: true,
},
);
expect(mocks.success).not.toHaveBeenCalled();
} else {
expect(mocks.warning).not.toHaveBeenCalled();
expect(mocks.success).toHaveBeenCalledOnce();
}
expect(mocks.error).not.toHaveBeenCalled();
} finally {
platform.mockRestore();
}
},
);
it("refreshes the remaining installation after uninstall instead of reporting a broken install", async () => {
await renderApps();
mocks.probeToolInstallations.mockResolvedValueOnce([