Files
cc-switch/tests/components/OpenTerminalDialog.test.tsx
Jason 0af8b77288 feat(agy): bind official cards to an Antigravity account
An official Antigravity card can now pick one of the Auth Center accounts
(macOS only). With an account picked, Open Terminal first switches agy's
global sign-in to it; when it is already signed in, nothing is written.
Without one, the card keeps following whatever agy is signed in with.

The binding is stored as authBinding with authProvider "agy_oauth". The
account id is the Google subject, so a card synced to another computer
works once the same account is added there; if it is missing, opening the
terminal stops with a message instead of launching under another account.
Platforms that cannot switch ignore the binding and keep it on the card.

The Open Terminal dialog says when opening will switch agy's sign-in, and
warns when agy is already running. The Auth Center counts bound cards as
users of an account, so the removal dialog lists them.
2026-10-21 17:04:27 +08:00

232 lines
7.1 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { OpenTerminalDialog } from "@/components/providers/OpenTerminalDialog";
import type { Provider } from "@/types";
const openTerminalMock = vi.fn();
const pickDirectoryMock = vi.fn();
const agyStatusMock = vi.fn();
const agyRunningMock = vi.fn();
vi.mock("@/lib/api", () => ({
providersApi: {
openTerminal: (...args: unknown[]) => openTerminalMock(...args),
},
settingsApi: {
pickDirectory: (...args: unknown[]) => pickDirectoryMock(...args),
},
authApi: {
authGetStatus: (...args: unknown[]) => agyStatusMock(...args),
agyAccountIsRunning: () => agyRunningMock(),
},
}));
vi.mock("@/lib/platform", () => ({ isMac: () => true }));
vi.mock("@/lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
const STORAGE_KEY = "cc-switch:terminal:recent-dirs";
const provider = {
id: "p1",
name: "Relay",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://relay.example.com",
ANTHROPIC_AUTH_TOKEN: "sk-test",
},
},
} as Provider;
function renderDialog(
onClose = vi.fn(),
target: Provider = provider,
appId: "claude" | "agy" = "claude",
) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={client}>
<OpenTerminalDialog provider={target} appId={appId} onClose={onClose} />
</QueryClientProvider>,
);
return onClose;
}
describe("OpenTerminalDialog", () => {
beforeEach(() => {
window.localStorage.clear();
openTerminalMock.mockReset().mockResolvedValue(true);
pickDirectoryMock.mockReset();
agyStatusMock.mockReset().mockResolvedValue({
provider: "agy_oauth",
authenticated: true,
default_account_id: "a",
accounts: [
{ id: "a", login: "a@example.com" },
{ id: "b", login: "b@example.com" },
],
});
agyRunningMock.mockReset().mockResolvedValue(false);
});
const agyOfficial = (accountId?: string) =>
({
id: "agy-official",
name: "Google Official",
category: "official",
settingsConfig: { env: {} },
meta: accountId
? {
authBinding: {
source: "managed_account",
authProvider: "agy_oauth",
accountId,
},
}
: undefined,
}) as Provider;
it("says agy's sign-in will switch when the card is bound to another account", async () => {
agyRunningMock.mockResolvedValue(true);
renderDialog(vi.fn(), agyOfficial("b"), "agy");
expect(
await screen.findByText(/打开前把 agy 的登录切换到 b@example.com/),
).toBeInTheDocument();
expect(await screen.findByText(/检测到 agy 正在运行/)).toBeInTheDocument();
});
it("stays quiet when the bound account is already signed in, or nothing is bound", async () => {
renderDialog(vi.fn(), agyOfficial("a"), "agy");
await waitFor(() => expect(agyStatusMock).toHaveBeenCalled());
expect(screen.queryByText(/切换到/)).not.toBeInTheDocument();
agyStatusMock.mockClear();
renderDialog(vi.fn(), agyOfficial(), "agy");
expect(agyStatusMock).not.toHaveBeenCalled();
});
it("explains first and only opens the folder picker on request", async () => {
pickDirectoryMock.mockResolvedValue("/work/alpha");
const onClose = renderDialog();
expect(pickDirectoryMock).not.toHaveBeenCalled();
const confirm = screen.getByRole("button", {
name: "provider.openTerminalDialog.confirm",
});
expect(confirm).toBeDisabled();
fireEvent.click(
screen.getByRole("button", {
name: "provider.openTerminalDialog.choose",
}),
);
await waitFor(() => expect(confirm).toBeEnabled());
fireEvent.click(confirm);
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(openTerminalMock).toHaveBeenCalledWith("p1", "claude", {
cwd: "/work/alpha",
});
expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY)!)).toEqual([
"/work/alpha",
]);
});
it("prefills the last folder and moves a picked recent one to the front", async () => {
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify(["/work/alpha", "/work/beta"]),
);
const onClose = renderDialog();
// 上次的目录直接填好,另一个在「最近用过」里
expect(screen.getByTitle("/work/alpha")).toBeInTheDocument();
fireEvent.click(screen.getByTitle("/work/beta"));
fireEvent.click(
screen.getByRole("button", {
name: "provider.openTerminalDialog.confirm",
}),
);
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(openTerminalMock).toHaveBeenCalledWith("p1", "claude", {
cwd: "/work/beta",
});
expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY)!)).toEqual([
"/work/beta",
"/work/alpha",
]);
});
it("keeps the dialog open and the list unchanged when opening fails", async () => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(["/gone"]));
openTerminalMock.mockRejectedValue(new Error("目录不存在: /gone"));
const onClose = renderDialog();
fireEvent.click(
screen.getByRole("button", {
name: "provider.openTerminalDialog.confirm",
}),
);
await waitFor(() => expect(openTerminalMock).toHaveBeenCalled());
expect(onClose).not.toHaveBeenCalled();
expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY)!)).toEqual([
"/gone",
]);
});
it("shows the isolation notice only when the card carries its own address and key", () => {
renderDialog();
expect(
screen.getByText("provider.openTerminalDialog.isolationTitle"),
).toBeInTheDocument();
});
// 官方登录卡 env 为空:终端跟着 ~/.claude/settings.json 和全局登录走,不能说隔离
it("hides the isolation notice for an official login card", () => {
renderDialog(vi.fn(), {
id: "official",
name: "Claude Official",
settingsConfig: { env: {} },
} as Provider);
expect(
screen.queryByText("provider.openTerminalDialog.isolationTitle"),
).not.toBeInTheDocument();
});
// agy 带 Key 的卡用自己的私有目录启动;官方登录卡跟着全局登录走
it("treats only Antigravity key cards as isolated", () => {
renderDialog(
vi.fn(),
{
id: "relay",
name: "Relay",
settingsConfig: { env: { GEMINI_API_KEY: "k" } },
} as Provider,
"agy",
);
expect(
screen.getByText("provider.openTerminalDialog.isolationTitle"),
).toBeInTheDocument();
});
it("hides the isolation notice for the Antigravity login card", () => {
renderDialog(
vi.fn(),
{
id: "login",
name: "Google",
category: "official",
settingsConfig: { env: {}, config: { model: { name: "m" } } },
} as Provider,
"agy",
);
expect(
screen.queryByText("provider.openTerminalDialog.isolationTitle"),
).not.toBeInTheDocument();
});
});