mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-07 22:28:26 +08:00
* feat: add bulk delete for session manager * fix: address batch delete review issues * fix: keep session list in sync after batch delete
332 lines
9.0 KiB
TypeScript
332 lines
9.0 KiB
TypeScript
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import {
|
|
act,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} from "@testing-library/react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
|
import { sessionsApi } from "@/lib/api/sessions";
|
|
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 {
|
|
client,
|
|
...render(
|
|
<QueryClientProvider client={client}>
|
|
<SessionManagerPage appId="codex" />
|
|
</QueryClientProvider>,
|
|
),
|
|
};
|
|
};
|
|
|
|
const openSearch = () => {
|
|
const searchButton = Array.from(screen.getAllByRole("button")).find(
|
|
(button) => button.querySelector(".lucide-search"),
|
|
);
|
|
|
|
if (!searchButton) {
|
|
throw new Error("Search button not found");
|
|
}
|
|
|
|
fireEvent.click(searchButton);
|
|
};
|
|
|
|
const closeSearch = () => {
|
|
const closeButton = Array.from(screen.getAllByRole("button")).find(
|
|
(button) => button.querySelector(".lucide-x"),
|
|
);
|
|
|
|
if (!closeButton) {
|
|
throw new Error("Search close button not found");
|
|
}
|
|
|
|
fireEvent.click(closeButton);
|
|
};
|
|
|
|
describe("SessionManagerPage", () => {
|
|
beforeEach(() => {
|
|
toastSuccessMock.mockReset();
|
|
toastErrorMock.mockReset();
|
|
Element.prototype.scrollIntoView = vi.fn();
|
|
|
|
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();
|
|
});
|
|
|
|
it("removes a deleted session from filtered search results", async () => {
|
|
renderPage();
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByRole("heading", { name: "Alpha Session" }),
|
|
).toBeInTheDocument(),
|
|
);
|
|
|
|
openSearch();
|
|
|
|
fireEvent.change(screen.getByRole("textbox"), {
|
|
target: { value: "Alpha" },
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getAllByText("Alpha Session")).toHaveLength(2),
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /删除会话/i }));
|
|
|
|
const dialog = screen.getByTestId("confirm-dialog");
|
|
fireEvent.click(within(dialog).getByRole("button", { name: /删除会话/i }));
|
|
|
|
await waitFor(() =>
|
|
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(),
|
|
);
|
|
|
|
expect(
|
|
screen.getByText("sessionManager.selectSession"),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.queryByText("sessionManager.emptySession"),
|
|
).not.toBeInTheDocument();
|
|
expect(toastErrorMock).not.toHaveBeenCalled();
|
|
expect(toastSuccessMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it("restores batch delete controls when deleteMany rejects", async () => {
|
|
const deleteManySpy = vi
|
|
.spyOn(sessionsApi, "deleteMany")
|
|
.mockRejectedValueOnce(new Error("network error"));
|
|
|
|
renderPage();
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByRole("heading", { name: "Alpha Session" }),
|
|
).toBeInTheDocument(),
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
|
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
|
|
fireEvent.click(screen.getByRole("button", { name: /批量删除/i }));
|
|
|
|
const dialog = screen.getByTestId("confirm-dialog");
|
|
fireEvent.click(
|
|
within(dialog).getByRole("button", { name: /删除所选会话/i }),
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(toastErrorMock).toHaveBeenCalledWith("network error"),
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByRole("button", { name: /批量删除/i }),
|
|
).not.toBeDisabled(),
|
|
);
|
|
|
|
deleteManySpy.mockRestore();
|
|
});
|
|
|
|
it("keeps the exit batch mode button visible when search hides all sessions", async () => {
|
|
renderPage();
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByRole("heading", { name: "Alpha Session" }),
|
|
).toBeInTheDocument(),
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
|
openSearch();
|
|
fireEvent.change(screen.getByRole("textbox"), {
|
|
target: { value: "NoSuchSession" },
|
|
});
|
|
|
|
await waitFor(() => expect(screen.queryByText("Alpha Session")).toBeNull());
|
|
|
|
expect(screen.getByRole("button", { name: /退出批量管理/i })).toBeVisible();
|
|
});
|
|
|
|
it("drops hidden selections when search narrows the result set", async () => {
|
|
renderPage();
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByRole("heading", { name: "Alpha Session" }),
|
|
).toBeInTheDocument(),
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
|
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
|
|
|
|
expect(screen.getByText("已选 2 项")).toBeInTheDocument();
|
|
|
|
openSearch();
|
|
fireEvent.change(screen.getByRole("textbox"), {
|
|
target: { value: "Alpha" },
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(screen.queryByText("Beta Session")).not.toBeInTheDocument(),
|
|
);
|
|
|
|
closeSearch();
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByText("已选 1 项")).toBeInTheDocument(),
|
|
);
|
|
});
|
|
|
|
it("removes successfully deleted sessions from the UI before refetch completes", async () => {
|
|
const view = renderPage();
|
|
let resolveInvalidate!: () => void;
|
|
const invalidateSpy = vi
|
|
.spyOn(view.client, "invalidateQueries")
|
|
.mockImplementation(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveInvalidate = () => resolve(undefined);
|
|
}),
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByRole("heading", { name: "Alpha Session" }),
|
|
).toBeInTheDocument(),
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /批量管理/i }));
|
|
fireEvent.click(screen.getByRole("button", { name: /全选当前/i }));
|
|
fireEvent.click(screen.getByRole("button", { name: /批量删除/i }));
|
|
|
|
const dialog = screen.getByTestId("confirm-dialog");
|
|
fireEvent.click(
|
|
within(dialog).getByRole("button", { name: /删除所选会话/i }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Beta Session")).not.toBeInTheDocument();
|
|
});
|
|
|
|
await act(async () => {
|
|
resolveInvalidate();
|
|
});
|
|
invalidateSpy.mockRestore();
|
|
});
|
|
});
|