mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
957 lines
31 KiB
TypeScript
957 lines
31 KiB
TypeScript
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { QueryClientProvider } from "@tanstack/react-query";
|
||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||
import { http, HttpResponse } from "msw";
|
||
import { toast } from "sonner";
|
||
import type { Provider } from "@/types";
|
||
import type { ModeAppId } from "@/config/appConfig";
|
||
import type { AppMode } from "@/types/proxy";
|
||
import { SwitchModePanel } from "@/components/providers/mode/SwitchModePanel";
|
||
import { server } from "../msw/server";
|
||
import { createTestQueryClient } from "../utils/testQueryClient";
|
||
|
||
vi.mock("sonner", () => ({
|
||
toast: {
|
||
success: vi.fn(),
|
||
info: vi.fn(),
|
||
warning: vi.fn(),
|
||
error: vi.fn(),
|
||
},
|
||
}));
|
||
|
||
// 只关心模式层:卡片画成按钮,按 presentation 的 key 找;「更多」菜单里的项加 menu- 前缀
|
||
vi.mock("@/components/providers/ProviderCard", () => ({
|
||
ProviderCard: ({ provider, presentation }: any) => (
|
||
<div data-testid={`card-${provider.id}`}>
|
||
{presentation.buttons.map((button: any) => (
|
||
<button
|
||
key={button.key}
|
||
data-testid={`${button.key}-${provider.id}`}
|
||
disabled={Boolean(button.disabledReason)}
|
||
onClick={button.onClick}
|
||
>
|
||
{button.label}
|
||
</button>
|
||
))}
|
||
{(presentation.menuItems ?? []).map((item: any) => (
|
||
<button
|
||
key={item.key}
|
||
data-testid={`menu-${item.key}-${provider.id}`}
|
||
disabled={Boolean(item.disabledReason)}
|
||
onClick={item.onSelect}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
),
|
||
}));
|
||
|
||
vi.mock("@/hooks/useStreamCheck", () => ({
|
||
useStreamCheck: () => ({ checkProvider: vi.fn(), isChecking: () => false }),
|
||
}));
|
||
|
||
const TAURI_ENDPOINT = "http://tauri.local";
|
||
|
||
const provider = (id: string): Provider => ({
|
||
id,
|
||
name: id,
|
||
settingsConfig: {},
|
||
});
|
||
|
||
function mockMode(
|
||
mode: AppMode,
|
||
routeProviderId: string | null,
|
||
directProviderId: string | null = routeProviderId,
|
||
) {
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_app_mode`, () =>
|
||
HttpResponse.json({
|
||
mode,
|
||
attached: mode !== "direct",
|
||
routeProviderId,
|
||
directProviderId,
|
||
}),
|
||
),
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_status`, () =>
|
||
HttpResponse.json({ running: true }),
|
||
),
|
||
);
|
||
}
|
||
|
||
function renderPanel(
|
||
app: ModeAppId,
|
||
providers: Record<string, Provider>,
|
||
onSwitch = vi.fn(),
|
||
) {
|
||
const queryClient = createTestQueryClient();
|
||
const statusSlot = document.createElement("div");
|
||
const view = render(
|
||
<QueryClientProvider client={queryClient}>
|
||
<div
|
||
data-testid="connection-header"
|
||
ref={(node) => {
|
||
node?.appendChild(statusSlot);
|
||
}}
|
||
/>
|
||
<SwitchModePanel
|
||
app={app}
|
||
statusSlot={statusSlot}
|
||
providers={providers}
|
||
currentProviderId={Object.keys(providers)[0] ?? ""}
|
||
isLoading={false}
|
||
onSwitch={onSwitch}
|
||
onOpenRoutingSettings={vi.fn()}
|
||
onEdit={vi.fn()}
|
||
onDelete={vi.fn()}
|
||
onDuplicate={vi.fn()}
|
||
onOpenWebsite={vi.fn()}
|
||
/>
|
||
</QueryClientProvider>,
|
||
);
|
||
return { ...view, queryClient };
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.mocked(toast.info).mockClear();
|
||
vi.mocked(toast.success).mockClear();
|
||
// jsdom 没有 scrollIntoView,确认框里的下拉打开时会调
|
||
Element.prototype.scrollIntoView = vi.fn();
|
||
});
|
||
|
||
describe("SwitchModePanel — Stack mode", () => {
|
||
it("opens the Codex aggregation settings from the Stack tab only", async () => {
|
||
const user = userEvent.setup();
|
||
mockMode("stack", "route");
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||
HttpResponse.json({
|
||
active: true,
|
||
members: [
|
||
{ providerId: "route", modelIds: [], route: true },
|
||
{
|
||
providerId: "deepseek",
|
||
modelIds: ["ccs-deepseek/deepseek-v4-flash"],
|
||
route: false,
|
||
},
|
||
],
|
||
subagentModels: [
|
||
{
|
||
id: "ccs-deepseek/deepseek-v4-flash",
|
||
providerId: "deepseek",
|
||
displayName: "DeepSeek V4 Flash(deepseek)",
|
||
efforts: ["high"],
|
||
},
|
||
],
|
||
}),
|
||
),
|
||
);
|
||
renderPanel("codex", {
|
||
route: provider("route"),
|
||
deepseek: provider("deepseek"),
|
||
});
|
||
|
||
await user.click(
|
||
await screen.findByRole("button", { name: "mode.stackSettings.title" }),
|
||
);
|
||
expect(
|
||
await screen.findByRole("combobox", {
|
||
name: "mode.stackSettings.subagentModel",
|
||
}),
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it("has no aggregation settings for Claude Code", async () => {
|
||
mockMode("stack", "route");
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||
HttpResponse.json({
|
||
active: true,
|
||
members: [{ providerId: "route", modelIds: ["m"], route: true }],
|
||
}),
|
||
),
|
||
);
|
||
renderPanel("claude", { route: provider("route") });
|
||
|
||
await screen.findByTestId("card-route");
|
||
expect(
|
||
screen.queryByRole("button", { name: "mode.stackSettings.title" }),
|
||
).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("adds and removes Stack members through the backend", async () => {
|
||
mockMode("stack", "route");
|
||
const setCalls: unknown[] = [];
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||
HttpResponse.json({
|
||
active: true,
|
||
members: [
|
||
{
|
||
providerId: "route",
|
||
modelIds: ["m-route"],
|
||
route: true,
|
||
memberModels: 1,
|
||
},
|
||
{
|
||
providerId: "kimi",
|
||
modelIds: ["m-kimi"],
|
||
route: false,
|
||
memberModels: 1,
|
||
},
|
||
],
|
||
}),
|
||
),
|
||
http.post(
|
||
`${TAURI_ENDPOINT}/set_proxy_stack_member`,
|
||
async ({ request }) => {
|
||
setCalls.push(await request.json());
|
||
return HttpResponse.json(null);
|
||
},
|
||
),
|
||
);
|
||
|
||
renderPanel("claude", {
|
||
route: provider("route"),
|
||
kimi: provider("kimi"),
|
||
other: provider("other"),
|
||
});
|
||
|
||
fireEvent.click(await screen.findByTestId("remove-kimi"));
|
||
fireEvent.click(await screen.findByTestId("add-other"));
|
||
await waitFor(() => expect(setCalls).toHaveLength(2));
|
||
expect(setCalls).toEqual([
|
||
{ appType: "claude", providerId: "kimi", enabled: false },
|
||
{ appType: "claude", providerId: "other", enabled: true },
|
||
]);
|
||
});
|
||
|
||
it("says why the Codex model list could not be read", async () => {
|
||
mockMode("stack", "route");
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||
HttpResponse.json({
|
||
active: true,
|
||
members: [
|
||
{
|
||
providerId: "route",
|
||
modelIds: [],
|
||
route: true,
|
||
memberModels: 0,
|
||
},
|
||
],
|
||
notice: "officialModelsUnavailable",
|
||
noticeReasons: ["loginStale", "noCli"],
|
||
}),
|
||
),
|
||
);
|
||
renderPanel("codex", { route: provider("route") });
|
||
|
||
expect(
|
||
await screen.findByText("provider.officialModelsUnavailable"),
|
||
).toBeInTheDocument();
|
||
expect(
|
||
screen.getByText("provider.officialModelsReason.loginStale"),
|
||
).toBeInTheDocument();
|
||
expect(
|
||
screen.getByText("provider.officialModelsReason.noCli"),
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it("warns about Codex clients on an old model list in every mode", async () => {
|
||
const stack = (active: boolean) =>
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||
HttpResponse.json({
|
||
active,
|
||
members: [
|
||
{
|
||
providerId: "route",
|
||
modelIds: [],
|
||
route: true,
|
||
memberModels: 0,
|
||
},
|
||
],
|
||
staleClients: { daemon: true, others: false },
|
||
}),
|
||
);
|
||
|
||
mockMode("stack", "route");
|
||
server.use(stack(true));
|
||
const view = renderPanel("codex", { route: provider("route") });
|
||
expect(
|
||
await screen.findByText("proxy.stackMode.codexStale.title"),
|
||
).toBeInTheDocument();
|
||
view.unmount();
|
||
|
||
mockMode("route", "route");
|
||
server.use(stack(false));
|
||
renderPanel("codex", { route: provider("route") });
|
||
expect(
|
||
await screen.findByText("proxy.stackMode.codexStale.title"),
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it("shows a dismissed Codex stale notice again once the model list changes again", async () => {
|
||
let revision = "r1";
|
||
mockMode("direct", "route");
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||
HttpResponse.json({
|
||
active: false,
|
||
members: [],
|
||
staleClients: { daemon: false, others: true },
|
||
staleRevision: revision,
|
||
}),
|
||
),
|
||
);
|
||
const { queryClient } = renderPanel("codex", { route: provider("route") });
|
||
await screen.findByText("proxy.stackMode.codexStale.title");
|
||
fireEvent.click(screen.getByRole("button", { name: "common.close" }));
|
||
expect(
|
||
screen.queryByText("proxy.stackMode.codexStale.title"),
|
||
).not.toBeInTheDocument();
|
||
|
||
// 同一份:再拉一次还是关着。
|
||
await queryClient.invalidateQueries({ queryKey: ["providers", "codex"] });
|
||
expect(
|
||
screen.queryByText("proxy.stackMode.codexStale.title"),
|
||
).not.toBeInTheDocument();
|
||
|
||
// 同一个供应商又改了一次目录:同样的客户端,但已经是新的一份。
|
||
revision = "r2";
|
||
await queryClient.invalidateQueries({ queryKey: ["providers", "codex"] });
|
||
expect(
|
||
await screen.findByText("proxy.stackMode.codexStale.title"),
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it.each(["direct", "route", "stack"] as const)(
|
||
"shows a cached account notice in %s mode",
|
||
async (mode) => {
|
||
mockMode(mode, "route");
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||
HttpResponse.json({
|
||
active: mode === "stack",
|
||
members: [],
|
||
staleClients: { daemon: true, others: false, auth: true },
|
||
}),
|
||
),
|
||
);
|
||
renderPanel("codex", { route: provider("route") });
|
||
expect(
|
||
await screen.findByText("proxy.stackMode.codexStale.authTitle"),
|
||
).toBeInTheDocument();
|
||
expect(
|
||
screen.queryByText("proxy.stackMode.codexStale.title"),
|
||
).not.toBeInTheDocument();
|
||
},
|
||
);
|
||
|
||
it("does not read the Stack list for apps without Stack mode", async () => {
|
||
mockMode("route", "a");
|
||
let stackReads = 0;
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () => {
|
||
stackReads += 1;
|
||
return HttpResponse.json({ active: false, members: [] });
|
||
}),
|
||
);
|
||
|
||
renderPanel("gemini", { a: provider("a") });
|
||
await screen.findByTestId("card-a");
|
||
expect(stackReads).toBe(0);
|
||
expect(
|
||
screen.queryByRole("tab", { name: /mode\.stack/ }),
|
||
).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe("SwitchModePanel — Claude Desktop", () => {
|
||
it("offers all three modes, without failover or route settings", async () => {
|
||
mockMode("route", "map", "direct");
|
||
let failoverReads = 0;
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_auto_failover_enabled`, () => {
|
||
failoverReads += 1;
|
||
return HttpResponse.json(false);
|
||
}),
|
||
http.post(`${TAURI_ENDPOINT}/get_failover_queue`, () => {
|
||
failoverReads += 1;
|
||
return HttpResponse.json([]);
|
||
}),
|
||
);
|
||
|
||
renderPanel("claude-desktop", {
|
||
direct: provider("direct"),
|
||
map: provider("map"),
|
||
});
|
||
|
||
expect(
|
||
await screen.findByLabelText("mode.status.routeLead map"),
|
||
).toBeInTheDocument();
|
||
expect(
|
||
screen.getByRole("button", { name: /mode\.names\.stack/ }),
|
||
).toBeInTheDocument();
|
||
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
|
||
expect(failoverReads).toBe(0);
|
||
});
|
||
|
||
it("enters Stack with Desktop's own notes and asks for a restart", async () => {
|
||
const user = userEvent.setup();
|
||
mockMode("route", "map", "direct");
|
||
const calls: unknown[] = [];
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||
HttpResponse.json({
|
||
active: false,
|
||
members: [
|
||
{ providerId: "map", modelIds: [], route: true, memberModels: 1 },
|
||
{
|
||
providerId: "kimi",
|
||
modelIds: ["claude-fable-ccs-473908"],
|
||
route: false,
|
||
memberModels: 1,
|
||
},
|
||
],
|
||
}),
|
||
),
|
||
http.post(
|
||
`${TAURI_ENDPOINT}/set_proxy_takeover_for_app`,
|
||
async ({ request }) => {
|
||
calls.push(await request.json());
|
||
return HttpResponse.json(null);
|
||
},
|
||
),
|
||
);
|
||
|
||
renderPanel("claude-desktop", {
|
||
direct: provider("direct"),
|
||
map: provider("map"),
|
||
kimi: provider("kimi"),
|
||
});
|
||
|
||
await user.click(
|
||
await screen.findByRole("button", { name: /mode\.names\.stack/ }),
|
||
);
|
||
await user.click(
|
||
screen.getByRole("button", { name: "mode.activate.stack" }),
|
||
);
|
||
expect(
|
||
await screen.findByText("mode.dialog.desktopStackNoteMembers"),
|
||
).toBeVisible();
|
||
expect(
|
||
screen.getByText("mode.dialog.desktopRouteNoteRestart"),
|
||
).toBeVisible();
|
||
// Claude Code 的 /model 说明和故障转移说明不适用于 Desktop
|
||
expect(
|
||
screen.queryByText("mode.dialog.stackNoteMembers"),
|
||
).not.toBeInTheDocument();
|
||
expect(
|
||
screen.queryByText("mode.dialog.stackNoteNoFailover"),
|
||
).not.toBeInTheDocument();
|
||
|
||
await user.click(screen.getByText("mode.dialog.confirmStack"));
|
||
await waitFor(() =>
|
||
expect(calls).toEqual([
|
||
{ appType: "claude-desktop", enabled: true, stack: true, route: "map" },
|
||
]),
|
||
);
|
||
await waitFor(() =>
|
||
expect(toast.success).toHaveBeenCalledWith(
|
||
"mode.toast.enteredStackDesktop",
|
||
expect.anything(),
|
||
),
|
||
);
|
||
});
|
||
|
||
it("shows the Desktop config check in the notice slot", async () => {
|
||
mockMode("route", "map", "direct");
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_claude_desktop_status`, () =>
|
||
HttpResponse.json({
|
||
supported: true,
|
||
configured: true,
|
||
mode: "proxy",
|
||
missingRouteMappings: true,
|
||
staleRawModels: false,
|
||
gatewayTokenConfigured: true,
|
||
proxyRunning: true,
|
||
}),
|
||
),
|
||
);
|
||
|
||
renderPanel("claude-desktop", { map: provider("map") });
|
||
|
||
expect(
|
||
await screen.findByText("claudeDesktop.statusMissingRouteMappings"),
|
||
).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe("SwitchModePanel — mode layer", () => {
|
||
function captureTakeover() {
|
||
const calls: unknown[] = [];
|
||
server.use(
|
||
http.post(
|
||
`${TAURI_ENDPOINT}/set_proxy_takeover_for_app`,
|
||
async ({ request }) => {
|
||
calls.push(await request.json());
|
||
return HttpResponse.json(null);
|
||
},
|
||
),
|
||
);
|
||
return calls;
|
||
}
|
||
|
||
it("picks the Codex sub-agent model in the enter-Stack dialog and saves it before entering", async () => {
|
||
const user = userEvent.setup();
|
||
mockMode("direct", null, "a");
|
||
const order: string[] = [];
|
||
const saved: Record<string, unknown>[] = [];
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||
HttpResponse.json({
|
||
active: false,
|
||
members: [
|
||
{ providerId: "deepseek", modelIds: ["x"], route: false },
|
||
{ providerId: "kimi", modelIds: ["y"], route: false },
|
||
],
|
||
subagentModels: [
|
||
{
|
||
id: "ccs-deepseek/deepseek-v4-flash",
|
||
providerId: "deepseek",
|
||
displayName: "DeepSeek V4 Flash(deepseek)",
|
||
efforts: ["high"],
|
||
},
|
||
{
|
||
id: "ccs-kimi/kimi-k3",
|
||
providerId: "kimi",
|
||
displayName: "Kimi K3(kimi)",
|
||
efforts: ["medium"],
|
||
},
|
||
],
|
||
}),
|
||
),
|
||
http.post(`${TAURI_ENDPOINT}/save_settings`, async ({ request }) => {
|
||
const { settings } = (await request.json()) as {
|
||
settings: Record<string, unknown>;
|
||
};
|
||
order.push("save");
|
||
saved.push(settings);
|
||
return HttpResponse.json(true);
|
||
}),
|
||
http.post(`${TAURI_ENDPOINT}/set_proxy_takeover_for_app`, () => {
|
||
order.push("enter");
|
||
return HttpResponse.json(null);
|
||
}),
|
||
);
|
||
renderPanel("codex", {
|
||
a: provider("a"),
|
||
deepseek: provider("deepseek"),
|
||
kimi: provider("kimi"),
|
||
});
|
||
|
||
await user.click(
|
||
await screen.findByRole("button", { name: /mode\.names\.stack/ }),
|
||
);
|
||
await user.click(
|
||
screen.getByRole("button", { name: "mode.activate.stack" }),
|
||
);
|
||
expect(await screen.findByText("mode.dialog.stackTitle")).toBeVisible();
|
||
|
||
// 经典工具默认开着:选了别家的模型也不用提醒
|
||
const classic = screen.getByRole("switch", {
|
||
name: "settings.codexStackClassicSubagents",
|
||
});
|
||
expect(classic).toBeChecked();
|
||
const model = screen.getByRole("combobox", {
|
||
name: "mode.stackSettings.subagentModel",
|
||
});
|
||
expect(model).toHaveTextContent("mode.stackSettings.followMain");
|
||
await user.click(model);
|
||
await user.click(
|
||
await screen.findByRole("option", { name: "Kimi K3(kimi)" }),
|
||
);
|
||
expect(classic).toBeChecked();
|
||
expect(
|
||
screen.queryByTestId("stack-subagent-classic-off"),
|
||
).not.toBeInTheDocument();
|
||
// 关掉经典工具:子代理收不到任务,提醒必须打开(不替用户勾回去)
|
||
await user.click(classic);
|
||
expect(classic).not.toBeChecked();
|
||
expect(
|
||
await screen.findByTestId("stack-subagent-classic-off"),
|
||
).toBeInTheDocument();
|
||
await user.click(classic);
|
||
|
||
// 换成 Kimi 当默认:它自己的模型不能当子代理,选过的那个标成已不在聚合里
|
||
const pick = screen.getByRole("combobox", {
|
||
name: "mode.dialog.stackDefault",
|
||
});
|
||
await user.click(pick);
|
||
await user.click(await screen.findByRole("option", { name: "kimi" }));
|
||
expect(
|
||
await screen.findByTestId("stack-subagent-unavailable"),
|
||
).toBeInTheDocument();
|
||
await user.click(pick);
|
||
await user.click(await screen.findByRole("option", { name: "a" }));
|
||
expect(
|
||
screen.queryByTestId("stack-subagent-unavailable"),
|
||
).not.toBeInTheDocument();
|
||
|
||
await user.click(screen.getByText("mode.dialog.confirmStack"));
|
||
await waitFor(() => expect(order).toEqual(["save", "enter"]));
|
||
expect(saved[0]).toMatchObject({
|
||
codexStackSubagentModel: "ccs-kimi/kimi-k3",
|
||
codexStackClassicTools: true,
|
||
});
|
||
});
|
||
|
||
it("enters Stack without saving settings when the dialog's sub-agent choice is unchanged", async () => {
|
||
const user = userEvent.setup();
|
||
mockMode("direct", null, "a");
|
||
let saves = 0;
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||
HttpResponse.json({ active: false, members: [] }),
|
||
),
|
||
http.post(`${TAURI_ENDPOINT}/save_settings`, () => {
|
||
saves += 1;
|
||
return HttpResponse.json(true);
|
||
}),
|
||
);
|
||
const calls = captureTakeover();
|
||
renderPanel("codex", { a: provider("a") });
|
||
|
||
await user.click(
|
||
await screen.findByRole("button", { name: /mode\.names\.stack/ }),
|
||
);
|
||
await user.click(
|
||
screen.getByRole("button", { name: "mode.activate.stack" }),
|
||
);
|
||
await screen.findByRole("combobox", {
|
||
name: "mode.stackSettings.subagentModel",
|
||
});
|
||
await user.click(screen.getByText("mode.dialog.confirmStack"));
|
||
await waitFor(() => expect(calls).toHaveLength(1));
|
||
expect(saves).toBe(0);
|
||
});
|
||
|
||
it("enters Stack from the notice and picks the default in the dialog", async () => {
|
||
const user = userEvent.setup();
|
||
mockMode("direct", null, "a");
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||
HttpResponse.json({ active: false, members: [] }),
|
||
),
|
||
);
|
||
const calls = captureTakeover();
|
||
renderPanel("claude", { a: provider("a"), kimi: provider("kimi") });
|
||
|
||
await user.click(
|
||
await screen.findByRole("button", { name: /mode\.names\.stack/ }),
|
||
);
|
||
// 行上只有名单的添加,「设为默认」在「更多」菜单里;没有会切模式的按钮
|
||
const row = await screen.findByTestId("card-kimi");
|
||
expect(
|
||
Array.from(row.querySelectorAll("button")).map((b) => b.dataset.testid),
|
||
).toEqual(["add-kimi", "menu-setDefault-kimi"]);
|
||
expect(screen.getByTestId("add-kimi")).not.toBeDisabled();
|
||
|
||
await user.click(
|
||
screen.getByRole("button", { name: "mode.activate.stack" }),
|
||
);
|
||
expect(await screen.findByText("mode.dialog.stackTitle")).toBeVisible();
|
||
// 预选直连那家,可以在框里换
|
||
const select = screen.getByRole("combobox", {
|
||
name: "mode.dialog.stackDefault",
|
||
});
|
||
expect(select).toHaveTextContent("a");
|
||
await user.click(select);
|
||
await user.click(await screen.findByRole("option", { name: "kimi" }));
|
||
expect(select).toHaveTextContent("kimi");
|
||
// Claude 官方订阅进不了聚合,没有「官方只能做默认」的提示
|
||
expect(screen.queryByTestId("stack-official-note")).not.toBeInTheDocument();
|
||
|
||
await user.click(screen.getByText("mode.dialog.confirmStack"));
|
||
await waitFor(() =>
|
||
expect(calls).toEqual([
|
||
{ appType: "claude", enabled: true, stack: true, route: "kimi" },
|
||
]),
|
||
);
|
||
});
|
||
|
||
it("records the Stack default from the card menu while direct, without entering the mode", async () => {
|
||
const user = userEvent.setup();
|
||
let route: string | null = null;
|
||
const routeCalls: unknown[] = [];
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_app_mode`, () =>
|
||
HttpResponse.json({
|
||
mode: "direct",
|
||
attached: false,
|
||
routeProviderId: route,
|
||
directProviderId: "a",
|
||
}),
|
||
),
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||
HttpResponse.json({ active: false, members: [] }),
|
||
),
|
||
http.post(`${TAURI_ENDPOINT}/set_proxy_route`, async ({ request }) => {
|
||
const body = (await request.json()) as { providerId: string };
|
||
routeCalls.push(body);
|
||
route = body.providerId;
|
||
return HttpResponse.json({
|
||
kind: "remembered",
|
||
clientConfigChanged: false,
|
||
});
|
||
}),
|
||
);
|
||
const takeover = captureTakeover();
|
||
const onSwitch = vi.fn();
|
||
renderPanel(
|
||
"claude",
|
||
{ a: provider("a"), kimi: provider("kimi") },
|
||
onSwitch,
|
||
);
|
||
|
||
await user.click(
|
||
await screen.findByRole("button", { name: /mode\.names\.stack/ }),
|
||
);
|
||
await user.click(await screen.findByTestId("menu-setDefault-kimi"));
|
||
|
||
// 只记下选择:不进聚合、不走切换供应商
|
||
await waitFor(() =>
|
||
expect(routeCalls).toEqual([{ appType: "claude", providerId: "kimi" }]),
|
||
);
|
||
expect(takeover).toEqual([]);
|
||
expect(onSwitch).not.toHaveBeenCalled();
|
||
await waitFor(() =>
|
||
expect(toast.success).toHaveBeenCalledWith(
|
||
"已记住供应商 kimi,客户端接入路由或聚合后使用。",
|
||
expect.anything(),
|
||
),
|
||
);
|
||
// 记下的那家成了「切换后的默认」:自己不再有「设为默认」,原来那家有了
|
||
await screen.findByTestId("menu-setDefault-a");
|
||
expect(
|
||
screen.queryByTestId("menu-setDefault-kimi"),
|
||
).not.toBeInTheDocument();
|
||
|
||
// 确认框预选它
|
||
await user.click(
|
||
screen.getByRole("button", { name: "mode.activate.stack" }),
|
||
);
|
||
expect(
|
||
await screen.findByRole("combobox", { name: "mode.dialog.stackDefault" }),
|
||
).toHaveTextContent("kimi");
|
||
});
|
||
|
||
it("changes the Stack default right away once Stack mode is on", async () => {
|
||
mockMode("stack", "route");
|
||
const routeCalls: unknown[] = [];
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||
HttpResponse.json({
|
||
active: true,
|
||
members: [
|
||
{
|
||
providerId: "route",
|
||
modelIds: ["m-route"],
|
||
route: true,
|
||
memberModels: 1,
|
||
},
|
||
{
|
||
providerId: "kimi",
|
||
modelIds: ["m-kimi"],
|
||
route: false,
|
||
memberModels: 1,
|
||
},
|
||
],
|
||
}),
|
||
),
|
||
http.post(`${TAURI_ENDPOINT}/set_proxy_route`, async ({ request }) => {
|
||
routeCalls.push(await request.json());
|
||
return HttpResponse.json(null);
|
||
}),
|
||
);
|
||
const onSwitch = vi.fn();
|
||
const kimi = provider("kimi");
|
||
const other = provider("other");
|
||
renderPanel("claude", { route: provider("route"), kimi, other }, onSwitch);
|
||
|
||
// 已添加的、还没添加的都能直接设为默认,走的是切换供应商(当场生效)
|
||
fireEvent.click(await screen.findByTestId("menu-setDefault-kimi"));
|
||
fireEvent.click(screen.getByTestId("menu-setDefault-other"));
|
||
expect(onSwitch.mock.calls.map(([p]) => p)).toEqual([kimi, other]);
|
||
expect(
|
||
screen.queryByTestId("menu-setDefault-route"),
|
||
).not.toBeInTheDocument();
|
||
expect(routeCalls).toEqual([]);
|
||
});
|
||
|
||
it("cannot change the Stack default from the preview while routing", async () => {
|
||
mockMode("route", "a");
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||
HttpResponse.json({ active: false, members: [] }),
|
||
),
|
||
);
|
||
renderPanel("claude", { a: provider("a"), kimi: provider("kimi") });
|
||
|
||
// 等模式读到了再换页签:生效的模式一变,查看的那格会跳回生效的那格
|
||
await screen.findByLabelText("mode.status.routeLead a");
|
||
fireEvent.click(screen.getByRole("button", { name: /mode\.names\.stack/ }));
|
||
// 默认和路由目标是同一个指针:路由中改它就是当场换路由,只能在切换时的确认框里选
|
||
expect(await screen.findByTestId("menu-setDefault-kimi")).toBeDisabled();
|
||
});
|
||
|
||
it("warns that a Codex official account drops out of the Stack unless it is the default", async () => {
|
||
const user = userEvent.setup();
|
||
mockMode("direct", null, "kimi");
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_stack`, () =>
|
||
HttpResponse.json({ active: false, members: [] }),
|
||
),
|
||
);
|
||
const account: Provider = {
|
||
...provider("account"),
|
||
category: "official",
|
||
settingsConfig: { auth: {}, config: "" },
|
||
};
|
||
renderPanel("codex", { account, kimi: provider("kimi") });
|
||
|
||
await user.click(
|
||
await screen.findByRole("button", { name: /mode\.names\.stack/ }),
|
||
);
|
||
await user.click(
|
||
await screen.findByRole("button", { name: "mode.activate.stack" }),
|
||
);
|
||
const select = await screen.findByRole("combobox", {
|
||
name: "mode.dialog.stackDefault",
|
||
});
|
||
expect(select).toHaveTextContent("kimi");
|
||
expect(screen.getByTestId("stack-official-note")).toHaveTextContent(
|
||
"mode.dialog.stackOfficialNote",
|
||
);
|
||
|
||
await user.click(select);
|
||
await user.click(
|
||
await screen.findByRole("option", { name: "mode.dialog.officialOption" }),
|
||
);
|
||
expect(screen.queryByTestId("stack-official-note")).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("enters routing from the notice with the direct provider preselected, without an acknowledgement checkbox", async () => {
|
||
mockMode("direct", null, "b");
|
||
const calls = captureTakeover();
|
||
renderPanel("claude", { a: provider("a"), b: provider("b") });
|
||
|
||
fireEvent.click(
|
||
await screen.findByRole("button", { name: /mode\.names\.route/ }),
|
||
);
|
||
// 行上没有进入路由的按钮,入口只有通知条
|
||
await screen.findByTestId("card-b");
|
||
expect(screen.getByTestId("card-b").querySelector("button")).toBeNull();
|
||
fireEvent.click(
|
||
screen.getByRole("button", { name: "mode.activate.route" }),
|
||
);
|
||
expect(await screen.findByText("mode.dialog.routeTitle")).toBeVisible();
|
||
// 没有上次的路由目标时,预选直连那家
|
||
expect(
|
||
screen.getByRole("combobox", { name: "mode.dialog.routeTo" }),
|
||
).toHaveTextContent("b");
|
||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||
const confirm = screen.getByText("mode.dialog.confirmRoute");
|
||
expect(confirm.closest("button")).not.toBeDisabled();
|
||
fireEvent.click(confirm);
|
||
await waitFor(() =>
|
||
expect(calls).toEqual([
|
||
{ appType: "claude", enabled: true, stack: false, route: "b" },
|
||
]),
|
||
);
|
||
});
|
||
|
||
it.each(["route", "stack"] as const)(
|
||
"keeps %s status gray when the service runs but the app is detached",
|
||
async (mode) => {
|
||
mockMode(mode, "a");
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_app_mode`, () =>
|
||
HttpResponse.json({
|
||
mode,
|
||
attached: false,
|
||
routeProviderId: "a",
|
||
directProviderId: "a",
|
||
}),
|
||
),
|
||
);
|
||
renderPanel("claude", { a: provider("a") });
|
||
await screen.findByLabelText(new RegExp(`mode.status.${mode}Lead`));
|
||
expect(
|
||
screen.getByTestId("connection-header").querySelector(".bg-fg-3"),
|
||
).not.toBeNull();
|
||
expect(
|
||
screen.getByTestId("connection-header").querySelector(`.bg-${mode}`),
|
||
).toBeNull();
|
||
},
|
||
);
|
||
|
||
it("shows only the route target in the status line, not the local address", async () => {
|
||
mockMode("route", "route");
|
||
server.use(
|
||
http.post(`${TAURI_ENDPOINT}/get_proxy_status`, () =>
|
||
HttpResponse.json({ running: true, address: "127.0.0.1", port: 15721 }),
|
||
),
|
||
);
|
||
renderPanel("gemini", { route: provider("route") });
|
||
|
||
expect(
|
||
await screen.findByLabelText("mode.status.routeLead route"),
|
||
).toHaveTextContent("route");
|
||
expect(screen.getByTestId("connection-header")).toHaveTextContent("route");
|
||
fireEvent.click(screen.getByRole("button", { name: "mode.names.direct" }));
|
||
expect(
|
||
screen.getByLabelText("mode.status.routeLead route"),
|
||
).toBeInTheDocument();
|
||
expect(screen.queryByText(/15721/)).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe("SwitchModePanel — tray needs-route request", () => {
|
||
it("opens the same needs-routing dialog once the provider is there", async () => {
|
||
mockMode("direct", null);
|
||
const onHandled = vi.fn();
|
||
const copilot: Provider = {
|
||
...provider("copilot"),
|
||
meta: { providerType: "github_copilot" },
|
||
};
|
||
const queryClient = createTestQueryClient();
|
||
render(
|
||
<QueryClientProvider client={queryClient}>
|
||
<SwitchModePanel
|
||
app="claude"
|
||
providers={{ kimi: provider("kimi"), copilot }}
|
||
currentProviderId="kimi"
|
||
isLoading={false}
|
||
onSwitch={vi.fn()}
|
||
onOpenRoutingSettings={vi.fn()}
|
||
onEdit={vi.fn()}
|
||
onDelete={vi.fn()}
|
||
onDuplicate={vi.fn()}
|
||
onOpenWebsite={vi.fn()}
|
||
needsRouteRequest={{ providerId: "copilot", nonce: 1 }}
|
||
onNeedsRouteHandled={onHandled}
|
||
/>
|
||
</QueryClientProvider>,
|
||
);
|
||
|
||
expect(
|
||
await screen.findByText("mode.dialog.needsRouteTitle"),
|
||
).toBeInTheDocument();
|
||
expect(onHandled).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|