mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-01 17:42:18 +08:00
feat: add session deletion with per-provider cleanup and path safety
Add delete_session Tauri command dispatching to provider-specific deletion logic for all 5 providers (Claude, Codex, Gemini, OpenCode, OpenClaw). Includes path traversal protection via canonicalize + starts_with validation, session ID verification against file contents, frontend confirmation dialog with optimistic cache updates, i18n keys (zh/en/ja), and component tests.
This commit is contained in:
140
tests/components/SessionManagerPage.test.tsx
Normal file
140
tests/components/SessionManagerPage.test.tsx
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
|
||||
|
||||
@@ -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