Files
Jason ba6a8a5554 fix(apps): group Claude Desktop by whether it is installed
Claude Desktop was grouped by whether it was connected to CC Switch, so
an installed but unconnected Desktop showed up under "Not installed".

`get_claude_desktop_status` now reports `installed`. Desktop has no
command to probe, so it is inferred: connected, or Desktop's own data
directory (`Claude`, created on its first launch; `Claude-3p` is not
counted because CC Switch writes it too) exists, or on macOS a
Claude.app bundle is in /Applications or ~/Applications. The apps page
groups the Desktop row by it; the row still says whether it is connected.
2026-10-24 22:56:15 +08:00

2086 lines
71 KiB
TypeScript

import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
settingsApi,
ToolInstallationReport,
ToolUninstallCandidate,
ToolUninstallPlan,
} from "@/lib/api/settings";
import type { DndContextProps, DragEndEvent } from "@dnd-kit/core";
import { APP_IDS } from "@/config/appConfig";
type ToolVersions = Awaited<ReturnType<typeof settingsApi.getToolVersions>>;
const mocks = vi.hoisted(() => ({
getToolVersions: vi.fn(),
probeToolInstallations: vi.fn(),
listToolInstallations: vi.fn(),
runToolLifecycleAction: vi.fn(),
prepareToolUninstall: vi.fn(),
copyText: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
settings: {} as { visibleApps?: undefined; appOrder?: string[] },
updateSettings: vi.fn(),
autoSaveSettings: vi.fn(async () => null),
dragEndHandlers: [] as ((event: DragEndEvent) => void)[],
desktopStatus: { supported: true, installed: true, configured: true },
}));
vi.mock("@/lib/api", () => ({ settingsApi: mocks }));
vi.mock("@/lib/api/providers", () => ({
providersApi: {
getClaudeDesktopStatus: async () => mocks.desktopStatus,
},
}));
vi.mock("@/hooks/useSettings", () => ({
useSettings: () => ({
settings: mocks.settings,
updateSettings: mocks.updateSettings,
autoSaveSettings: mocks.autoSaveSettings,
}),
}));
vi.mock("@dnd-kit/core", async () => {
const actual =
await vi.importActual<typeof import("@dnd-kit/core")>("@dnd-kit/core");
return {
...actual,
DndContext: (props: DndContextProps) => {
if (props.onDragEnd) mocks.dragEndHandlers.push(props.onDragEnd);
return <actual.DndContext {...props} />;
},
};
});
vi.mock("sonner", () => ({ toast: mocks }));
vi.mock("@/lib/clipboard", () => ({ copyText: mocks.copyText }));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function report(
tool: string,
overrides: Partial<ToolInstallationReport> = {},
): ToolInstallationReport {
return {
tool,
installs: [],
is_conflict: false,
needs_confirmation: false,
command: `${tool} update`,
anchored: true,
unmanaged: false,
...overrides,
};
}
function uninstallPlan(tool: string): ToolUninstallPlan {
return {
tool,
path: `C:/tools/${tool}.cmd`,
real_path: `C:/tools/${tool}.cmd`,
command: `npm uninstall -g --prefix C:/tools ${tool}`,
native: false,
wsl_distro: null,
};
}
function candidate(
plan: ToolUninstallPlan | null,
overrides: Partial<ToolUninstallCandidate> = {},
): ToolUninstallCandidate {
return {
path: plan?.path ?? "C:/tools/unknown.exe",
version: "1.0.0",
runnable: true,
error: null,
source: "system",
is_path_default: true,
plan,
unavailable_reason: null,
reinstalled_by_updater: false,
...overrides,
};
}
const upgraded = new Set<string>();
const outdated = new Set<string>();
const missing = new Set<string>();
function card(name: string) {
return within(
screen
.getByText(name, { selector: "span" })
.closest("[data-tool-row]") as HTMLElement,
);
}
/** 已是最新:没有升级 / 安装按钮,显示新版本号。 */
function isReady(name: string) {
return (
card(name).queryByRole("button", {
name: /settings\.tool(Update|Install)/,
}) === null && card(name).queryByText("2.0.0") !== null
);
}
/** 某一组里各行的应用 ID,按显示顺序。 */
function rowApps(region: string) {
return Array.from(
screen
.getByRole("region", { name: region })
.querySelectorAll<HTMLElement>("[data-tool-row]"),
(row) => row.dataset.toolRow,
);
}
/** 行尾「⋯」菜单(卸载在里面)。 */
function rowMenu(name: string) {
return card(name).getByRole("button", { name: "appPage.moreActions" });
}
async function openUninstall(name: string) {
const user = userEvent.setup();
await user.click(rowMenu(name));
await user.click(
await screen.findByRole("menuitem", { name: "settings.toolUninstall" }),
);
}
function updateButton(name: string) {
return card(name).getByRole("button", { name: "settings.toolUpdate" });
}
function mountApps(AppsPage: () => JSX.Element) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<AppsPage />
</QueryClientProvider>,
);
}
async function renderApps() {
// 安装 / 升级的状态放在模块级 store 里,跨挂载保留。
const { AppsPage } = await import("@/components/apps/AppsPage");
const view = mountApps(AppsPage);
// Both header labels stay mounted to reserve their width. Wait for the idle
// label to be shown, including when a pending dialog hides the page itself.
await waitFor(() =>
expect(
within(view.container).getByText("appsPage.checkUpdates"),
).toHaveAttribute("aria-hidden", "false"),
);
return view;
}
const updateAllButton = () =>
screen.getByRole("button", { name: /settings\.updateAllTools/ });
// Radix Select scrolls the focused option; jsdom does not implement scrolling.
const originalScrollIntoView = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"scrollIntoView",
);
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
afterAll(() => {
if (originalScrollIntoView) {
Object.defineProperty(
HTMLElement.prototype,
"scrollIntoView",
originalScrollIntoView,
);
} else {
Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
}
});
describe("AppsPage concurrent CLI upgrades", () => {
beforeEach(() => {
vi.resetModules();
upgraded.clear();
outdated.clear();
missing.clear();
mocks.settings = {};
mocks.dragEndHandlers = [];
mocks.desktopStatus = {
supported: true,
installed: true,
configured: true,
};
mocks.updateSettings.mockReset();
mocks.autoSaveSettings.mockReset().mockResolvedValue(null);
mocks.copyText.mockReset().mockResolvedValue(undefined);
outdated.add("claude").add("codex").add("gemini");
mocks.getToolVersions
.mockReset()
.mockImplementation(async (tools: string[]) =>
tools.map((name) => ({
name,
version: missing.has(name)
? null
: upgraded.has(name) || !outdated.has(name)
? "2.0.0"
: "1.0.0",
latest_version: "2.0.0",
error: null,
installed_but_broken: false,
env_type: "windows",
wsl_distro: null,
})),
);
mocks.probeToolInstallations
.mockReset()
.mockImplementation(async (tools: string[]) =>
tools.map((tool) => report(tool)),
);
mocks.listToolInstallations
.mockReset()
.mockImplementation(async (tools: string[]) =>
tools.map((tool) => report(tool)),
);
mocks.prepareToolUninstall
.mockReset()
.mockImplementation(async (tool: string) => [
candidate(uninstallPlan(tool)),
]);
mocks.runToolLifecycleAction
.mockReset()
.mockImplementation(async ([tool]: string[]) => {
upgraded.add(tool);
missing.delete(tool);
});
});
it("copies only the selected application's manual command and switches installation methods", async () => {
const getVersions = mocks.getToolVersions.getMockImplementation()!;
mocks.getToolVersions.mockImplementation(async (tools: string[]) =>
((await getVersions(tools)) as ToolVersions).map((tool) => ({
...tool,
env_type: "macos",
})),
);
const user = userEvent.setup();
await renderApps();
await user.click(
screen.getByRole("button", { name: "appsPage.moreActions" }),
);
await user.click(
await screen.findByRole("menuitem", {
name: "settings.manualUninstallCommands",
}),
);
const dialog = within(screen.getByRole("dialog"));
const codex = () => within(dialog.getByText("Codex").closest("li")!);
expect(
codex().getByText("npm uninstall -g @openai/codex"),
).toBeInTheDocument();
expect(dialog.queryByText("Hermes")).not.toBeInTheDocument();
await user.click(codex().getByRole("button"));
expect(mocks.copyText).toHaveBeenLastCalledWith(
"npm uninstall -g @openai/codex",
);
expect(dialog.getByText("macOS")).toBeInTheDocument();
expect(dialog.getAllByRole("combobox")).toHaveLength(2);
await user.click(
dialog.getByRole("combobox", { name: "settings.manualUninstallMethod" }),
);
await user.click(
await screen.findByRole("option", { name: "Homebrew (cask)" }),
);
expect(
dialog.queryByText("npm uninstall -g @openai/codex"),
).not.toBeInTheDocument();
expect(
dialog.getByText("brew uninstall --cask claude-code"),
).toBeInTheDocument();
expect(
dialog.getByText("brew uninstall --cask claude-code@latest"),
).toBeInTheDocument();
await user.click(codex().getByRole("button"));
expect(mocks.copyText).toHaveBeenLastCalledWith(
"brew uninstall --cask codex",
);
expect(mocks.success).toHaveBeenCalledWith(
"settings.uninstallCommandCopied",
{ closeButton: true },
);
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
await user.click(dialog.getByRole("button", { name: "common.close" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "appsPage.moreActions" }),
);
await user.click(
await screen.findByRole("menuitem", { name: "appsPage.manualCommands" }),
);
expect(
within(screen.getByRole("dialog")).getByRole("heading", {
name: "settings.manualInstallCommands",
}),
).toBeInTheDocument();
});
it.each([["Windows NT 10.0"], ["X11; Linux x86_64"]])(
"shows the official Hermes uninstall command for %s",
async (userAgent) => {
const platform = vi
.spyOn(navigator, "userAgent", "get")
.mockReturnValue(userAgent);
try {
const user = userEvent.setup();
await renderApps();
await user.click(
screen.getByRole("button", { name: "appsPage.moreActions" }),
);
await user.click(
await screen.findByRole("menuitem", {
name: "settings.manualUninstallCommands",
}),
);
const dialog = within(screen.getByRole("dialog"));
await user.click(
dialog.getByRole("combobox", {
name: "settings.manualUninstallMethod",
}),
);
await user.click(await screen.findByRole("option", { name: "Hermes" }));
expect(
within(dialog.getByRole("list")).getByText("Hermes"),
).toBeInTheDocument();
expect(dialog.queryByText("Codex")).not.toBeInTheDocument();
await user.click(
dialog.getByRole("button", {
name: "settings.copyToolUninstallCommand",
}),
);
expect(mocks.copyText).toHaveBeenCalledWith("hermes uninstall --yes");
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
} finally {
platform.mockRestore();
}
},
);
it("keeps manual commands visible when copying fails", async () => {
mocks.copyText.mockRejectedValueOnce(new Error("clipboard unavailable"));
const user = userEvent.setup();
await renderApps();
await user.click(
screen.getByRole("button", { name: "appsPage.moreActions" }),
);
await user.click(
await screen.findByRole("menuitem", {
name: "settings.manualUninstallCommands",
}),
);
const dialog = within(screen.getByRole("dialog"));
const codex = within(dialog.getByText("Codex").closest("li")!);
await user.click(codex.getByRole("button"));
expect(mocks.error).toHaveBeenCalledWith(
"settings.uninstallCommandCopyFailed",
);
expect(
codex.getByText("npm uninstall -g @openai/codex"),
).toBeInTheDocument();
expect(mocks.success).not.toHaveBeenCalled();
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
});
it("automatically detects Windows and only shows documented pnpm and Bun clients", async () => {
const platform = vi
.spyOn(navigator, "userAgent", "get")
.mockReturnValue("Windows NT 10.0");
try {
const user = userEvent.setup();
await renderApps();
await user.click(
screen.getByRole("button", { name: "appsPage.moreActions" }),
);
await user.click(
await screen.findByRole("menuitem", {
name: "settings.manualUninstallCommands",
}),
);
const dialog = within(screen.getByRole("dialog"));
expect(dialog.getByText("Windows")).toBeInTheDocument();
expect(dialog.getAllByRole("combobox")).toHaveLength(2);
const chooseMethod = async (name: string) => {
await user.click(
dialog.getByRole("combobox", {
name: "settings.manualUninstallMethod",
}),
);
expect(
screen.queryByRole("option", { name: "Volta" }),
).not.toBeInTheDocument();
await user.click(await screen.findByRole("option", { name }));
};
await user.click(
dialog.getByRole("combobox", {
name: "settings.manualUninstallMethod",
}),
);
expect(
screen.queryByRole("option", { name: "Homebrew (cask)" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("option", { name: "Homebrew (formula)" }),
).not.toBeInTheDocument();
await user.click(await screen.findByRole("option", { name: "pnpm" }));
expect(dialog.getAllByRole("listitem")).toHaveLength(2);
expect(
dialog.getByText("pnpm remove -g opencode-ai"),
).toBeInTheDocument();
expect(dialog.getByText("pnpm remove -g openclaw")).toBeInTheDocument();
expect(dialog.queryByText("Codex")).not.toBeInTheDocument();
await chooseMethod("bun");
expect(dialog.getAllByRole("listitem")).toHaveLength(2);
expect(dialog.getByText("bun remove -g openclaw")).toBeInTheDocument();
expect(
dialog.getByText("bun remove -g @oh-my-pi/pi-coding-agent"),
).toBeInTheDocument();
expect(
dialog.queryByText("bun remove -g opencode-ai"),
).not.toBeInTheDocument();
expect(
dialog.getByText("settings.manualUninstallOpenClawHint"),
).toBeInTheDocument();
expect(
dialog.getByText("settings.manualUninstallBunLauncherHint"),
).toBeInTheDocument();
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
} finally {
platform.mockRestore();
}
});
it.each([
["updates the detected environments and per-client WSL commands", false],
["preserves a manual environment selection during detection", true],
] as const)("%s", async (_name, manualOverride) => {
const platform = vi
.spyOn(navigator, "userAgent", "get")
.mockReturnValue("Windows NT 10.0");
try {
const pending = deferred<ToolVersions>();
const getVersions = mocks.getToolVersions.getMockImplementation()!;
const opencode = (await getVersions(["opencode"])) as ToolVersions;
mocks.getToolVersions.mockImplementation((tools: string[]) =>
tools.includes("opencode") ? pending.promise : getVersions(tools),
);
const user = userEvent.setup();
const { AppsPage } = await import("@/components/apps/AppsPage");
mountApps(AppsPage);
await user.click(
screen.getByRole("button", { name: "appsPage.moreActions" }),
);
await user.click(
await screen.findByRole("menuitem", {
name: "settings.manualUninstallCommands",
}),
);
const dialog = within(screen.getByRole("dialog"));
const environmentSelect = dialog.getByRole("combobox", {
name: "settings.manualUninstallEnvironment",
});
expect(environmentSelect).toHaveTextContent("Windows");
await user.click(
dialog.getByRole("combobox", {
name: "settings.manualUninstallMethod",
}),
);
await user.click(await screen.findByRole("option", { name: "bun" }));
expect(
dialog.queryByText("bun remove -g opencode-ai"),
).not.toBeInTheDocument();
const chooseEnvironment = async (name: string) => {
await user.click(environmentSelect);
expect(
(await screen.findAllByRole("option")).map(
(option) => option.textContent,
),
).toEqual(["Windows", "macOS", "Linux / WSL"]);
await user.click(await screen.findByRole("option", { name }));
};
// Selecting the currently detected Windows environment explicitly makes
// it an override; the pending WSL detection must not replace that choice.
if (manualOverride) await chooseEnvironment("Windows");
await act(async () =>
pending.resolve(
opencode.map((tool) => ({
...tool,
env_type: "wsl",
wsl_distro: "Ubuntu",
})),
),
);
if (manualOverride) {
expect(environmentSelect).toHaveTextContent(/^Windows$/);
expect(
dialog.queryByText("bun remove -g opencode-ai"),
).not.toBeInTheDocument();
} else {
expect(environmentSelect).toHaveTextContent("Windows / WSL · Ubuntu");
const opencodeRow = within(
dialog.getByText("bun remove -g opencode-ai").closest("li")!,
);
expect(opencodeRow.getByText("WSL · Ubuntu")).toBeInTheDocument();
const openclawRow = within(
dialog.getByText("bun remove -g openclaw").closest("li")!,
);
expect(openclawRow.getByText("Windows")).toBeInTheDocument();
expect(dialog.getAllByRole("combobox")).toHaveLength(2);
await user.click(
dialog.getByRole("combobox", {
name: "settings.manualUninstallMethod",
}),
);
expect(
screen.queryByRole("option", { name: "Homebrew (cask)" }),
).not.toBeInTheDocument();
await user.click(
await screen.findByRole("option", { name: "Homebrew (formula)" }),
);
expect(
dialog.getByText("brew uninstall --formula opencode"),
).toBeInTheDocument();
expect(
dialog.getByText("brew uninstall --formula anomalyco/tap/opencode"),
).toBeInTheDocument();
expect(
dialog.queryByText("brew uninstall --formula gemini-cli"),
).not.toBeInTheDocument();
}
await chooseEnvironment("macOS");
await user.click(
dialog.getByRole("combobox", {
name: "settings.manualUninstallMethod",
}),
);
await user.click(
await screen.findByRole("option", { name: "Homebrew (cask)" }),
);
expect(
dialog.getByText("brew uninstall --cask codex"),
).toBeInTheDocument();
expect(dialog.queryByText("WSL · Ubuntu")).not.toBeInTheDocument();
// A manual switch also drops methods unsupported in the new environment.
await chooseEnvironment("Windows");
expect(
dialog.getByRole("combobox", {
name: "settings.manualUninstallMethod",
}),
).toHaveTextContent("npm");
expect(
dialog.queryByText("brew uninstall --cask codex"),
).not.toBeInTheDocument();
await chooseEnvironment("Linux / WSL");
await user.click(
dialog.getByRole("combobox", {
name: "settings.manualUninstallMethod",
}),
);
await user.click(await screen.findByRole("option", { name: "bun" }));
expect(dialog.getByText("bun remove -g opencode-ai")).toBeInTheDocument();
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
} finally {
platform.mockRestore();
}
});
it("offers uninstall for installed tools and keeps missing tools installable", async () => {
missing.add("codex");
await renderApps();
expect(
card("Claude Code").getByRole("button", {
name: "appPage.moreActions",
}),
).toBeEnabled();
expect(
card("OpenCode").getByRole("button", {
name: "appPage.moreActions",
}),
).toBeEnabled();
expect(
card("Codex").queryByRole("button", {
name: "appPage.moreActions",
}),
).toBeNull();
expect(
card("Codex").getByRole("button", { name: "settings.toolInstall" }),
).toBeEnabled();
});
it("groups apps into installed and not installed sections", async () => {
missing.add("codex");
await renderApps();
const installed = within(
screen.getByRole("region", { name: "appsPage.sectionInstalled" }),
);
const notInstalled = within(
screen.getByRole("region", { name: "appsPage.sectionNotInstalled" }),
);
expect(installed.getByText("Claude Code")).toBeInTheDocument();
expect(installed.getByText("Claude Desktop")).toBeInTheDocument();
expect(installed.queryByText("Codex")).toBeNull();
expect(notInstalled.getByText("Codex")).toBeInTheDocument();
expect(notInstalled.queryByText("Claude Code")).toBeNull();
});
it("groups Claude Desktop by whether it is installed, not whether it is connected", async () => {
mocks.desktopStatus = {
supported: true,
installed: true,
configured: false,
};
await renderApps();
expect(rowApps("appsPage.sectionInstalled")).toContain("claude-desktop");
expect(
card("Claude Desktop").getByText("appsPage.desktopNotConnected"),
).toBeInTheDocument();
});
it("puts Claude Desktop under not installed when it is not on this machine", async () => {
mocks.desktopStatus = {
supported: true,
installed: false,
configured: false,
};
await renderApps();
expect(rowApps("appsPage.sectionNotInstalled")).toEqual(["claude-desktop"]);
});
it("orders rows by the saved app order within each section", async () => {
missing.add("codex").add("hermes");
mocks.settings = {
appOrder: [
"hermes",
"gemini",
"claude",
"claude-desktop",
"codex",
...APP_IDS.filter(
(app) =>
!["hermes", "gemini", "claude", "claude-desktop", "codex"].includes(
app,
),
),
],
};
await renderApps();
expect(rowApps("appsPage.sectionInstalled").slice(0, 4)).toEqual([
"gemini",
"claude",
"claude-desktop",
"grokbuild",
]);
expect(rowApps("appsPage.sectionNotInstalled")).toEqual([
"hermes",
"codex",
]);
// 只有已安装的能拖
expect(
within(
screen.getByRole("region", { name: "appsPage.sectionInstalled" }),
).getAllByRole("button", { name: "appsPage.dragHandle" }),
).toHaveLength(APP_IDS.length - 2);
expect(
within(
screen.getByRole("region", { name: "appsPage.sectionNotInstalled" }),
).queryByRole("button", { name: "appsPage.dragHandle" }),
).toBeNull();
});
it("saves the new order when a row is dropped onto another in its section", async () => {
await renderApps();
// jsdom 不做布局,算不出拖动落点:直接调各组 DndContext 收到的 onDragEnd
const onDragEnd = mocks.dragEndHandlers.at(-1);
expect(onDragEnd).toBeDefined();
act(() => {
onDragEnd?.({
active: { id: "claude" },
over: { id: "codex" },
} as unknown as DragEndEvent);
});
const next = [
"claude-desktop",
"codex",
"claude",
...APP_IDS.filter(
(app) => !["claude", "claude-desktop", "codex"].includes(app),
),
];
expect(mocks.updateSettings).toHaveBeenCalledWith({ appOrder: next });
expect(mocks.autoSaveSettings).toHaveBeenCalledWith({ appOrder: next });
});
it("shows the selected path and command while retaining the resolved target for uninstall validation", async () => {
const pending = deferred<ToolUninstallCandidate[]>();
mocks.prepareToolUninstall.mockReturnValueOnce(pending.promise);
await renderApps();
await openUninstall("Claude Code");
const dialog = within(screen.getByRole("dialog"));
expect(dialog.getByRole("status")).toHaveTextContent(
"settings.toolUninstallPreparing",
);
expect(
dialog.queryByRole("button", { name: "settings.toolUninstall" }),
).toBeNull();
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
const plan = {
...uninstallPlan("claude"),
real_path: "C:/Users/me/.local/share/claude/versions/2.0.0",
native: true,
};
await act(async () => pending.resolve([candidate(plan)]));
expect(dialog.getByText(plan.path)).toBeInTheDocument();
expect(dialog.queryByText(plan.real_path)).not.toBeInTheDocument();
expect(dialog.getByText(plan.command)).toBeInTheDocument();
expect(
dialog.getByText("settings.toolUninstallNativeHint"),
).toBeInTheDocument();
expect(mocks.prepareToolUninstall).toHaveBeenCalledWith("claude", {});
fireEvent.click(
dialog.getByRole("button", { name: "settings.toolUninstall" }),
);
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledWith(
["claude"],
"uninstall",
{},
plan,
),
);
});
it("offers only manual uninstall when a plan cannot be prepared", async () => {
mocks.prepareToolUninstall.mockRejectedValueOnce(
new Error("Unverified Homebrew owner: mise"),
);
await renderApps();
await openUninstall("Codex");
const dialog = within(screen.getByRole("dialog"));
expect(await dialog.findByRole("alert")).toHaveTextContent(
"settings.toolUninstallManualRequired",
);
expect(
dialog.getByText("Unverified Homebrew owner: mise"),
).toBeInTheDocument();
expect(
dialog.queryByRole("button", { name: "settings.toolUninstall" }),
).toBeNull();
fireEvent.click(
dialog.getByRole("button", { name: "settings.manualUninstallCommands" }),
);
expect(
screen.getByRole("heading", { name: "settings.manualUninstallCommands" }),
).toBeInTheDocument();
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
});
it("lists every installation and uninstalls only the one the user picks", async () => {
const native = {
...uninstallPlan("grok"),
path: "/Users/me/.grok/bin/grok",
real_path: "/Users/me/.grok/bin/grok-1.0.50",
command:
"rm -- '/Users/me/.grok/bin/grok' '/Users/me/.grok/bin/grok-1.0.50'",
native: true,
};
const npm = {
...uninstallPlan("grok"),
path: "/Users/me/.nvm/versions/node/v22/bin/grok",
command:
"npm uninstall -g --prefix /Users/me/.nvm/versions/node/v22 @xai-official/grok",
};
mocks.prepareToolUninstall.mockResolvedValueOnce([
candidate(native, { version: "1.0.50" }),
candidate(npm, {
version: "1.0.50",
source: "nvm",
is_path_default: false,
reinstalled_by_updater: true,
}),
candidate(null, {
path: "/opt/tools/grok",
version: "0.9.0",
is_path_default: false,
unavailable_reason:
"Cannot automatically uninstall Grok Build at /opt/tools/grok.",
}),
]);
await renderApps();
await openUninstall("Grok Build");
const dialog = within(screen.getByRole("dialog"));
const npmRadio = await dialog.findByRole("radio", { name: npm.path });
const nativeRadio = dialog.getByRole("radio", { name: native.path });
const manualRadio = dialog.getByRole("radio", { name: "/opt/tools/grok" });
expect(nativeRadio).not.toBeChecked();
expect(npmRadio).not.toBeChecked();
expect(manualRadio).toBeDisabled();
expect(
dialog.getByText(
"Cannot automatically uninstall Grok Build at /opt/tools/grok.",
),
).toBeInTheDocument();
expect(
dialog.getByText("settings.toolUninstallReinstalledByUpdater"),
).toBeInTheDocument();
expect(
dialog.getByRole("button", { name: "settings.manualUninstallCommands" }),
).toBeInTheDocument();
const confirm = dialog.getByRole("button", {
name: "settings.toolUninstall",
});
expect(confirm).toBeDisabled();
expect(dialog.queryByText(native.command)).toBeNull();
fireEvent.click(npmRadio);
expect(dialog.getByText(npm.command)).toBeInTheDocument();
expect(dialog.queryByText("settings.toolUninstallNativeHint")).toBeNull();
fireEvent.click(nativeRadio);
expect(dialog.getByText(native.command)).toBeInTheDocument();
expect(
dialog.getByText("settings.toolUninstallNativeHint"),
).toBeInTheDocument();
fireEvent.click(confirm);
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledWith(
["grok"],
"uninstall",
{},
native,
),
);
});
it("discards a cancelled preview instead of showing it for another tool", async () => {
const first = deferred<ToolUninstallCandidate[]>();
mocks.prepareToolUninstall.mockReturnValueOnce(first.promise);
await renderApps();
await openUninstall("Claude Code");
fireEvent.click(
within(screen.getByRole("dialog")).getByRole("button", {
name: "common.cancel",
}),
);
await openUninstall("Codex");
const dialog = within(screen.getByRole("dialog"));
expect(
await dialog.findByText(uninstallPlan("codex").path),
).toBeInTheDocument();
await act(async () => first.resolve([candidate(uninstallPlan("claude"))]));
expect(dialog.queryByText(uninstallPlan("claude").path)).toBeNull();
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
});
it("only offers valid Homebrew formula uninstall commands", async () => {
const getVersions = mocks.getToolVersions.getMockImplementation()!;
mocks.getToolVersions.mockImplementation(async (tools: string[]) =>
((await getVersions(tools)) as ToolVersions).map((tool) => ({
...tool,
env_type: "linux",
})),
);
const user = userEvent.setup();
await renderApps();
await user.click(
screen.getByRole("button", { name: "appsPage.moreActions" }),
);
await user.click(
await screen.findByRole("menuitem", {
name: "settings.manualUninstallCommands",
}),
);
const dialog = within(screen.getByRole("dialog"));
expect(dialog.getByText("Linux")).toBeInTheDocument();
await user.click(
dialog.getByRole("combobox", { name: "settings.manualUninstallMethod" }),
);
await user.click(
await screen.findByRole("option", { name: "Homebrew (formula)" }),
);
expect(
dialog.getByText("brew uninstall --formula gemini-cli"),
).toBeInTheDocument();
expect(
dialog.getByText("brew uninstall --formula opencode"),
).toBeInTheDocument();
expect(
dialog.getByText("brew uninstall --formula anomalyco/tap/opencode"),
).toBeInTheDocument();
expect(dialog.queryByText("Codex")).toBeNull();
expect(dialog.queryByText("Claude Code")).toBeNull();
expect(dialog.queryByText("Grok Build")).toBeNull();
});
it("confirms uninstall, preserves the running task across navigation and refreshes to install", async () => {
const running = deferred<void>();
mocks.runToolLifecycleAction.mockImplementation(
async ([tool]: string[]) => {
await running.promise;
missing.add(tool);
},
);
const view = await renderApps();
const uninstall = () => rowMenu("Claude Code");
await openUninstall("Claude Code");
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
fireEvent.click(
within(screen.getByRole("dialog")).getByRole("button", {
name: "common.cancel",
}),
);
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
await openUninstall("Claude Code");
fireEvent.click(
await within(screen.getByRole("dialog")).findByRole("button", {
name: "settings.toolUninstall",
}),
);
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(1);
expect(mocks.runToolLifecycleAction).toHaveBeenCalledWith(
["claude"],
"uninstall",
{},
uninstallPlan("claude"),
);
expect(uninstall()).toBeDisabled();
expect(uninstall()).toHaveAttribute("aria-busy", "true");
expect(updateButton("Claude Code")).toBeDisabled();
expect(updateButton("Codex")).toBeEnabled();
view.unmount();
await renderApps();
expect(uninstall()).toBeDisabled();
expect(uninstall()).toHaveAttribute("aria-busy", "true");
await act(async () => running.resolve());
expect(
card("Claude Code").queryByRole("button", {
name: "appPage.moreActions",
}),
).toBeNull();
expect(
card("Claude Code").getByRole("button", { name: "settings.toolInstall" }),
).toBeEnabled();
expect(
card("Claude Code").getByText("common.notInstalled"),
).toBeInTheDocument();
expect(mocks.probeToolInstallations).toHaveBeenCalledWith(["claude"]);
expect(mocks.success).toHaveBeenCalledOnce();
expect(mocks.warning).not.toHaveBeenCalled();
});
it("keeps uninstall available for a broken installation", async () => {
mocks.getToolVersions.mockImplementation(async (tools: string[]) =>
tools.map((name) => ({
name,
version: null,
latest_version: "2.0.0",
error: "cannot run",
installed_but_broken: true,
env_type: "windows",
wsl_distro: null,
})),
);
await renderApps();
expect(
card("Claude Code").getByRole("button", {
name: "appPage.moreActions",
}),
).toBeEnabled();
expect(
card("Claude Code").queryByRole("button", {
name: "settings.toolInstall",
}),
).toBeNull();
});
it("reports uninstall failure and unlocks the tool for retry", async () => {
mocks.runToolLifecycleAction.mockRejectedValueOnce(
new Error("permission denied"),
);
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await renderApps();
await openUninstall("Claude Code");
fireEvent.click(
await within(screen.getByRole("dialog")).findByRole("button", {
name: "settings.toolUninstall",
}),
);
await waitFor(() =>
expect(mocks.error).toHaveBeenCalledWith(
"settings.toolUninstallFailed",
{
description: "permission denied",
closeButton: true,
},
),
);
expect(
card("Claude Code").getByRole("button", {
name: "appPage.moreActions",
}),
).toBeEnabled();
expect(updateButton("Claude Code")).toBeEnabled();
expect(mocks.success).not.toHaveBeenCalled();
} finally {
errorLog.mockRestore();
}
});
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([]);
}
});
await openUninstall("Claude Code");
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([]);
}
});
await openUninstall("Claude Code");
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: "appPage.moreActions",
}),
).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([
candidate({
...uninstallPlan("hermes"),
command: "hermes uninstall --yes",
wsl_distro: distro,
}),
]);
mocks.runToolLifecycleAction.mockImplementationOnce(async () => {
missing.add("hermes");
});
await renderApps();
await openUninstall("Hermes");
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([
report("claude", {
installs: [
{
path: "C:/remaining/claude.exe",
version: "2.0.0",
runnable: true,
error: null,
source: "system",
is_path_default: true,
},
],
}),
]);
await openUninstall("Claude Code");
fireEvent.click(
await within(screen.getByRole("dialog")).findByRole("button", {
name: "settings.toolUninstall",
}),
);
await waitFor(() =>
expect(mocks.info).toHaveBeenCalledWith(
"settings.toolUninstallRemaining",
{
description: "Claude Code",
closeButton: true,
},
),
);
expect(
card("Claude Code").getByText("C:/remaining/claude.exe"),
).toBeInTheDocument();
expect(
card("Claude Code").getByRole("button", {
name: "appPage.moreActions",
}),
).toBeEnabled();
expect(mocks.warning).not.toHaveBeenCalled();
});
it("lets different tools preflight and submit together while blocking duplicate clicks", async () => {
const claudeProbe = deferred<ToolInstallationReport[]>();
const codexProbe = deferred<ToolInstallationReport[]>();
const claudeRun = deferred<void>();
const codexRun = deferred<void>();
mocks.probeToolInstallations.mockImplementation(([tool]: string[]) =>
tool === "claude" ? claudeProbe.promise : codexProbe.promise,
);
mocks.runToolLifecycleAction.mockImplementation(
async ([tool]: string[]) => {
await (tool === "claude" ? claudeRun.promise : codexRun.promise);
upgraded.add(tool);
},
);
await renderApps();
fireEvent.click(updateButton("Claude Code"));
fireEvent.click(updateButton("Claude Code"));
expect(updateButton("Claude Code")).toBeDisabled();
expect(updateButton("Codex")).toBeEnabled();
fireEvent.click(updateButton("Codex"));
expect(mocks.probeToolInstallations.mock.calls).toEqual([
[["claude"]],
[["codex"]],
]);
await act(async () => {
claudeProbe.resolve([report("claude")]);
codexProbe.resolve([report("codex")]);
});
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(2);
expect(updateButton("Claude Code")).toBeDisabled();
expect(updateButton("Codex")).toBeDisabled();
expect(updateButton("Gemini CLI")).toBeEnabled();
await act(async () => codexRun.resolve());
expect(isReady("Codex")).toBe(true);
expect(updateButton("Claude Code")).toBeDisabled();
await act(async () => claudeRun.resolve());
expect(isReady("Claude Code")).toBe(true);
expect(mocks.success).toHaveBeenCalledTimes(2);
});
it("submits the remaining tools while a single upgrade is already running", async () => {
const runs = new Map(
["claude", "codex", "gemini"].map((name) => [name, deferred<void>()]),
);
mocks.runToolLifecycleAction.mockImplementation(
async ([tool]: string[]) => {
await runs.get(tool)!.promise;
upgraded.add(tool);
},
);
await renderApps();
fireEvent.click(updateButton("Claude Code"));
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(1),
);
const updateAll = updateAllButton();
expect(updateAll).toBeEnabled();
fireEvent.click(updateAll);
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(3),
);
expect(mocks.probeToolInstallations).toHaveBeenNthCalledWith(2, [
"codex",
"gemini",
]);
expect(
mocks.runToolLifecycleAction.mock.calls.map(([tools]) => tools),
).toEqual([["claude"], ["codex"], ["gemini"]]);
await act(async () => {
runs.get("gemini")!.resolve();
runs.get("codex")!.resolve();
});
expect(isReady("Gemini CLI")).toBe(true);
expect(isReady("Codex")).toBe(true);
expect(updateButton("Claude Code")).toBeDisabled();
await act(async () => runs.get("claude")!.resolve());
});
it.each(["install", "update"] as const)(
"restores an ongoing %s after remount and receives its completion",
async (action) => {
if (action === "install") missing.add("claude");
const running = deferred<void>();
mocks.runToolLifecycleAction.mockImplementationOnce(async () => {
await running.promise;
upgraded.add("claude");
missing.delete("claude");
});
const actionButton = () =>
card("Claude Code").getByRole("button", {
name:
action === "install"
? "settings.toolInstall"
: "settings.toolUpdate",
});
const view = await renderApps();
fireEvent.click(actionButton());
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(1),
);
view.unmount();
await renderApps();
const versionChecks = mocks.getToolVersions.mock.calls.length;
expect(actionButton()).toBeDisabled();
expect(actionButton()).toHaveAttribute("aria-busy", "true");
expect(updateButton("Codex")).toBeEnabled();
fireEvent.click(actionButton());
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(1);
expect(mocks.getToolVersions).toHaveBeenCalledTimes(versionChecks);
expect(mocks.info).not.toHaveBeenCalled();
expect(mocks.error).not.toHaveBeenCalled();
expect(mocks.warning).not.toHaveBeenCalled();
expect(mocks.success).not.toHaveBeenCalled();
await act(async () => running.resolve());
expect(isReady("Claude Code")).toBe(true);
expect(
screen.getByRole("button", { name: "appsPage.checkUpdates" }),
).toBeEnabled();
expect(mocks.success).toHaveBeenCalledTimes(1);
},
);
it.each(["install", "update"] as const)(
"shows an informational toast when the backend reports an ongoing %s",
async (action) => {
if (action === "install") missing.add("claude");
mocks.runToolLifecycleAction.mockRejectedValueOnce(
"TOOL_ACTION_IN_PROGRESS",
);
await renderApps();
const versionChecks = mocks.getToolVersions.mock.calls.length;
fireEvent.click(
card("Claude Code").getByRole("button", {
name:
action === "install"
? "settings.toolInstall"
: "settings.toolUpdate",
}),
);
await waitFor(() =>
expect(mocks.info).toHaveBeenCalledWith(
"settings.toolActionInProgress",
{
description: "settings.toolActionInProgressDetail",
closeButton: true,
},
),
);
expect(mocks.getToolVersions).toHaveBeenCalledTimes(versionChecks);
expect(mocks.error).not.toHaveBeenCalled();
expect(mocks.warning).not.toHaveBeenCalled();
expect(mocks.success).not.toHaveBeenCalled();
},
);
it("ignores a late probe from the old page after the remounted page upgrades a tool", async () => {
const stale = deferred<ToolVersions>();
const getVersions = mocks.getToolVersions.getMockImplementation()!;
let firstClaudeProbe = true;
let oldResult: ToolVersions = [];
mocks.getToolVersions.mockImplementation(async (tools: string[]) => {
if (tools.includes("claude") && firstClaudeProbe) {
firstClaudeProbe = false;
oldResult = await getVersions(tools);
return stale.promise;
}
return getVersions(tools);
});
const { AppsPage } = await import("@/components/apps/AppsPage");
const { TOOL_NAMES } = await import("@/components/apps/useToolManagement");
const view = mountApps(AppsPage);
await waitFor(() =>
expect(mocks.getToolVersions).toHaveBeenCalledTimes(TOOL_NAMES.length),
);
view.unmount();
const remounted = await renderApps();
fireEvent.click(updateButton("Claude Code"));
await waitFor(() => expect(isReady("Claude Code")).toBe(true));
await act(async () => stale.resolve(oldResult));
expect(isReady("Claude Code")).toBe(true);
expect(card("Claude Code").queryByText("1.0.0")).not.toBeInTheDocument();
remounted.unmount();
await renderApps();
expect(isReady("Claude Code")).toBe(true);
});
it("preserves WSL execution and refresh parameters when confirming a mixed batch after remount", async () => {
const preflight = deferred<ToolInstallationReport[]>();
const getVersions = mocks.getToolVersions.getMockImplementation()!;
mocks.getToolVersions.mockImplementation(async (tools: string[]) =>
((await getVersions(tools)) as ToolVersions).map((tool) =>
tool.name === "claude"
? { ...tool, env_type: "wsl", wsl_distro: "Ubuntu" }
: tool,
),
);
mocks.probeToolInstallations.mockImplementationOnce(
() => preflight.promise,
);
const scrollIntoView = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"scrollIntoView",
);
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
try {
const user = userEvent.setup();
const view = await renderApps();
await user.click(card("Claude Code").getAllByRole("combobox")[0]);
await user.click(screen.getByRole("option", { name: "bash" }));
await waitFor(() => expect(updateButton("Claude Code")).toBeEnabled());
await user.click(card("Claude Code").getAllByRole("combobox")[1]);
await user.click(screen.getByRole("option", { name: "-lic" }));
await waitFor(() => expect(updateButton("Claude Code")).toBeEnabled());
fireEvent.click(updateAllButton());
view.unmount();
// WSL tools do not request confirmation themselves; a native tool in
// the same batch can hold the whole batch pending confirmation.
await act(async () =>
preflight.resolve([
report("claude"),
report("codex", { needs_confirmation: true }),
report("gemini"),
]),
);
await renderApps();
mocks.getToolVersions.mockClear();
fireEvent.click(
screen.getByRole("button", { name: "settings.toolUpgradeConfirmBtn" }),
);
await waitFor(() => expect(mocks.success).toHaveBeenCalledTimes(1));
const preferences = {
claude: { wslShell: "bash", wslShellFlag: "-lic" },
};
expect(mocks.runToolLifecycleAction).toHaveBeenCalledWith(
["claude"],
"update",
preferences,
);
expect(mocks.getToolVersions).toHaveBeenCalledWith(
["claude"],
preferences,
);
} finally {
if (scrollIntoView) {
Object.defineProperty(
HTMLElement.prototype,
"scrollIntoView",
scrollIntoView,
);
} else {
Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
}
}
});
it("preserves batch progress across remounts and updates each completed tool", async () => {
const runs = new Map(
["claude", "codex", "gemini"].map((name) => [name, deferred<void>()]),
);
mocks.runToolLifecycleAction.mockImplementation(
async ([tool]: string[]) => {
await runs.get(tool)!.promise;
upgraded.add(tool);
},
);
const view = await renderApps();
fireEvent.click(updateAllButton());
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(3),
);
view.unmount();
await act(async () => runs.get("codex")!.resolve());
await renderApps();
const updateAll = updateAllButton();
expect(updateAll).toBeDisabled();
expect(updateAll).toHaveAttribute("aria-busy", "true");
expect(isReady("Codex")).toBe(true);
expect(updateButton("Claude Code")).toHaveAttribute("aria-busy", "true");
expect(updateButton("Gemini CLI")).toBeDisabled();
await act(async () => runs.get("gemini")!.resolve());
expect(isReady("Gemini CLI")).toBe(true);
expect(updateButton("Claude Code")).toBeDisabled();
expect(updateAll).toHaveAttribute("aria-busy", "true");
await act(async () => runs.get("claude")!.resolve());
expect(isReady("Claude Code")).toBe(true);
// 全部升级完、没有可升级的了:按钮收起
expect(
screen.queryByRole("button", { name: /settings\.updateAllTools/ }),
).toBeNull();
expect(
screen.getByRole("button", { name: "appsPage.checkUpdates" }),
).toBeEnabled();
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(3);
expect(mocks.success).toHaveBeenCalledTimes(1);
});
it("preserves preflight and its confirmation when navigating away and back", async () => {
const preflight = deferred<ToolInstallationReport[]>();
mocks.probeToolInstallations.mockImplementationOnce(
() => preflight.promise,
);
const view = await renderApps();
fireEvent.click(updateButton("Claude Code"));
view.unmount();
const remounted = await renderApps();
expect(updateButton("Claude Code")).toBeDisabled();
expect(updateButton("Claude Code")).toHaveAttribute("aria-busy", "true");
expect(mocks.probeToolInstallations).toHaveBeenCalledTimes(1);
await act(async () =>
preflight.resolve([report("claude", { needs_confirmation: true })]),
);
expect(screen.getByRole("dialog")).toBeInTheDocument();
remounted.unmount();
await renderApps();
expect(
within(screen.getByRole("dialog")).getByText("Claude Code"),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "common.cancel" }));
expect(updateButton("Claude Code")).toBeEnabled();
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
fireEvent.click(updateButton("Claude Code"));
await waitFor(() => expect(isReady("Claude Code")).toBe(true));
});
it("unlocks a failed background task after remount so it can be retried", async () => {
const running = deferred<void>();
mocks.runToolLifecycleAction.mockImplementationOnce(() => running.promise);
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const view = await renderApps();
fireEvent.click(updateButton("Claude Code"));
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(1),
);
view.unmount();
await renderApps();
expect(updateButton("Claude Code")).toBeDisabled();
await act(async () => running.reject(new Error("installer failed")));
expect(updateButton("Claude Code")).toBeEnabled();
expect(mocks.error).toHaveBeenCalledWith("settings.toolActionFailed", {
description: "installer failed",
closeButton: true,
});
fireEvent.click(updateButton("Claude Code"));
await waitFor(() => expect(isReady("Claude Code")).toBe(true));
} finally {
errorLog.mockRestore();
}
});
it("does not report success or failure when all requested tools are already running", async () => {
mocks.runToolLifecycleAction.mockRejectedValue("TOOL_ACTION_IN_PROGRESS");
await renderApps();
const versionChecks = mocks.getToolVersions.mock.calls.length;
fireEvent.click(updateAllButton());
await waitFor(() => expect(mocks.info).toHaveBeenCalledTimes(3));
expect(mocks.getToolVersions).toHaveBeenCalledTimes(versionChecks);
expect(mocks.success).not.toHaveBeenCalled();
expect(mocks.warning).not.toHaveBeenCalled();
expect(mocks.error).not.toHaveBeenCalled();
});
it("completes other tools without counting an ongoing task as a failure", async () => {
mocks.runToolLifecycleAction.mockImplementation(
async ([tool]: string[]) => {
if (tool === "claude") throw "TOOL_ACTION_IN_PROGRESS";
upgraded.add(tool);
},
);
await renderApps();
fireEvent.click(updateAllButton());
await waitFor(() => expect(mocks.success).toHaveBeenCalledTimes(1));
expect(mocks.info).toHaveBeenCalledTimes(1);
expect(isReady("Codex")).toBe(true);
expect(isReady("Gemini CLI")).toBe(true);
expect(mocks.warning).not.toHaveBeenCalled();
expect(mocks.error).not.toHaveBeenCalled();
});
it("keeps genuine failures in the batch summary while excluding ongoing tasks", async () => {
mocks.runToolLifecycleAction.mockImplementation(
async ([tool]: string[]) => {
if (tool === "claude") throw "TOOL_ACTION_IN_PROGRESS";
if (tool === "codex") throw new Error("installer failed");
upgraded.add(tool);
},
);
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await renderApps();
fireEvent.click(updateAllButton());
await waitFor(() =>
expect(mocks.warning).toHaveBeenCalledWith(
"settings.toolActionPartial",
{ description: "Codex: installer failed", closeButton: true },
),
);
expect(mocks.info).toHaveBeenCalledTimes(1);
expect(mocks.success).not.toHaveBeenCalled();
expect(mocks.error).not.toHaveBeenCalled();
expect(errorLog).toHaveBeenCalledTimes(1);
} finally {
errorLog.mockRestore();
}
});
it("releases a failed tool for retry without unlocking another running tool", async () => {
const firstRun = deferred<void>();
const retryRun = deferred<void>();
const codexRun = deferred<void>();
const geminiRun = deferred<void>();
let claudeAttempts = 0;
mocks.runToolLifecycleAction.mockImplementation(
async ([tool]: string[]) => {
const run =
tool === "claude"
? ++claudeAttempts === 1
? firstRun
: retryRun
: tool === "codex"
? codexRun
: geminiRun;
await run.promise;
upgraded.add(tool);
},
);
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
await renderApps();
fireEvent.click(updateAllButton());
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(3),
);
await act(async () => firstRun.reject(new Error("upgrade failed")));
expect(updateButton("Claude Code")).toBeEnabled();
expect(updateButton("Codex")).toBeDisabled();
fireEvent.click(updateButton("Claude Code"));
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(4),
);
await act(async () => {
codexRun.resolve();
geminiRun.resolve();
});
expect(updateButton("Claude Code")).toBeDisabled();
expect(mocks.warning).toHaveBeenCalledWith(
"settings.toolActionPartial",
expect.objectContaining({ description: "Claude Code: upgrade failed" }),
);
await act(async () => retryRun.resolve());
expect(isReady("Claude Code")).toBe(true);
errorLog.mockRestore();
});
it("queues concurrent confirmation requests and releases only the cancelled tool", async () => {
const claudeProbe = deferred<ToolInstallationReport[]>();
const codexProbe = deferred<ToolInstallationReport[]>();
const claudeRun = deferred<void>();
mocks.probeToolInstallations.mockImplementation(([tool]: string[]) =>
tool === "claude" ? claudeProbe.promise : codexProbe.promise,
);
mocks.runToolLifecycleAction.mockImplementation(
async ([tool]: string[]) => {
await claudeRun.promise;
upgraded.add(tool);
},
);
await renderApps();
fireEvent.click(updateButton("Claude Code"));
fireEvent.click(updateButton("Codex"));
await act(async () => {
claudeProbe.resolve([report("claude", { needs_confirmation: true })]);
codexProbe.resolve([report("codex", { needs_confirmation: true })]);
});
expect(
within(screen.getByRole("dialog")).getByText("Claude Code"),
).toBeInTheDocument();
expect(
within(screen.getByRole("dialog")).queryByText("Codex"),
).not.toBeInTheDocument();
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
fireEvent.click(
screen.getByRole("button", { name: "settings.toolUpgradeConfirmBtn" }),
);
expect(
within(screen.getByRole("dialog")).getByText("Codex"),
).toBeInTheDocument();
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(1);
expect(mocks.runToolLifecycleAction).toHaveBeenCalledWith(
["claude"],
"update",
{},
);
fireEvent.click(screen.getByRole("button", { name: "common.cancel" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(updateButton("Codex")).toBeEnabled();
expect(updateButton("Claude Code")).toBeDisabled();
await act(async () => claudeRun.resolve());
});
it.each(["double click", "held Enter", "held Space"])(
"does not confirm the next queued plan with a %s",
async (gesture) => {
const claudeProbe = deferred<ToolInstallationReport[]>();
const codexProbe = deferred<ToolInstallationReport[]>();
const run = deferred<void>();
mocks.probeToolInstallations.mockImplementation(([tool]: string[]) =>
tool === "claude" ? claudeProbe.promise : codexProbe.promise,
);
mocks.runToolLifecycleAction.mockImplementation(() => run.promise);
const user = userEvent.setup();
await renderApps();
fireEvent.click(updateButton("Claude Code"));
fireEvent.click(updateButton("Codex"));
await act(async () => {
claudeProbe.resolve([report("claude", { needs_confirmation: true })]);
codexProbe.resolve([report("codex", { needs_confirmation: true })]);
});
const confirm = screen.getByRole("button", {
name: "settings.toolUpgradeConfirmBtn",
});
if (gesture === "double click") {
await user.dblClick(confirm);
} else {
confirm.focus();
await user.keyboard(
gesture === "held Enter" ? "{Enter>3/}" : "[Space>3/]",
);
}
expect(mocks.runToolLifecycleAction.mock.calls).toEqual([
[["claude"], "update", {}],
]);
expect(
within(screen.getByRole("dialog")).getByText("Codex"),
).toBeInTheDocument();
if (gesture !== "double click") {
expect(
screen.getByRole("heading", {
name: "settings.toolUpgradeConfirmTitle",
}),
).toHaveFocus();
}
// 第二项仍可通过一次新的、明确的操作正常确认。
await user.click(confirm);
expect(mocks.runToolLifecycleAction.mock.calls).toEqual([
[["claude"], "update", {}],
[["codex"], "update", {}],
]);
await act(async () => run.resolve());
},
);
it("does not cancel the next queued plan with a double click", async () => {
const claudeProbe = deferred<ToolInstallationReport[]>();
const codexProbe = deferred<ToolInstallationReport[]>();
mocks.probeToolInstallations.mockImplementation(([tool]: string[]) =>
tool === "claude" ? claudeProbe.promise : codexProbe.promise,
);
const user = userEvent.setup();
await renderApps();
fireEvent.click(updateButton("Claude Code"));
fireEvent.click(updateButton("Codex"));
await act(async () => {
claudeProbe.resolve([report("claude", { needs_confirmation: true })]);
codexProbe.resolve([report("codex", { needs_confirmation: true })]);
});
await user.dblClick(screen.getByRole("button", { name: "common.cancel" }));
expect(
within(screen.getByRole("dialog")).getByText("Codex"),
).toBeInTheDocument();
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "common.cancel" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(updateButton("Claude Code")).toBeEnabled();
expect(updateButton("Codex")).toBeEnabled();
});
it("does not block another installation submission while an upgrade is running", async () => {
const claudeRun = deferred<void>();
missing.add("codex");
mocks.runToolLifecycleAction.mockImplementation(
async ([tool]: string[]) => {
if (tool === "claude") await claudeRun.promise;
upgraded.add(tool);
missing.delete(tool);
},
);
await renderApps();
fireEvent.click(updateButton("Claude Code"));
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(1),
);
const install = card("Codex").getByRole("button", {
name: "settings.toolInstall",
});
expect(install).toBeEnabled();
fireEvent.click(install);
await waitFor(() => expect(isReady("Codex")).toBe(true));
expect(mocks.runToolLifecycleAction).toHaveBeenCalledWith(
["codex"],
"install",
{},
);
expect(updateButton("Claude Code")).toBeDisabled();
await act(async () => claudeRun.resolve());
});
it("keeps a tool locked through version refresh and remount with an expired cache", async () => {
const refreshed =
deferred<Awaited<ReturnType<typeof mocks.getToolVersions>>>();
const view = await renderApps();
mocks.getToolVersions.mockImplementationOnce(() => refreshed.promise);
fireEvent.click(updateButton("Claude Code"));
await waitFor(() =>
expect(
card("Claude Code").getAllByLabelText("common.loading").length,
).toBeGreaterThan(0),
);
// 等原任务的版本刷新真正发出(初始探测 + 刷新共 2 次)再卸载,
// 否则机器慢时刷新可能落在重新挂载之后,计数就会抖
await waitFor(() =>
expect(
mocks.getToolVersions.mock.calls.filter(([tools]) =>
tools.includes("claude"),
),
).toHaveLength(2),
);
view.unmount();
const now = vi
.spyOn(Date, "now")
.mockReturnValue(Date.now() + 11 * 60 * 1000);
try {
await renderApps();
// Initial probe and the original task's refresh only: remount must not
// probe a tool again while its installation/version refresh is pending.
expect(
mocks.getToolVersions.mock.calls.filter(([tools]) =>
tools.includes("claude"),
),
).toHaveLength(2);
} finally {
now.mockRestore();
}
expect(updateButton("Claude Code")).toBeDisabled();
expect(updateButton("Claude Code")).toHaveAttribute("aria-busy", "true");
// 缓存过期,重新挂载时别的工具会重新探测版本;等探测完、按钮出来再点「全部更新」,
// 不然机器慢时这几行还在加载
await waitFor(() => {
expect(updateButton("Codex")).toBeEnabled();
expect(updateButton("Gemini CLI")).toBeEnabled();
});
fireEvent.click(updateAllButton());
await waitFor(() =>
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(3),
);
expect(
mocks.runToolLifecycleAction.mock.calls.filter(([tools]) =>
tools.includes("claude"),
),
).toHaveLength(1);
await act(async () =>
refreshed.resolve([
{
name: "claude",
version: "2.0.0",
latest_version: "2.0.0",
error: null,
installed_but_broken: false,
env_type: "windows",
wsl_distro: null,
},
]),
);
expect(isReady("Claude Code")).toBe(true);
});
it("releases skipped unmanaged tools and runs the remaining batch", async () => {
mocks.probeToolInstallations.mockImplementation(async (tools: string[]) =>
tools.map((tool) => report(tool, { unmanaged: tool === "claude" })),
);
await renderApps();
fireEvent.click(updateAllButton());
await waitFor(() => expect(isReady("Codex")).toBe(true));
expect(isReady("Gemini CLI")).toBe(true);
expect(updateButton("Claude Code")).toBeEnabled();
expect(
mocks.runToolLifecycleAction.mock.calls.map(([tools]) => tools),
).toEqual([["codex"], ["gemini"]]);
expect(
screen.getByRole("button", { name: "appsPage.checkUpdates" }),
).toBeEnabled();
expect(mocks.warning).toHaveBeenCalledWith(
"settings.toolUpgradeUnmanagedTitle",
expect.anything(),
);
});
it("keeps the header steady while checking for updates", async () => {
await renderApps();
expect(updateAllButton()).toBeEnabled();
const pending = deferred<void>();
const getVersions = mocks.getToolVersions.getMockImplementation()!;
mocks.getToolVersions.mockImplementation(async (tools: string[]) => {
await pending.promise;
return getVersions(tools);
});
fireEvent.click(
screen.getByRole("button", { name: "appsPage.checkUpdates" }),
);
// 检查期间各工具都在加载:「全部升级」保持显示(禁用),不先消失再出现
expect(
await screen.findByRole("button", { name: "appsPage.checking" }),
).toBeDisabled();
expect(updateAllButton()).toBeDisabled();
await act(async () => pending.resolve());
expect(updateAllButton()).toBeEnabled();
expect(
screen.getByRole("button", { name: "appsPage.checkUpdates" }),
).toBeEnabled();
});
it("withholds the upgrade when the new version needs a newer Node and leaves it out of update all", async () => {
const defaultVersions = mocks.getToolVersions.getMockImplementation()!;
mocks.getToolVersions.mockImplementation(async (tools: string[]) =>
(await defaultVersions(tools)).map(
(tool: { name: string; version: string | null }) => ({
...tool,
update_blocked_by_node:
tool.name === "claude" && tool.version === "1.0.0"
? { required: ">=24.16.0 <25 || >=26.1.0", current: "22.23.2" }
: null,
}),
),
);
await renderApps();
await waitFor(() =>
expect(
card("Claude Code").getByText("appsPage.updateNeedsNode"),
).toBeInTheDocument(),
);
expect(
card("Claude Code").queryByRole("button", {
name: "settings.toolUpdate",
}),
).toBeNull();
fireEvent.click(updateAllButton());
await waitFor(() => expect(isReady("Codex")).toBe(true));
expect(isReady("Gemini CLI")).toBe(true);
expect(
mocks.runToolLifecycleAction.mock.calls.map(([tools]) => tools),
).toEqual([["codex"], ["gemini"]]);
});
it("shows diagnosed conflicts on the row, refreshes the install report and says so", async () => {
vi.resetModules();
const install = (path: string, isDefault: boolean) => ({
path,
version: "2.0.0",
runnable: true,
error: null,
source: "npm",
is_path_default: isDefault,
});
// 打开页面时只有一处安装;之后外部又装了一份,诊断才发现
mocks.probeToolInstallations.mockImplementation(async (tools: string[]) =>
tools.map((tool) =>
tool === "claude"
? report(tool, {
is_conflict: true,
installs: [
install("/usr/local/bin/claude", true),
install("/opt/homebrew/bin/claude", false),
],
})
: report(tool),
),
);
await renderApps();
expect(screen.queryByText("/opt/homebrew/bin/claude")).toBeNull();
await userEvent.click(
screen.getByRole("button", { name: "appsPage.moreActions" }),
);
await userEvent.click(
await screen.findByRole("menuitem", { name: "settings.toolDiagnose" }),
);
// 冲突列表直接在行里展开,入口「另有 1 处安装」也刷新出来,并有一条提示
expect(
await card("Claude Code").findByText("/opt/homebrew/bin/claude"),
).toBeInTheDocument();
expect(
card("Claude Code").getByRole("button", {
name: /appsPage\.otherInstalls/,
}),
).toHaveAttribute("aria-expanded", "true");
expect(mocks.warning).toHaveBeenCalledWith(
"settings.toolDiagnoseConflicts",
expect.anything(),
);
expect(mocks.info).not.toHaveBeenCalled();
});
});
describe("AppsPage visibility column", () => {
it("renders the sidebar visibility switches small with the action color", async () => {
vi.resetModules();
await renderApps();
const switches = screen.getAllByRole("switch", {
name: /appsPage\.showInSidebar/,
});
expect(switches.length).toBeGreaterThan(0);
for (const element of switches) {
expect(element).toHaveAttribute("data-size", "sm");
expect(element).not.toHaveAttribute("data-tone");
}
});
});