mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-31 11:01:36 +08:00
Merge branch 'refs/heads/main' into feat/proxy-url-refactor-full-url-mode
# Conflicts: # src-tauri/src/provider.rs # src-tauri/src/proxy/forwarder.rs # src-tauri/src/proxy/usage/parser.rs # src/components/providers/forms/ClaudeFormFields.tsx # src/components/providers/forms/ProviderForm.tsx # src/components/providers/forms/hooks/useApiKeyState.ts # src/i18n/locales/en.json # src/i18n/locales/ja.json # src/i18n/locales/zh.json # src/types.ts # src/utils/providerConfigUtils.ts
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
||||
import type { SessionMessage, SessionMeta } from "@/types";
|
||||
import { setSessionFixtures } from "../msw/state";
|
||||
|
||||
const toastSuccessMock = vi.fn();
|
||||
const toastErrorMock = vi.fn();
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => toastSuccessMock(...args),
|
||||
error: (...args: unknown[]) => toastErrorMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/sessions/SessionToc", () => ({
|
||||
SessionTocSidebar: () => null,
|
||||
SessionTocDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ConfirmDialog", () => ({
|
||||
ConfirmDialog: ({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
cancelText,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText: string;
|
||||
cancelText: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) =>
|
||||
isOpen ? (
|
||||
<div data-testid="confirm-dialog">
|
||||
<div>{title}</div>
|
||||
<div>{message}</div>
|
||||
<button onClick={onConfirm}>{confirmText}</button>
|
||||
<button onClick={onCancel}>{cancelText}</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
const renderPage = () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SessionManagerPage appId="codex" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("SessionManagerPage", () => {
|
||||
beforeEach(() => {
|
||||
toastSuccessMock.mockReset();
|
||||
toastErrorMock.mockReset();
|
||||
|
||||
const sessions: SessionMeta[] = [
|
||||
{
|
||||
providerId: "codex",
|
||||
sessionId: "codex-session-1",
|
||||
title: "Alpha Session",
|
||||
summary: "Alpha summary",
|
||||
projectDir: "/mock/codex",
|
||||
createdAt: 2,
|
||||
lastActiveAt: 20,
|
||||
sourcePath: "/mock/codex/session-1.jsonl",
|
||||
resumeCommand: "codex resume codex-session-1",
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
sessionId: "codex-session-2",
|
||||
title: "Beta Session",
|
||||
summary: "Beta summary",
|
||||
projectDir: "/mock/codex",
|
||||
createdAt: 1,
|
||||
lastActiveAt: 10,
|
||||
sourcePath: "/mock/codex/session-2.jsonl",
|
||||
resumeCommand: "codex resume codex-session-2",
|
||||
},
|
||||
];
|
||||
const messages: Record<string, SessionMessage[]> = {
|
||||
"codex:/mock/codex/session-1.jsonl": [
|
||||
{ role: "user", content: "alpha", ts: 20 },
|
||||
],
|
||||
"codex:/mock/codex/session-2.jsonl": [
|
||||
{ role: "user", content: "beta", ts: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
setSessionFixtures(sessions, messages);
|
||||
});
|
||||
|
||||
it("deletes the selected session and selects the next visible session", async () => {
|
||||
renderPage();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Alpha Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /删除会话/i }));
|
||||
|
||||
const dialog = screen.getByTestId("confirm-dialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/Alpha Session/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: /删除会话/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Beta Session" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument();
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
expect(toastSuccessMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -228,6 +228,14 @@ describe("WebdavSyncSection", () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole("switch", { name: "settings.webdavSync.autoSync" }),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "confirm.autoSync.confirm" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("switch", { name: "settings.webdavSync.autoSync" }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getOpenClawTimeoutInputValue,
|
||||
getOpenClawToolsProfileSelectValue,
|
||||
getOpenClawUnsupportedProfile,
|
||||
OPENCLAW_UNSUPPORTED_PROFILE,
|
||||
parseOpenClawEnvEditorValue,
|
||||
} from "@/components/openclaw/utils";
|
||||
|
||||
describe("OpenClaw utils", () => {
|
||||
it("parses nested env objects without stringifying them", () => {
|
||||
const env = parseOpenClawEnvEditorValue(`{
|
||||
"API_KEY": "secret",
|
||||
"vars": { "HTTP_PROXY": "http://127.0.0.1:8080" },
|
||||
"shellEnv": { "NODE_OPTIONS": "--max-old-space-size=4096" }
|
||||
}`);
|
||||
|
||||
expect(env).toEqual({
|
||||
API_KEY: "secret",
|
||||
vars: { HTTP_PROXY: "http://127.0.0.1:8080" },
|
||||
shellEnv: { NODE_OPTIONS: "--max-old-space-size=4096" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-object env payloads", () => {
|
||||
expect(() => parseOpenClawEnvEditorValue(`["not", "an object"]`)).toThrow(
|
||||
"OPENCLAW_ENV_OBJECT_REQUIRED",
|
||||
);
|
||||
});
|
||||
|
||||
it("flags unsupported tools profiles without silently normalizing them", () => {
|
||||
expect(getOpenClawToolsProfileSelectValue("default")).toBe(
|
||||
OPENCLAW_UNSUPPORTED_PROFILE,
|
||||
);
|
||||
expect(getOpenClawUnsupportedProfile("default")).toBe("default");
|
||||
expect(getOpenClawUnsupportedProfile("coding")).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers timeoutSeconds and falls back to legacy timeout", () => {
|
||||
expect(
|
||||
getOpenClawTimeoutInputValue({ timeoutSeconds: 120, timeout: 30 }),
|
||||
).toBe("120");
|
||||
expect(getOpenClawTimeoutInputValue({ timeout: 45 })).toBe("45");
|
||||
expect(getOpenClawTimeoutInputValue({})).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,9 @@ const providersApiUpdateMock = vi.fn();
|
||||
const providersApiUpdateTrayMenuMock = vi.fn();
|
||||
const settingsApiGetMock = vi.fn();
|
||||
const settingsApiApplyMock = vi.fn();
|
||||
const openclawApiGetModelCatalogMock = vi.fn();
|
||||
const openclawApiGetDefaultModelMock = vi.fn();
|
||||
const openclawApiSetDefaultModelMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
providersApi: {
|
||||
@@ -65,6 +68,14 @@ vi.mock("@/lib/api", () => ({
|
||||
applyClaudePluginConfig: (...args: unknown[]) =>
|
||||
settingsApiApplyMock(...args),
|
||||
},
|
||||
openclawApi: {
|
||||
getModelCatalog: (...args: unknown[]) =>
|
||||
openclawApiGetModelCatalogMock(...args),
|
||||
getDefaultModel: (...args: unknown[]) =>
|
||||
openclawApiGetDefaultModelMock(...args),
|
||||
setDefaultModel: (...args: unknown[]) =>
|
||||
openclawApiSetDefaultModelMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
interface WrapperProps {
|
||||
@@ -100,6 +111,9 @@ beforeEach(() => {
|
||||
providersApiUpdateTrayMenuMock.mockReset();
|
||||
settingsApiGetMock.mockReset();
|
||||
settingsApiApplyMock.mockReset();
|
||||
openclawApiGetModelCatalogMock.mockReset();
|
||||
openclawApiGetDefaultModelMock.mockReset();
|
||||
openclawApiSetDefaultModelMock.mockReset();
|
||||
toastSuccessMock.mockReset();
|
||||
toastErrorMock.mockReset();
|
||||
|
||||
@@ -450,6 +464,35 @@ describe("useProviderActions", () => {
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show backup details when setting OpenClaw default model", async () => {
|
||||
openclawApiSetDefaultModelMock.mockResolvedValueOnce({
|
||||
backupPath: "/tmp/openclaw-backup.json5",
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const { wrapper } = createWrapper();
|
||||
const provider = createProvider({
|
||||
settingsConfig: {
|
||||
models: [{ id: "gpt-4.1" }, { id: "gpt-4.1-mini" }],
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useProviderActions("openclaw"), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setAsDefaultModel(provider);
|
||||
});
|
||||
|
||||
expect(openclawApiSetDefaultModelMock).toHaveBeenCalledWith({
|
||||
primary: "provider-1/gpt-4.1",
|
||||
fallbacks: ["provider-1/gpt-4.1-mini"],
|
||||
});
|
||||
expect(toastSuccessMock).toHaveBeenCalledTimes(1);
|
||||
expect(toastSuccessMock.mock.calls[0]?.[1]).toEqual({ closeButton: true });
|
||||
});
|
||||
});
|
||||
it("clears loading flag when all mutations idle", () => {
|
||||
addProviderMutation.isPending = false;
|
||||
|
||||
+34
-4
@@ -4,9 +4,12 @@ import type { McpServer, Provider, Settings } from "@/types";
|
||||
import {
|
||||
addProvider,
|
||||
deleteProvider,
|
||||
deleteSession,
|
||||
getCurrentProviderId,
|
||||
getSessionMessages,
|
||||
getProviders,
|
||||
listProviders,
|
||||
listSessions,
|
||||
resetProviderState,
|
||||
setCurrentProviderId,
|
||||
updateProvider,
|
||||
@@ -37,7 +40,9 @@ const success = <T>(payload: T) => HttpResponse.json(payload as any);
|
||||
|
||||
export const handlers = [
|
||||
http.post(`${TAURI_ENDPOINT}/get_migration_result`, () => success(false)),
|
||||
http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () => success(null)),
|
||||
http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () =>
|
||||
success(null),
|
||||
),
|
||||
http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => {
|
||||
const { app } = await withJson<{ app: AppId }>(request);
|
||||
return success(getProviders(app));
|
||||
@@ -105,6 +110,25 @@ export const handlers = [
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/open_external`, () => success(true)),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/list_sessions`, () => success(listSessions())),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/get_session_messages`, async ({ request }) => {
|
||||
const { providerId, sourcePath } = await withJson<{
|
||||
providerId: string;
|
||||
sourcePath: string;
|
||||
}>(request);
|
||||
return success(getSessionMessages(providerId, sourcePath));
|
||||
}),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/delete_session`, async ({ request }) => {
|
||||
const { providerId, sessionId, sourcePath } = await withJson<{
|
||||
providerId: string;
|
||||
sessionId: string;
|
||||
sourcePath: string;
|
||||
}>(request);
|
||||
return success(deleteSession(providerId, sessionId, sourcePath));
|
||||
}),
|
||||
|
||||
// MCP APIs
|
||||
http.post(`${TAURI_ENDPOINT}/get_mcp_config`, async ({ request }) => {
|
||||
const { app } = await withJson<{ app: AppId }>(request);
|
||||
@@ -178,9 +202,13 @@ export const handlers = [
|
||||
},
|
||||
),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/apply_claude_onboarding_skip`, () => success(true)),
|
||||
http.post(`${TAURI_ENDPOINT}/apply_claude_onboarding_skip`, () =>
|
||||
success(true),
|
||||
),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/clear_claude_onboarding_skip`, () => success(true)),
|
||||
http.post(`${TAURI_ENDPOINT}/clear_claude_onboarding_skip`, () =>
|
||||
success(true),
|
||||
),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/get_config_dir`, async ({ request }) => {
|
||||
const { app } = await withJson<{ app: AppId }>(request);
|
||||
@@ -280,7 +308,9 @@ export const handlers = [
|
||||
success([]),
|
||||
),
|
||||
http.post(`${TAURI_ENDPOINT}/add_to_failover_queue`, () => success(true)),
|
||||
http.post(`${TAURI_ENDPOINT}/remove_from_failover_queue`, () => success(true)),
|
||||
http.post(`${TAURI_ENDPOINT}/remove_from_failover_queue`, () =>
|
||||
success(true),
|
||||
),
|
||||
http.post(`${TAURI_ENDPOINT}/reorder_failover_queue`, () => success(true)),
|
||||
http.post(`${TAURI_ENDPOINT}/set_failover_item_enabled`, () => success(true)),
|
||||
|
||||
|
||||
+125
-5
@@ -1,5 +1,11 @@
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import type { McpServer, Provider, Settings } from "@/types";
|
||||
import type {
|
||||
McpServer,
|
||||
Provider,
|
||||
SessionMessage,
|
||||
SessionMeta,
|
||||
Settings,
|
||||
} from "@/types";
|
||||
|
||||
type ProvidersByApp = Record<AppId, Record<string, Provider>>;
|
||||
type CurrentProviderState = Record<AppId, string>;
|
||||
@@ -80,13 +86,69 @@ let settingsState: Settings = {
|
||||
language: "zh",
|
||||
};
|
||||
let appConfigDirOverride: string | null = null;
|
||||
const sessionMessageKey = (providerId: string, sourcePath: string) =>
|
||||
`${providerId}:${sourcePath}`;
|
||||
|
||||
const createDefaultSessions = (): SessionMeta[] => {
|
||||
const now = Date.now();
|
||||
return [
|
||||
{
|
||||
providerId: "codex",
|
||||
sessionId: "codex-session-1",
|
||||
title: "Codex Session One",
|
||||
summary: "Codex summary",
|
||||
projectDir: "/mock/codex",
|
||||
createdAt: now - 2000,
|
||||
lastActiveAt: now - 1000,
|
||||
sourcePath: "/mock/codex/session-1.jsonl",
|
||||
resumeCommand: "codex resume codex-session-1",
|
||||
},
|
||||
{
|
||||
providerId: "claude",
|
||||
sessionId: "claude-session-1",
|
||||
title: "Claude Session One",
|
||||
summary: "Claude summary",
|
||||
projectDir: "/mock/claude",
|
||||
createdAt: now - 4000,
|
||||
lastActiveAt: now - 3000,
|
||||
sourcePath: "/mock/claude/session-1.jsonl",
|
||||
resumeCommand: "claude --resume claude-session-1",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const createDefaultSessionMessages = (): Record<string, SessionMessage[]> => ({
|
||||
[sessionMessageKey("codex", "/mock/codex/session-1.jsonl")]: [
|
||||
{
|
||||
role: "user",
|
||||
content: "First codex message",
|
||||
ts: Date.now() - 1000,
|
||||
},
|
||||
],
|
||||
[sessionMessageKey("claude", "/mock/claude/session-1.jsonl")]: [
|
||||
{
|
||||
role: "user",
|
||||
content: "First claude message",
|
||||
ts: Date.now() - 3000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let sessionsState = createDefaultSessions();
|
||||
let sessionMessagesState = createDefaultSessionMessages();
|
||||
let mcpConfigs: McpConfigState = {
|
||||
claude: {
|
||||
sample: {
|
||||
id: "sample",
|
||||
name: "Sample Claude Server",
|
||||
enabled: true,
|
||||
apps: { claude: true, codex: false, gemini: false, opencode: false, openclaw: false },
|
||||
apps: {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
},
|
||||
server: {
|
||||
type: "stdio",
|
||||
command: "claude-server",
|
||||
@@ -98,7 +160,13 @@ let mcpConfigs: McpConfigState = {
|
||||
id: "httpServer",
|
||||
name: "HTTP Codex Server",
|
||||
enabled: false,
|
||||
apps: { claude: false, codex: true, gemini: false, opencode: false, openclaw: false },
|
||||
apps: {
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
},
|
||||
server: {
|
||||
type: "http",
|
||||
url: "http://localhost:3000",
|
||||
@@ -116,6 +184,8 @@ const cloneProviders = (value: ProvidersByApp) =>
|
||||
export const resetProviderState = () => {
|
||||
providers = createDefaultProviders();
|
||||
current = createDefaultCurrent();
|
||||
sessionsState = createDefaultSessions();
|
||||
sessionMessagesState = createDefaultSessionMessages();
|
||||
settingsState = {
|
||||
showInTray: true,
|
||||
minimizeToTrayOnClose: true,
|
||||
@@ -131,7 +201,13 @@ export const resetProviderState = () => {
|
||||
id: "sample",
|
||||
name: "Sample Claude Server",
|
||||
enabled: true,
|
||||
apps: { claude: true, codex: false, gemini: false, opencode: false, openclaw: false },
|
||||
apps: {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
},
|
||||
server: {
|
||||
type: "stdio",
|
||||
command: "claude-server",
|
||||
@@ -143,7 +219,13 @@ export const resetProviderState = () => {
|
||||
id: "httpServer",
|
||||
name: "HTTP Codex Server",
|
||||
enabled: false,
|
||||
apps: { claude: false, codex: true, gemini: false, opencode: false, openclaw: false },
|
||||
apps: {
|
||||
claude: false,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
},
|
||||
server: {
|
||||
type: "http",
|
||||
url: "http://localhost:3000",
|
||||
@@ -285,3 +367,41 @@ export const deleteMcpServer = (appType: AppId, id: string) => {
|
||||
if (!mcpConfigs[appType]) return;
|
||||
delete mcpConfigs[appType][id];
|
||||
};
|
||||
|
||||
export const listSessions = () =>
|
||||
JSON.parse(JSON.stringify(sessionsState)) as SessionMeta[];
|
||||
|
||||
export const getSessionMessages = (providerId: string, sourcePath: string) =>
|
||||
JSON.parse(
|
||||
JSON.stringify(
|
||||
sessionMessagesState[sessionMessageKey(providerId, sourcePath)] ?? [],
|
||||
),
|
||||
) as SessionMessage[];
|
||||
|
||||
export const deleteSession = (
|
||||
providerId: string,
|
||||
sessionId: string,
|
||||
sourcePath: string,
|
||||
) => {
|
||||
sessionsState = sessionsState.filter(
|
||||
(session) =>
|
||||
!(
|
||||
session.providerId === providerId &&
|
||||
session.sessionId === sessionId &&
|
||||
session.sourcePath === sourcePath
|
||||
),
|
||||
);
|
||||
delete sessionMessagesState[sessionMessageKey(providerId, sourcePath)];
|
||||
return true;
|
||||
};
|
||||
|
||||
export const setSessionFixtures = (
|
||||
sessions: SessionMeta[],
|
||||
messages: Record<string, SessionMessage[]>,
|
||||
) => {
|
||||
sessionsState = JSON.parse(JSON.stringify(sessions)) as SessionMeta[];
|
||||
sessionMessagesState = JSON.parse(JSON.stringify(messages)) as Record<
|
||||
string,
|
||||
SessionMessage[]
|
||||
>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user