import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, fireEvent, render, screen, waitFor, within, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; 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(); const GROUP_EXPANSION_STORAGE_KEY = "cc-switch.sessionManager.groupExpansionState"; 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 ? (
{title}
{message}
) : null, })); const renderPage = (appId = "codex") => { const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); return { client, ...render( , ), }; }; 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); }; const openViewModeMenu = async () => { await userEvent.click(screen.getByRole("combobox", { name: /查看方式/i })); }; const switchToGroupedView = async () => { await openViewModeMenu(); const groupedOption = await screen.findByRole("option", { name: /分类/i }); await userEvent.click(groupedOption); await waitFor(() => expect( screen.queryByRole("option", { name: /分类/i }), ).not.toBeInTheDocument(), ); }; const switchProviderFilter = async (providerLabel: RegExp) => { const providerFilterTrigger = screen.getByRole("combobox", { name: /供应商筛选/i, }); await userEvent.click(providerFilterTrigger); await userEvent.click( await screen.findByRole("option", { name: providerLabel }), ); }; const enterGroupedBatchMode = async () => { await switchToGroupedView(); fireEvent.click(screen.getByRole("button", { name: /批量管理/i })); }; const collapseAllGroups = () => { fireEvent.click(screen.getByRole("button", { name: /全部收起/i })); }; const expandDirectoryGroup = (provider: string, directory: string) => { fireEvent.click( screen.getByRole("button", { name: new RegExp(`展开或折叠 ${provider} 供应商分组`), }), ); fireEvent.click( screen.getByRole("button", { name: new RegExp(`展开或折叠 ${directory} 目录分组`), }), ); }; describe("SessionManagerPage", () => { beforeEach(() => { toastSuccessMock.mockReset(); toastErrorMock.mockReset(); Element.prototype.scrollIntoView = vi.fn(); window.localStorage.removeItem("cc-switch.sessionManager.listViewMode"); window.localStorage.removeItem(GROUP_EXPANSION_STORAGE_KEY); 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", }, { providerId: "claude", sessionId: "claude-session-1", title: "Claude Session", summary: "Claude summary", projectDir: "/mock/claude", createdAt: 3, lastActiveAt: 30, sourcePath: "/mock/claude/session-1.jsonl", resumeCommand: "claude --resume claude-session-1", }, { providerId: "codex", sessionId: "codex-session-3", title: "Gamma Session", summary: "Gamma summary", projectDir: null, createdAt: 0, lastActiveAt: 5, sourcePath: "/mock/codex/session-3.jsonl", resumeCommand: "codex resume codex-session-3", }, ]; const messages: Record = { "codex:/mock/codex/session-1.jsonl": [ { role: "user", content: "alpha", ts: 20 }, ], "codex:/mock/codex/session-2.jsonl": [ { role: "user", content: "beta", ts: 10 }, ], "codex:/mock/codex/session-3.jsonl": [ { role: "user", content: "gamma", ts: 5 }, ], "claude:/mock/claude/session-1.jsonl": [ { role: "user", content: "claude", ts: 30 }, ], }; 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.getByRole("heading", { name: "Alpha Session" }), ).toBeInTheDocument(), ); 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("已选 3 项")).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(); }); it("switches to grouped view collapsed by default and shows collapse control", async () => { renderPage("all"); await waitFor(() => expect( screen.getByRole("heading", { name: "Claude Session" }), ).toBeInTheDocument(), ); await switchToGroupedView(); expect( screen.getByRole("button", { name: /全部收起/i }), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: /展开或折叠 codex 供应商分组/, }), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: /展开或折叠 claude 供应商分组/, }), ).toBeInTheDocument(); expect( screen.queryByRole("button", { name: /展开或折叠 codex 目录分组/ }), ).not.toBeInTheDocument(); expect( screen.queryByRole("button", { name: /Alpha Session/ }), ).not.toBeInTheDocument(); }); it("persists manual expansion and collapses all grouped sessions", async () => { renderPage(); await waitFor(() => expect( screen.getByRole("heading", { name: "Alpha Session" }), ).toBeInTheDocument(), ); await switchToGroupedView(); expandDirectoryGroup("codex", "codex"); expect( screen.getByRole("button", { name: /展开或折叠 codex 目录分组/ }), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: /Alpha Session/ }), ).toBeInTheDocument(); await waitFor(() => expect( JSON.parse(window.localStorage.getItem(GROUP_EXPANSION_STORAGE_KEY)!), ).toEqual({ expandedProviderIds: ["codex"], expandedDirectoryKeys: ["codex:/mock/codex"], }), ); collapseAllGroups(); await waitFor(() => expect( screen.queryByRole("button", { name: /展开或折叠 codex 目录分组/ }), ).not.toBeInTheDocument(), ); await waitFor(() => expect( JSON.parse(window.localStorage.getItem(GROUP_EXPANSION_STORAGE_KEY)!), ).toEqual({ expandedProviderIds: [], expandedDirectoryKeys: [], }), ); }); it("keeps filtered grouped sessions collapsed until expanding the group", async () => { renderPage("all"); await waitFor(() => expect(screen.getByText("Alpha Session")).toBeInTheDocument(), ); fireEvent.click(screen.getByRole("button", { name: /Alpha Session/ })); await switchToGroupedView(); await switchProviderFilter(/Claude Code/i); await waitFor(() => expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(), ); expect( screen.getByRole("heading", { name: "Claude Session" }), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: /展开或折叠 claude 供应商分组/, }), ).toBeInTheDocument(); expect( screen.queryByRole("button", { name: /展开或折叠 claude 目录分组/ }), ).not.toBeInTheDocument(); expandDirectoryGroup("claude", "claude"); expect( screen.getByRole("button", { name: /展开或折叠 claude 目录分组/ }), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: /Claude Session/ }), ).toBeInTheDocument(); expect(screen.queryByText("Gamma Session")).not.toBeInTheDocument(); }); it("supports batch deletion from grouped view", async () => { renderPage(); await waitFor(() => expect( screen.getByRole("heading", { name: "Alpha Session" }), ).toBeInTheDocument(), ); await switchToGroupedView(); 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(); expect(screen.queryByText("Gamma Session")).not.toBeInTheDocument(); }); expect(toastErrorMock).not.toHaveBeenCalled(); expect(toastSuccessMock).toHaveBeenCalled(); }); it("selects visible deletable sessions by provider group in grouped batch mode", async () => { renderPage("all"); await waitFor(() => expect( screen.getByRole("heading", { name: "Claude Session" }), ).toBeInTheDocument(), ); await enterGroupedBatchMode(); const codexProviderCheckbox = screen.getByRole("checkbox", { name: /选择 codex 供应商分组内会话/, }); const claudeProviderCheckbox = screen.getByRole("checkbox", { name: /选择 claude 供应商分组内会话/, }); fireEvent.click(codexProviderCheckbox); expect(codexProviderCheckbox).toBeChecked(); expect(claudeProviderCheckbox).not.toBeChecked(); expect(screen.getByText("已选 3 项")).toBeInTheDocument(); fireEvent.click(codexProviderCheckbox); expect(codexProviderCheckbox).not.toBeChecked(); expect(screen.getByText("已选 0 项")).toBeInTheDocument(); }); it("selects visible deletable sessions by directory group and marks the provider as mixed", async () => { renderPage(); await waitFor(() => expect( screen.getByRole("heading", { name: "Alpha Session" }), ).toBeInTheDocument(), ); await enterGroupedBatchMode(); expandDirectoryGroup("codex", "codex"); const providerCheckbox = screen.getByRole("checkbox", { name: /选择 codex 供应商分组内会话/, }); const codexDirectoryCheckbox = screen.getByRole("checkbox", { name: /选择 codex 目录分组内会话/, }); fireEvent.click(codexDirectoryCheckbox); expect(codexDirectoryCheckbox).toBeChecked(); expect(providerCheckbox).toHaveAttribute("aria-checked", "mixed"); expect(screen.getByText("已选 2 项")).toBeInTheDocument(); }); it("marks grouped batch checkboxes as mixed when only one session is selected", async () => { renderPage(); await waitFor(() => expect( screen.getByRole("heading", { name: "Alpha Session" }), ).toBeInTheDocument(), ); await enterGroupedBatchMode(); expandDirectoryGroup("codex", "codex"); fireEvent.click(screen.getAllByRole("checkbox", { name: "选择会话" })[0]); expect( screen.getByRole("checkbox", { name: /选择 codex 供应商分组内会话/, }), ).toHaveAttribute("aria-checked", "mixed"); expect( screen.getByRole("checkbox", { name: /选择 codex 目录分组内会话/ }), ).toHaveAttribute("aria-checked", "mixed"); expect(screen.getByText("已选 1 项")).toBeInTheDocument(); }); it("batch deletes only sessions selected from a grouped directory", async () => { renderPage(); await waitFor(() => expect( screen.getByRole("heading", { name: "Alpha Session" }), ).toBeInTheDocument(), ); await enterGroupedBatchMode(); expandDirectoryGroup("codex", "codex"); fireEvent.click( screen.getByRole("checkbox", { name: /选择 codex 目录分组内会话/, }), ); 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(); }); expect( screen.getByRole("button", { name: /展开或折叠 未知目录 目录分组/ }), ).toBeInTheDocument(); expect( screen.queryByRole("checkbox", { name: "选择会话" }), ).not.toBeInTheDocument(); fireEvent.click( screen.getByRole("button", { name: /展开或折叠 未知目录 目录分组/ }), ); expect( screen.getByRole("checkbox", { name: "选择会话" }), ).toBeInTheDocument(); expect(toastErrorMock).not.toHaveBeenCalled(); expect(toastSuccessMock).toHaveBeenCalled(); }); });