mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
CC Switch can only reach the agy it starts itself: a third-party key travels in that process's environment, so "enabling" a key card never changed what a plain `agy` used. Antigravity cards now only launch. - Every card's primary action is Open Terminal; there is no "in use" state, the official quota shows on official cards at all times, and any card can be deleted. - Official cards start `agy --model=<display name>`, which applies to that session only. Key cards keep their private --gemini_dir. - The real ~/.gemini/antigravity-cli/settings.json is never written any more: switching is refused, adding the first card, full syncs and deep links with enabled=true leave it alone. The only remaining write removes the modelProvider left behind by the #8268 preview. - The terminal dialog and form hints no longer talk about switching.
897 lines
30 KiB
TypeScript
897 lines
30 KiB
TypeScript
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
QueryClient,
|
|
QueryClientProvider,
|
|
useQuery,
|
|
} from "@tanstack/react-query";
|
|
import { Profiler } from "react";
|
|
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
|
import { http, HttpResponse } from "msw";
|
|
import i18n from "i18next";
|
|
import { emitTauriEvent } from "../msw/tauriMocks";
|
|
import { server } from "../msw/server";
|
|
import { peekPendingDeepLink, setPendingDeepLink } from "../msw/state";
|
|
import { pngDataUri } from "../fixtures/customIcon";
|
|
import { toast } from "@/lib/toast";
|
|
|
|
// jsdom 没有 canvas,重新编码这一步换成可控的假实现
|
|
const { normalizeIconDataUri } = vi.hoisted(() => ({
|
|
normalizeIconDataUri: vi.fn<(dataUri: string) => Promise<string>>(),
|
|
}));
|
|
vi.mock("@/utils/customIcon", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@/utils/customIcon")>()),
|
|
normalizeIconDataUri,
|
|
}));
|
|
|
|
// 模拟后端:先暂存链接,再发 deeplink-pending 通知前端来取
|
|
const deliverDeepLink = (request: Record<string, unknown>) => {
|
|
setPendingDeepLink({ kind: "import", request });
|
|
emitTauriEvent("deeplink-pending", null);
|
|
};
|
|
|
|
vi.mock("@/components/ui/dialog", () => ({
|
|
Dialog: ({ children }: { children: React.ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
DialogContent: ({ children }: { children: React.ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
DialogHeader: ({ children }: { children: React.ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
DialogTitle: ({ children }: { children: React.ReactNode }) => (
|
|
<h1>{children}</h1>
|
|
),
|
|
DialogDescription: ({ children }: { children: React.ReactNode }) => (
|
|
<p>{children}</p>
|
|
),
|
|
DialogFooter: ({ children }: { children: React.ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
}));
|
|
|
|
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
{children}
|
|
</QueryClientProvider>
|
|
);
|
|
|
|
describe("DeepLinkImportDialog", () => {
|
|
it.each([
|
|
{ app: "opencode", enabled: true, fails: false, refreshes: true },
|
|
{ app: "opencode", enabled: false, fails: false, refreshes: true },
|
|
{ app: "opencode", enabled: true, fails: true, refreshes: false },
|
|
{ app: "claude", enabled: true, fails: false, refreshes: false },
|
|
])(
|
|
"refreshes OpenCode live data only after its successful import: %j",
|
|
async ({ app, enabled, fails, refreshes }) => {
|
|
const success = vi.spyOn(toast, "success");
|
|
const failure = vi.spyOn(toast, "error");
|
|
const client = new QueryClient({
|
|
defaultOptions: { queries: { staleTime: Infinity, retry: false } },
|
|
});
|
|
let liveIds: string[] = [];
|
|
let models: string[] = [];
|
|
const readLiveIds = vi.fn(async () => liveIds);
|
|
const readModels = vi.fn(async () => models);
|
|
const importProvider = vi.fn(async () => {
|
|
if (fails)
|
|
return HttpResponse.json(
|
|
{ message: "Import failed" },
|
|
{ status: 500 },
|
|
);
|
|
if (app === "opencode" && enabled) {
|
|
liveIds = ["imported-provider"];
|
|
models = ["imported-provider/model"];
|
|
}
|
|
return HttpResponse.json({ type: "provider", id: "imported-provider" });
|
|
});
|
|
server.use(
|
|
http.post(
|
|
"http://tauri.local/import_from_deeplink_unified",
|
|
importProvider,
|
|
),
|
|
);
|
|
function LiveState() {
|
|
const ids = useQuery({
|
|
queryKey: ["opencodeLiveProviderIds"],
|
|
queryFn: readLiveIds,
|
|
});
|
|
const runtimeModels = useQuery({
|
|
queryKey: ["opencode", "runtime-models"],
|
|
queryFn: readModels,
|
|
});
|
|
return (
|
|
<>
|
|
<output data-testid="live-ids">{JSON.stringify(ids.data)}</output>
|
|
<output data-testid="runtime-models">
|
|
{JSON.stringify(runtimeModels.data)}
|
|
</output>
|
|
</>
|
|
);
|
|
}
|
|
render(
|
|
<QueryClientProvider client={client}>
|
|
<LiveState />
|
|
<DeepLinkImportDialog />
|
|
</QueryClientProvider>,
|
|
);
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("live-ids")).toHaveTextContent("[]"),
|
|
);
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("runtime-models")).toHaveTextContent("[]"),
|
|
);
|
|
act(() =>
|
|
deliverDeepLink({
|
|
version: "v1",
|
|
resource: "provider",
|
|
app,
|
|
enabled,
|
|
name: "Imported Provider",
|
|
endpoint: "https://example.com/v1",
|
|
apiKey: "public-test-key",
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
await user.click(
|
|
await screen.findByRole("button", { name: "deeplink.import" }),
|
|
);
|
|
await waitFor(() => expect(importProvider).toHaveBeenCalledTimes(1));
|
|
if (fails) {
|
|
await waitFor(() => expect(failure).toHaveBeenCalled());
|
|
} else {
|
|
await waitFor(() => expect(success).toHaveBeenCalled());
|
|
}
|
|
expect(readLiveIds).toHaveBeenCalledTimes(refreshes ? 2 : 1);
|
|
expect(readModels).toHaveBeenCalledTimes(refreshes ? 2 : 1);
|
|
const shouldBeLive = refreshes && enabled;
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("live-ids")).toHaveTextContent(
|
|
shouldBeLive ? '["imported-provider"]' : "[]",
|
|
),
|
|
);
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("runtime-models")).toHaveTextContent(
|
|
shouldBeLive ? '["imported-provider/model"]' : "[]",
|
|
),
|
|
);
|
|
client.clear();
|
|
success.mockRestore();
|
|
failure.mockRestore();
|
|
},
|
|
);
|
|
|
|
it("renders masked usage access token and user id for provider imports", async () => {
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
|
|
act(() => {
|
|
deliverDeepLink({
|
|
version: "v1",
|
|
resource: "provider",
|
|
app: "claude",
|
|
name: "Test Provider",
|
|
homepage: "https://example.com",
|
|
endpoint: "https://api.example.com",
|
|
apiKey: "sk-provider-key",
|
|
usageEnabled: true,
|
|
usageScript: btoa("console.log('usage');"),
|
|
usageApiKey: "sk-usage-key",
|
|
usageBaseUrl: "https://usage.example.com",
|
|
usageAccessToken: "pat-secret-token",
|
|
usageUserId: "user-12345",
|
|
usageAutoInterval: 60,
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("用量访问令牌")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByText("用量用户 ID")).toBeInTheDocument();
|
|
expect(screen.getByText("user-12345")).toBeInTheDocument();
|
|
// Masked: first 4 chars + 12 stars
|
|
expect(screen.getByText("pat-************")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows usage credentials even when the deeplink carries no usageScript", async () => {
|
|
// 后端 build_provider_meta 在任一 usage 字段存在时即持久化(含 access_token
|
|
// 与 user_id)。若对话框只在 usageScript 存在时开门,这条链接会把凭据静默
|
|
// 写进供应商配置。撤销门槛 widening(恢复只按 usageScript 开门)本测试即失败。
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
|
|
act(() => {
|
|
deliverDeepLink({
|
|
version: "v1",
|
|
resource: "provider",
|
|
app: "claude",
|
|
name: "Token Only Provider",
|
|
homepage: "https://example.com",
|
|
endpoint: "https://api.example.com",
|
|
apiKey: "sk-provider-key",
|
|
usageAccessToken: "pat-secret-token",
|
|
usageUserId: "user-12345",
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("用量访问令牌")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByText("pat-************")).toBeInTheDocument();
|
|
expect(screen.getByText("用量用户 ID")).toBeInTheDocument();
|
|
expect(screen.getByText("user-12345")).toBeInTheDocument();
|
|
// 没有脚本就不应渲染脚本执行警告与脚本代码区
|
|
expect(
|
|
screen.queryByText(
|
|
"这是一段 JavaScript 代码,启用后会在查询用量时执行。请确认来源可信后再导入。",
|
|
),
|
|
).not.toBeInTheDocument();
|
|
expect(screen.queryByText("脚本代码")).not.toBeInTheDocument();
|
|
});
|
|
|
|
// 徽章要和后端 `usage_enabled.unwrap_or(!code.is_empty())` 一致:
|
|
// 链接写了 usageEnabled 就照办,没写时带脚本即启用
|
|
it.each([
|
|
{ usageEnabled: undefined, expected: "已启用" },
|
|
{ usageEnabled: true, expected: "已启用" },
|
|
{ usageEnabled: false, expected: "未启用" },
|
|
])(
|
|
"shows the usage badge as $expected when usageEnabled is $usageEnabled and a script is attached",
|
|
async ({ usageEnabled, expected }) => {
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
|
|
act(() => {
|
|
deliverDeepLink({
|
|
version: "v1",
|
|
resource: "provider",
|
|
app: "claude",
|
|
name: "Usage Provider",
|
|
homepage: "https://example.com",
|
|
endpoint: "https://api.example.com",
|
|
apiKey: "sk-provider-key",
|
|
usageScript: btoa("console.log('usage');"),
|
|
...(usageEnabled === undefined ? {} : { usageEnabled }),
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(expected)).toBeInTheDocument();
|
|
});
|
|
},
|
|
);
|
|
|
|
// 套餐地址、不带脚本:后端按套餐额度查询并默认启用,徽章与后端一致
|
|
it.each([
|
|
{
|
|
app: "codex",
|
|
endpoint: "https://api.kimi.com/coding/v1",
|
|
usageEnabled: undefined,
|
|
expected: "已启用",
|
|
},
|
|
{
|
|
app: "codex",
|
|
endpoint: "https://api.kimi.com/coding/v1",
|
|
usageEnabled: false,
|
|
expected: "未启用",
|
|
},
|
|
])(
|
|
"shows the plan quota badge as $expected for $app with usageEnabled $usageEnabled",
|
|
async ({ app, endpoint, usageEnabled, expected }) => {
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
|
|
act(() => {
|
|
deliverDeepLink({
|
|
version: "v1",
|
|
resource: "provider",
|
|
app,
|
|
name: "Plan Provider",
|
|
homepage: "https://example.com",
|
|
endpoint,
|
|
apiKey: "sk-provider-key",
|
|
...(usageEnabled === undefined ? {} : { usageEnabled }),
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(expected)).toBeInTheDocument();
|
|
});
|
|
expect(screen.getByText("套餐额度:Kimi For Coding")).toBeInTheDocument();
|
|
},
|
|
);
|
|
|
|
it("does not show a usage row for ZenMux outside Claude", async () => {
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
|
|
act(() => {
|
|
deliverDeepLink({
|
|
version: "v1",
|
|
resource: "provider",
|
|
app: "codex",
|
|
name: "ZenMux Provider",
|
|
homepage: "https://example.com",
|
|
endpoint: "https://zenmux.ai/api/v1",
|
|
apiKey: "sk-provider-key",
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("ZenMux Provider")).toBeInTheDocument();
|
|
});
|
|
expect(screen.queryByText("用量查询")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows a link that arrived before the dialog mounted (cold start)", async () => {
|
|
// 冷启动时链接早于前端监听到达,后端已暂存;组件挂载后必须主动取一次
|
|
setPendingDeepLink({
|
|
kind: "import",
|
|
request: {
|
|
version: "v1",
|
|
resource: "provider",
|
|
app: "claude",
|
|
name: "Cold Start Provider",
|
|
homepage: "https://example.com",
|
|
endpoint: "https://api.example.com",
|
|
apiKey: "sk-provider-key",
|
|
},
|
|
});
|
|
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Cold Start Provider")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("warns that enabled=true switches to the provider right after import", async () => {
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
|
|
act(() => {
|
|
deliverDeepLink({
|
|
version: "v1",
|
|
resource: "provider",
|
|
app: "claude",
|
|
name: "Switching Provider",
|
|
homepage: "https://example.com",
|
|
endpoint: "https://api.example.com",
|
|
apiKey: "sk-provider-key",
|
|
enabled: true,
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText("deeplink.providerEnabledWarning"),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("does not show the switch warning when enabled is absent", async () => {
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
|
|
act(() => {
|
|
deliverDeepLink({
|
|
version: "v1",
|
|
resource: "provider",
|
|
app: "claude",
|
|
name: "Add Only Provider",
|
|
homepage: "https://example.com",
|
|
endpoint: "https://api.example.com",
|
|
apiKey: "sk-provider-key",
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Add Only Provider")).toBeInTheDocument();
|
|
});
|
|
expect(
|
|
screen.queryByText("deeplink.providerEnabledWarning"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
describe("the activate-after-import switch", () => {
|
|
// 记下导入请求里的 enabled:开关的值必须覆盖链接里的 enabled
|
|
const captureImport = () => {
|
|
const sent: Array<Record<string, unknown>> = [];
|
|
server.use(
|
|
http.post(
|
|
"http://tauri.local/import_from_deeplink_unified",
|
|
async ({ request }) => {
|
|
const body = (await request.json()) as {
|
|
request: Record<string, unknown>;
|
|
};
|
|
sent.push(body.request);
|
|
return HttpResponse.json(
|
|
body.request.resource === "prompt"
|
|
? { type: "prompt", id: "p1" }
|
|
: { type: "provider", id: "p1" },
|
|
);
|
|
},
|
|
),
|
|
);
|
|
return sent;
|
|
};
|
|
|
|
const providerLink = (extra: Record<string, unknown>) => ({
|
|
version: "v1",
|
|
resource: "provider",
|
|
name: "Toggle Provider",
|
|
homepage: "https://example.com",
|
|
endpoint: "https://api.example.com",
|
|
apiKey: "sk-provider-key",
|
|
...extra,
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
resource: "provider",
|
|
app: "opencode",
|
|
},
|
|
{
|
|
resource: "prompt",
|
|
app: "claude",
|
|
content: btoa("# review"),
|
|
enabled: true,
|
|
},
|
|
])(
|
|
"shows the correct activation state on the first $resource render",
|
|
async (link) => {
|
|
const renderedStates: Array<string | null> = [];
|
|
render(
|
|
<Profiler
|
|
id="deeplink-activation"
|
|
onRender={() => {
|
|
// Observe every committed render, before passive effects can fix a stale value.
|
|
const toggle = screen.queryByRole("switch");
|
|
if (toggle)
|
|
renderedStates.push(toggle.getAttribute("aria-checked"));
|
|
}}
|
|
>
|
|
<DeepLinkImportDialog />
|
|
</Profiler>,
|
|
{ wrapper: Wrapper },
|
|
);
|
|
act(() => deliverDeepLink(providerLink(link)));
|
|
|
|
await screen.findByRole("switch");
|
|
expect(renderedStates.length).toBeGreaterThan(0);
|
|
expect(new Set(renderedStates)).toEqual(new Set(["true"]));
|
|
},
|
|
);
|
|
|
|
it("starts from the link's enabled and lets the user turn switching off", async () => {
|
|
const sent = captureImport();
|
|
const user = userEvent.setup();
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
act(() =>
|
|
deliverDeepLink(providerLink({ app: "claude", enabled: true })),
|
|
);
|
|
|
|
const toggle = await screen.findByRole("switch");
|
|
expect(toggle).toBeChecked();
|
|
expect(
|
|
screen.getByText("deeplink.providerEnabledWarning"),
|
|
).toBeInTheDocument();
|
|
|
|
await user.click(toggle);
|
|
expect(toggle).not.toBeChecked();
|
|
expect(
|
|
screen.queryByText("deeplink.providerEnabledWarning"),
|
|
).not.toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole("button", { name: "deeplink.import" }));
|
|
await waitFor(() => expect(sent).toHaveLength(1));
|
|
expect(sent[0].enabled).toBe(false);
|
|
});
|
|
|
|
it("lets the user opt into switching when the link does not ask for it", async () => {
|
|
const sent = captureImport();
|
|
const user = userEvent.setup();
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
act(() => deliverDeepLink(providerLink({ app: "codex" })));
|
|
|
|
const toggle = await screen.findByRole("switch");
|
|
expect(toggle).not.toBeChecked();
|
|
expect(
|
|
screen.getByText("deeplink.switchAfterImport"),
|
|
).toBeInTheDocument();
|
|
|
|
await user.click(toggle);
|
|
expect(
|
|
screen.getByText("deeplink.providerEnabledWarning"),
|
|
).toBeInTheDocument();
|
|
await user.click(screen.getByRole("button", { name: "deeplink.import" }));
|
|
await waitFor(() => expect(sent).toHaveLength(1));
|
|
expect(sent[0].enabled).toBe(true);
|
|
});
|
|
|
|
it("only imports Antigravity providers, even when the link asks to enable", async () => {
|
|
const sent = captureImport();
|
|
const user = userEvent.setup();
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
act(() => deliverDeepLink(providerLink({ app: "agy", enabled: true })));
|
|
|
|
const importButton = await screen.findByRole("button", {
|
|
name: "deeplink.import",
|
|
});
|
|
// Antigravity 的卡没有「启用」,不给开关也不提示改发请求
|
|
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
|
|
expect(
|
|
screen.queryByText("deeplink.providerEnabledWarning"),
|
|
).not.toBeInTheDocument();
|
|
await user.click(importButton);
|
|
await waitFor(() => expect(sent).toHaveLength(1));
|
|
expect(sent[0].enabled).toBe(false);
|
|
});
|
|
|
|
it("adds additive-app providers to the app config unless the link opts out", async () => {
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
act(() => deliverDeepLink(providerLink({ app: "opencode" })));
|
|
|
|
const toggle = await screen.findByRole("switch");
|
|
expect(toggle).toBeChecked();
|
|
expect(
|
|
screen.getByText("deeplink.addToAppAfterImport"),
|
|
).toBeInTheDocument();
|
|
// 累加式应用没有「切换」,不提示改发请求
|
|
expect(
|
|
screen.queryByText("deeplink.providerEnabledWarning"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("lets the user decide whether an imported prompt is enabled", async () => {
|
|
const sent = captureImport();
|
|
const user = userEvent.setup();
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
act(() =>
|
|
deliverDeepLink({
|
|
version: "v1",
|
|
resource: "prompt",
|
|
app: "claude",
|
|
name: "Review Prompt",
|
|
content: btoa("# review"),
|
|
enabled: true,
|
|
}),
|
|
);
|
|
|
|
const toggle = await screen.findByRole("switch");
|
|
expect(toggle).toBeChecked();
|
|
expect(
|
|
screen.getByText("deeplink.prompt.enabledWarning"),
|
|
).toBeInTheDocument();
|
|
|
|
await user.click(toggle);
|
|
await user.click(screen.getByRole("button", { name: "deeplink.import" }));
|
|
await waitFor(() => expect(sent).toHaveLength(1));
|
|
expect(sent[0].enabled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("the link's iconUrl", () => {
|
|
const DOWNLOADED = "data:image/svg+xml;base64,PHN2Zy8+";
|
|
const NORMALIZED = pngDataUri(96, 96);
|
|
|
|
const iconLink = (extra: Record<string, unknown> = {}) => ({
|
|
version: "v1",
|
|
resource: "provider",
|
|
app: "claude",
|
|
name: "Icon Provider",
|
|
endpoint: "https://api.example.com",
|
|
apiKey: "sk-provider-key",
|
|
iconUrl: "https://cdn.example.com/logo.svg",
|
|
...extra,
|
|
});
|
|
|
|
// 记下下载请求和导入请求;download 决定后端返回什么
|
|
const mockBackend = (download: () => Promise<string | null>) => {
|
|
const fetched: string[] = [];
|
|
const sent: Array<Record<string, unknown>> = [];
|
|
server.use(
|
|
http.post(
|
|
"http://tauri.local/fetch_deeplink_icon",
|
|
async ({ request }) => {
|
|
const { url } = (await request.json()) as { url: string };
|
|
fetched.push(url);
|
|
return HttpResponse.json(await download());
|
|
},
|
|
),
|
|
http.post(
|
|
"http://tauri.local/import_from_deeplink_unified",
|
|
async ({ request }) => {
|
|
const body = (await request.json()) as {
|
|
request: Record<string, unknown>;
|
|
};
|
|
sent.push(body.request);
|
|
return HttpResponse.json({ type: "provider", id: "p1" });
|
|
},
|
|
),
|
|
);
|
|
return { fetched, sent };
|
|
};
|
|
|
|
afterEach(() => {
|
|
normalizeIconDataUri.mockReset();
|
|
});
|
|
|
|
it("downloads the icon on open, previews it and imports it without asking", async () => {
|
|
normalizeIconDataUri.mockResolvedValue(NORMALIZED);
|
|
const { fetched, sent } = mockBackend(async () => DOWNLOADED);
|
|
const user = userEvent.setup();
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
act(() => deliverDeepLink(iconLink({ icon: "deepseek" })));
|
|
|
|
const preview = await screen.findByAltText("Icon Provider");
|
|
expect(preview).toHaveAttribute("src", NORMALIZED);
|
|
expect(fetched).toEqual(["https://cdn.example.com/logo.svg"]);
|
|
expect(normalizeIconDataUri).toHaveBeenCalledWith(DOWNLOADED);
|
|
|
|
await user.click(screen.getByRole("button", { name: "deeplink.import" }));
|
|
await waitFor(() => expect(sent).toHaveLength(1));
|
|
expect(sent[0].icon).toBe(NORMALIZED);
|
|
expect(sent[0]).not.toHaveProperty("iconUrl");
|
|
});
|
|
|
|
it("falls back to the built-in icon when the download fails", async () => {
|
|
const { sent } = mockBackend(async () => null);
|
|
const user = userEvent.setup();
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
act(() => deliverDeepLink(iconLink({ icon: "deepseek" })));
|
|
|
|
await user.click(
|
|
await screen.findByRole("button", { name: "deeplink.import" }),
|
|
);
|
|
await waitFor(() => expect(sent).toHaveLength(1));
|
|
expect(sent[0].icon).toBe("deepseek");
|
|
expect(normalizeIconDataUri).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("imports without an icon when re-encoding fails and there is no fallback", async () => {
|
|
normalizeIconDataUri.mockRejectedValue(new Error("tainted canvas"));
|
|
const { sent } = mockBackend(async () => DOWNLOADED);
|
|
const user = userEvent.setup();
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
act(() => deliverDeepLink(iconLink()));
|
|
|
|
// 首字母占位
|
|
expect(await screen.findByText("IP")).toBeInTheDocument();
|
|
await user.click(screen.getByRole("button", { name: "deeplink.import" }));
|
|
await waitFor(() => expect(sent).toHaveLength(1));
|
|
expect(sent[0].icon).toBeUndefined();
|
|
});
|
|
|
|
it("waits for a download still in flight when the user imports", async () => {
|
|
normalizeIconDataUri.mockResolvedValue(NORMALIZED);
|
|
let finishDownload!: (value: string) => void;
|
|
const download = new Promise<string>((resolve) => {
|
|
finishDownload = resolve;
|
|
});
|
|
const { fetched, sent } = mockBackend(() => download);
|
|
const user = userEvent.setup();
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
act(() => deliverDeepLink(iconLink()));
|
|
|
|
await waitFor(() => expect(fetched).toHaveLength(1));
|
|
await user.click(screen.getByRole("button", { name: "deeplink.import" }));
|
|
// 下载没完成之前不提交
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
expect(sent).toHaveLength(0);
|
|
|
|
finishDownload(DOWNLOADED);
|
|
await waitFor(() => expect(sent).toHaveLength(1));
|
|
expect(sent[0].icon).toBe(NORMALIZED);
|
|
});
|
|
|
|
it("does not download for links without iconUrl", async () => {
|
|
const { fetched } = mockBackend(async () => DOWNLOADED);
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
act(() =>
|
|
deliverDeepLink(iconLink({ iconUrl: undefined, icon: "deepseek" })),
|
|
);
|
|
|
|
await screen.findByRole("button", { name: "deeplink.import" });
|
|
expect(fetched).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("when processing is interrupted after the link was read", () => {
|
|
const coldStartLink = {
|
|
kind: "import",
|
|
request: {
|
|
version: "v1",
|
|
resource: "provider",
|
|
app: "claude",
|
|
name: "Interrupted Provider",
|
|
homepage: "https://example.com",
|
|
endpoint: "https://api.example.com",
|
|
apiKey: "sk-provider-key",
|
|
},
|
|
};
|
|
|
|
// 让 peek 的响应停在半路:返回 hit(请求已到达)与 release(放行响应)
|
|
const holdPeek = () => {
|
|
let release!: () => void;
|
|
const gate = new Promise<void>((resolve) => (release = resolve));
|
|
let markHit!: () => void;
|
|
const hit = new Promise<void>((resolve) => (markHit = resolve));
|
|
server.use(
|
|
http.post("http://tauri.local/peek_pending_deeplink", async () => {
|
|
const entry = peekPendingDeepLink();
|
|
markHit();
|
|
await gate;
|
|
return HttpResponse.json(entry);
|
|
}),
|
|
);
|
|
return { hit, release };
|
|
};
|
|
|
|
afterEach(async () => {
|
|
await act(async () => {
|
|
await i18n.changeLanguage("zh");
|
|
});
|
|
});
|
|
|
|
it("still shows the link when the language changes mid-read", async () => {
|
|
// 启动时按后端设置校正语言会换掉 t;effect 若随 t 重建,读到一半的链接就被丢下
|
|
setPendingDeepLink(coldStartLink);
|
|
const { hit, release } = holdPeek();
|
|
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
await hit;
|
|
await act(async () => {
|
|
await i18n.changeLanguage("en");
|
|
});
|
|
release();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Interrupted Provider")).toBeInTheDocument();
|
|
});
|
|
await waitFor(() => expect(peekPendingDeepLink()).toBeNull());
|
|
});
|
|
|
|
it("keeps the link for the next mount when unmounted mid-read", async () => {
|
|
// 窗口销毁重建:旧实例读到了但没显示,链接必须留在后端给新实例
|
|
setPendingDeepLink(coldStartLink);
|
|
const { hit, release } = holdPeek();
|
|
|
|
const first = render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
await hit;
|
|
first.unmount();
|
|
release();
|
|
server.resetHandlers();
|
|
|
|
expect(peekPendingDeepLink()).not.toBeNull();
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Interrupted Provider")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("does not let a late read of an older link replace a newer one", async () => {
|
|
// A 的读取响应迟到;期间 B 到达。若读取可以并发,B 先显示并被清除,
|
|
// A 随后返回把 B 盖掉,B 从界面消失、后端也取不回来了
|
|
const providerLink = (name: string) => ({
|
|
...coldStartLink,
|
|
request: { ...coldStartLink.request, name },
|
|
});
|
|
setPendingDeepLink(providerLink("Older Provider"));
|
|
|
|
let release!: () => void;
|
|
const gate = new Promise<void>((resolve) => (release = resolve));
|
|
let markHit!: () => void;
|
|
const hit = new Promise<void>((resolve) => (markHit = resolve));
|
|
let calls = 0;
|
|
server.use(
|
|
http.post("http://tauri.local/peek_pending_deeplink", async () => {
|
|
const entry = peekPendingDeepLink();
|
|
if (++calls === 1) {
|
|
markHit();
|
|
await gate;
|
|
}
|
|
return HttpResponse.json(entry);
|
|
}),
|
|
);
|
|
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
await hit;
|
|
act(() => {
|
|
deliverDeepLink(providerLink("Newer Provider").request);
|
|
});
|
|
// 给并发读取(如果有)留出完成的时间
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
release();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Newer Provider")).toBeInTheDocument();
|
|
});
|
|
await waitFor(() => expect(peekPendingDeepLink()).toBeNull());
|
|
expect(screen.queryByText("Older Provider")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows the newer link when it arrives while the older one is merging its config", async () => {
|
|
// 读取已返回 A,A 带 config 正在合并(异步),此时 B 到达:最终必须停在 B
|
|
setPendingDeepLink({
|
|
...coldStartLink,
|
|
request: {
|
|
...coldStartLink.request,
|
|
name: "Merging Provider",
|
|
config: btoa('{"env":{}}'),
|
|
},
|
|
});
|
|
|
|
let release!: () => void;
|
|
const gate = new Promise<void>((resolve) => (release = resolve));
|
|
let markHit!: () => void;
|
|
const hit = new Promise<void>((resolve) => (markHit = resolve));
|
|
server.use(
|
|
http.post(
|
|
"http://tauri.local/merge_deeplink_config",
|
|
async ({ request }) => {
|
|
const body = (await request.json()) as {
|
|
request: Record<string, unknown>;
|
|
};
|
|
markHit();
|
|
await gate;
|
|
return HttpResponse.json(body.request);
|
|
},
|
|
),
|
|
);
|
|
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
await hit;
|
|
act(() => {
|
|
deliverDeepLink({
|
|
...coldStartLink.request,
|
|
name: "Arrived During Merge",
|
|
});
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
release();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Arrived During Merge")).toBeInTheDocument();
|
|
});
|
|
await waitFor(() => expect(peekPendingDeepLink()).toBeNull());
|
|
expect(screen.queryByText("Merging Provider")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("previews agy model and masks its key before confirmation", async () => {
|
|
server.use(
|
|
http.post(
|
|
"http://tauri.local/merge_deeplink_config",
|
|
async ({ request }) => {
|
|
const body = (await request.json()) as {
|
|
request: Record<string, unknown>;
|
|
};
|
|
return HttpResponse.json(body.request);
|
|
},
|
|
),
|
|
);
|
|
render(<DeepLinkImportDialog />, { wrapper: Wrapper });
|
|
act(() => {
|
|
deliverDeepLink({
|
|
version: "v1",
|
|
resource: "provider",
|
|
app: "agy",
|
|
name: "Agy fixture",
|
|
endpoint: "https://relay.example",
|
|
apiKey: "secret-agy-key",
|
|
config: btoa(
|
|
JSON.stringify({
|
|
env: { GEMINI_API_KEY: "secret-agy-key" },
|
|
config: { model: { name: "agy-preview-model" } },
|
|
}),
|
|
),
|
|
});
|
|
});
|
|
await waitFor(() =>
|
|
expect(screen.getByText("agy-preview-model")).toBeInTheDocument(),
|
|
);
|
|
expect(screen.queryByText("secret-agy-key")).not.toBeInTheDocument();
|
|
expect(screen.getAllByText("secr************").length).toBeGreaterThan(0);
|
|
});
|