import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; const { navigateMock } = vi.hoisted(() => ({ navigateMock: vi.fn(), })); const { listSessionsMock, deleteSessionMock, renameSessionMock, showRecordingOverlayMock, exportSessionPackageMock, importSessionPackageMock, revealInFileManagerMock, getSessionMetadataMock, } = vi.hoisted(() => ({ listSessionsMock: vi.fn(), deleteSessionMock: vi.fn(), renameSessionMock: vi.fn(), showRecordingOverlayMock: vi.fn(), exportSessionPackageMock: vi.fn(), importSessionPackageMock: vi.fn(), revealInFileManagerMock: vi.fn(), getSessionMetadataMock: vi.fn(), })); const { scanSessionForSensitiveDataMock } = vi.hoisted(() => ({ scanSessionForSensitiveDataMock: vi.fn(), })); const { getWorkflowMetricsMock, initializeWorkflowMetricsStoreMock, trackWorkflowEventMock } = vi.hoisted(() => ({ getWorkflowMetricsMock: vi.fn(), initializeWorkflowMetricsStoreMock: vi.fn(), trackWorkflowEventMock: vi.fn(), })); const { registerCategoryMock, unregisterCategoryMock, setShowHelpMock } = vi.hoisted(() => ({ registerCategoryMock: vi.fn(), unregisterCategoryMock: vi.fn(), setShowHelpMock: vi.fn(), })); vi.mock("react-router-dom", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useNavigate: () => navigateMock, }; }); vi.mock("../lib/api", () => ({ listSessions: listSessionsMock, deleteSession: deleteSessionMock, renameSession: renameSessionMock, showRecordingOverlay: showRecordingOverlayMock, exportSessionPackage: exportSessionPackageMock, importSessionPackage: importSessionPackageMock, revealInFileManager: revealInFileManagerMock, getSessionMetadata: getSessionMetadataMock, onProcessingStarted: vi.fn().mockResolvedValue(() => {}), onProcessingComplete: vi.fn().mockResolvedValue(() => {}), onProcessingError: vi.fn().mockResolvedValue(() => {}), })); vi.mock("../lib/privacyScan", () => ({ scanSessionForSensitiveData: scanSessionForSensitiveDataMock, })); vi.mock("../lib/usageMetrics", () => ({ getWorkflowMetrics: getWorkflowMetricsMock, initializeWorkflowMetricsStore: initializeWorkflowMetricsStoreMock, trackWorkflowEvent: trackWorkflowEventMock, })); vi.mock("../contexts/ShortcutContext", () => ({ useShortcutContext: () => ({ registerCategory: registerCategoryMock, unregisterCategory: unregisterCategoryMock, setShowHelp: setShowHelpMock, }), })); vi.mock("../components/ShareDialog", () => ({ default: ({ sessionId }: { sessionId: string }) =>
Share modal for {sessionId}
, })); import LibraryView from "./LibraryView"; type SessionSummary = { session_id: string; title: string | null; created_at: string; duration_ms: number | null; step_count: number; thumbnail_path: string | null; has_terminal: boolean; git_branch: string | null; git_repo: string | null; }; function renderView() { return render( ); } const SESSIONS: SessionSummary[] = [ { session_id: "session-aaa111", title: "Checkout flow", created_at: "1735689600", duration_ms: 65000, step_count: 3, thumbnail_path: null, has_terminal: true, git_branch: "main", git_repo: "repo-a", }, { session_id: "session-bbb222", title: "Settings page", created_at: "1735603200", duration_ms: 120000, step_count: 1, thumbnail_path: null, has_terminal: false, git_branch: null, git_repo: null, }, ]; describe("LibraryView", () => { beforeEach(() => { vi.useRealTimers(); navigateMock.mockReset(); listSessionsMock.mockReset(); deleteSessionMock.mockReset(); renameSessionMock.mockReset(); showRecordingOverlayMock.mockReset(); exportSessionPackageMock.mockReset(); importSessionPackageMock.mockReset(); revealInFileManagerMock.mockReset(); getSessionMetadataMock.mockReset(); scanSessionForSensitiveDataMock.mockReset(); getWorkflowMetricsMock.mockReset(); initializeWorkflowMetricsStoreMock.mockReset(); trackWorkflowEventMock.mockReset(); registerCategoryMock.mockReset(); unregisterCategoryMock.mockReset(); setShowHelpMock.mockReset(); getWorkflowMetricsMock.mockReturnValue({ windowDays: 30, recordingsStarted: 0, sessionsShared: 0, shareCompletionRate: 0, medianRecordToShareSeconds: null, medianEditsPerSharedSession: null, }); initializeWorkflowMetricsStoreMock.mockResolvedValue(undefined); exportSessionPackageMock.mockResolvedValue("/tmp/session-package"); revealInFileManagerMock.mockResolvedValue(undefined); importSessionPackageMock.mockResolvedValue({ session_id: "session-imported", source_session_id: "source-session", imported_files: 4, }); getSessionMetadataMock.mockResolvedValue({ session_id: "session-aaa111", created_at: "1735689600", display: { id: "display-1", resolution: "1920x1080", refresh_rate: 60 }, duration_ms: 1200, event_count: 12, recording_path: "/tmp/recording.mp4", events_path: "/tmp/events.ndjson", title: "Checkout flow", thumbnail_path: null, has_terminal: false, git_branch: null, git_commit: null, }); scanSessionForSensitiveDataMock.mockResolvedValue({ scannedPaths: ["/tmp/events.ndjson"], findings: [], highSeverityCount: 0, mediumSeverityCount: 0, }); }); afterEach(() => { cleanup(); }); it("renders sessions and filters by search query", async () => { listSessionsMock.mockResolvedValue(SESSIONS); renderView(); expect(screen.getByText("Loading sessions...")).toBeTruthy(); expect(await screen.findByText("Checkout flow")).toBeTruthy(); expect(screen.getByText("Settings page")).toBeTruthy(); const input = screen.getByPlaceholderText("Search"); fireEvent.change(input, { target: { value: "checkout" } }); expect(screen.getByText("Checkout flow")).toBeTruthy(); expect(screen.queryByText("Settings page")).toBeNull(); }); it("shows error banner when sessions fail to load", async () => { listSessionsMock.mockRejectedValue(new Error("boom")); renderView(); expect(await screen.findByText("Error: boom")).toBeTruthy(); }); it("shows no-results state for unmatched search", async () => { listSessionsMock.mockResolvedValue(SESSIONS); renderView(); expect(await screen.findByText("Checkout flow")).toBeTruthy(); fireEvent.change(screen.getByPlaceholderText("Search"), { target: { value: "not-found" }, }); expect(screen.getByText("No sessions found")).toBeTruthy(); expect(screen.getByText("Try a different search term")).toBeTruthy(); }); it("starts rename flow and saves on Enter", async () => { listSessionsMock .mockResolvedValueOnce(SESSIONS) .mockResolvedValueOnce([{ ...SESSIONS[0], title: "Checkout flow v2" }]); renameSessionMock.mockResolvedValue(undefined); renderView(); expect(await screen.findByText("Checkout flow")).toBeTruthy(); fireEvent.click(screen.getByText("Checkout flow")); const editInput = screen.getByDisplayValue("Checkout flow"); fireEvent.change(editInput, { target: { value: "Checkout flow v2" } }); fireEvent.keyDown(editInput, { key: "Enter" }); await waitFor(() => { expect(renameSessionMock).toHaveBeenCalledWith("session-aaa111", "Checkout flow v2"); }); expect(listSessionsMock).toHaveBeenCalledTimes(2); }); it("cancels rename when title is empty", async () => { listSessionsMock.mockResolvedValue(SESSIONS); renderView(); expect(await screen.findByText("Checkout flow")).toBeTruthy(); fireEvent.click(screen.getByText("Checkout flow")); const editInput = screen.getByDisplayValue("Checkout flow"); fireEvent.change(editInput, { target: { value: " " } }); fireEvent.keyDown(editInput, { key: "Enter" }); await waitFor(() => { expect(screen.getByText("Checkout flow")).toBeTruthy(); }); expect(renameSessionMock).not.toHaveBeenCalled(); }); it("deletes a session and reloads list", async () => { listSessionsMock.mockResolvedValueOnce(SESSIONS).mockResolvedValueOnce([SESSIONS[1]]); deleteSessionMock.mockResolvedValue(undefined); renderView(); expect(await screen.findByText("Checkout flow")).toBeTruthy(); fireEvent.click(screen.getAllByTitle("Delete")[0]); await waitFor(() => { expect(deleteSessionMock).toHaveBeenCalledWith("session-aaa111"); }); expect(listSessionsMock).toHaveBeenCalledTimes(2); }); it("logs when delete fails", async () => { listSessionsMock.mockResolvedValue(SESSIONS); deleteSessionMock.mockRejectedValue(new Error("delete failed")); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); renderView(); expect(await screen.findByText("Checkout flow")).toBeTruthy(); fireEvent.click(screen.getAllByTitle("Delete")[0]); await waitFor(() => { expect(consoleSpy).toHaveBeenCalled(); }); expect(listSessionsMock).toHaveBeenCalledTimes(1); consoleSpy.mockRestore(); }); it("triggers share and new recording actions", async () => { listSessionsMock.mockResolvedValue(SESSIONS); renderView(); expect(await screen.findByText("Checkout flow")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: /create/i })); expect(showRecordingOverlayMock).toHaveBeenCalledTimes(1); fireEvent.click(screen.getAllByTitle("Share")[0]); expect(screen.getByText("Share modal for session-aaa111")).toBeTruthy(); }); it("blocks export when privacy scan finds secrets and allows explicit override", async () => { listSessionsMock.mockResolvedValue(SESSIONS); scanSessionForSensitiveDataMock.mockResolvedValueOnce({ scannedPaths: ["/tmp/events.ndjson"], findings: [ { rule: "OpenAI API key", severity: "high", path: "/tmp/events.ndjson", snippet: "... sk-abcdef12345678901234567890 ...", }, ], highSeverityCount: 1, mediumSeverityCount: 0, }); renderView(); expect(await screen.findByText("Checkout flow")).toBeTruthy(); fireEvent.click(screen.getAllByTitle("Export package")[0]); await waitFor(() => { expect(getSessionMetadataMock).toHaveBeenCalledWith("session-aaa111"); }); await waitFor(() => { expect(scanSessionForSensitiveDataMock).toHaveBeenCalled(); }); expect(exportSessionPackageMock).not.toHaveBeenCalled(); expect(screen.getByText(/Privacy check flagged potential secrets/i)).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: /export package anyway/i })); await waitFor(() => { expect(exportSessionPackageMock).toHaveBeenCalledWith("session-aaa111"); }); await waitFor(() => { expect(revealInFileManagerMock).toHaveBeenCalledWith("/tmp/session-package"); }); }); it("registers and unregisters navigation shortcuts", async () => { listSessionsMock.mockResolvedValue(SESSIONS); const view = renderView(); expect(await screen.findByText("Checkout flow")).toBeTruthy(); expect(registerCategoryMock).toHaveBeenCalledWith( expect.objectContaining({ name: "Navigation" }) ); view.unmount(); expect(unregisterCategoryMock).toHaveBeenCalledWith("Navigation"); }); });