import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, cleanup, fireEvent, within } from "@testing-library/react"; import type { ComponentProps } from "react"; vi.mock("./changes-panel-file-row", () => ({ FileRow: ({ file }: { file: { path: string } }) =>
  • {file.path}
  • , BulkActionBar: () => null, DefaultActionButtons: () => null, })); vi.mock("./commit-row", () => ({ CommitRow: ({ commit, isLatest }: { commit: { commit_sha: string }; isLatest: boolean }) => (
  • {commit.commit_sha}
  • ), })); vi.mock("@/hooks/use-multi-select", () => ({ useMultiSelect: () => ({ selectedPaths: new Set(), isSelected: () => false, handleClick: vi.fn(), clearSelection: vi.fn(), }), })); vi.mock("@/components/state-provider", () => ({ useAppStore: ( selector: (state: { userSettings: { changesPanelLayout: "flat" | "tree" } }) => unknown, ) => selector({ userSettings: { changesPanelLayout: "flat" } }), })); import { FileListSection, CommitsSection, PRFilesSection } from "./changes-panel-timeline"; const REPO_HEADER_TID = "changes-repo-header"; const COMMIT_ROW_TID = "commit-row"; const COMMITS_SECTION_TOGGLE_TID = "commits-section-collapse-toggle"; const ARIA_EXPANDED = "aria-expanded"; afterEach(cleanup); type Props = ComponentProps; type CommitProps = ComponentProps; const baseProps: Omit = { pendingStageFiles: new Set(), onOpenDiff: vi.fn(), onEditFile: vi.fn(), onStage: vi.fn(), onUnstage: vi.fn(), onDiscard: vi.fn(), }; function file(path: string, repo?: string): Props["files"][number] { return { path, status: "modified", staged: false, plus: 1, minus: 0, oldPath: undefined, repositoryName: repo, }; } function commit(sha: string, message: string, repo?: string): CommitProps["commits"][number] { return { commit_sha: sha, commit_message: message, insertions: 1, deletions: 0, pushed: false, repository_name: repo, }; } describe("FileListSection — multi-repo grouping", () => { it("renders a flat file list (no per-repo header) for single-repo workspaces", () => { // Single-repo: drop the redundant per-repo sub-header above a flat file // list. Action buttons (Stage all / Commit / Unstage all) move up to the // section header so they remain accessible. render( undefined} onRepoAction={() => undefined} files={[file("a.ts"), file("b.ts")]} />, ); expect(screen.queryAllByTestId(REPO_HEADER_TID)).toHaveLength(0); expect(screen.getAllByTestId("file-row")).toHaveLength(2); // Section-level action button is still rendered. expect(screen.getByTestId("repo-group-action").textContent).toContain("Stage all"); }); it("renders one header per repo when 2+ repos are present", () => { render( undefined} files={[ file("src/app.tsx", "frontend"), file("src/api.ts", "frontend"), file("handlers/task.go", "backend"), ]} />, ); const headers = screen.getAllByTestId(REPO_HEADER_TID); expect(headers).toHaveLength(2); expect(headers[0].textContent).toContain("frontend"); expect(headers[0].textContent).toContain("2"); expect(headers[1].textContent).toContain("backend"); expect(headers[1].textContent).toContain("1"); }); it("shows a header for a single named repo too", () => { render( undefined} files={[file("a.ts", "only-repo")]} />, ); expect(screen.getByTestId(REPO_HEADER_TID).textContent).toContain("only-repo"); }); it("collapses one repo independently when its header is clicked", () => { render( undefined} files={[ file("src/app.tsx", "frontend"), file("src/api.ts", "frontend"), file("handlers/task.go", "backend"), ]} />, ); expect(screen.getAllByTestId("file-row")).toHaveLength(3); const groups = screen.getAllByTestId("changes-repo-group"); const frontendGroup = groups.find( (g) => g.getAttribute("data-repository-name") === "frontend", )!; const frontendHeader = within(frontendGroup).getByTestId(REPO_HEADER_TID); fireEvent.click(frontendHeader); // frontend's two rows hidden, backend's one row still visible expect(screen.getAllByTestId("file-row")).toHaveLength(1); expect(frontendHeader.getAttribute(ARIA_EXPANDED)).toBe("false"); fireEvent.click(frontendHeader); expect(screen.getAllByTestId("file-row")).toHaveLength(3); expect(frontendHeader.getAttribute(ARIA_EXPANDED)).toBe("true"); }); }); describe("CommitsSection", () => { it("renders the commits section header collapsed by default", () => { render(); // Commits section is collapsed by default; the toggle reflects that and // the commit rows are not in the DOM until the user expands the section. const toggle = screen.getByTestId(COMMITS_SECTION_TOGGLE_TID); expect(toggle.getAttribute(ARIA_EXPANDED)).toBe("false"); expect(screen.queryByTestId(COMMIT_ROW_TID)).toBeNull(); fireEvent.click(toggle); expect(toggle.getAttribute(ARIA_EXPANDED)).toBe("true"); expect(screen.getByTestId(COMMIT_ROW_TID)).toBeTruthy(); }); it("groups commits per repo when 2+ repos are present", () => { render( , ); fireEvent.click(screen.getByTestId(COMMITS_SECTION_TOGGLE_TID)); const headers = screen.getAllByTestId("commits-repo-header"); expect(headers).toHaveLength(2); expect(headers[0].textContent).toContain("frontend"); expect(headers[0].textContent).toContain("2"); expect(headers[1].textContent).toContain("backend"); expect(headers[1].textContent).toContain("1"); expect(screen.getAllByTestId(COMMIT_ROW_TID)).toHaveLength(3); }); it("renders commits flat (no commits-repo header) for single-repo workspaces", () => { // Single-repo: drop the per-repo "REPOSITORY" sub-header. Push / PR // buttons live in the section header instead. render( undefined} />, ); fireEvent.click(screen.getByTestId(COMMITS_SECTION_TOGGLE_TID)); expect(screen.queryAllByTestId("commits-repo-header")).toHaveLength(0); expect(screen.getAllByTestId(COMMIT_ROW_TID)).toHaveLength(2); }); }); describe("CommitsSection actions", () => { it("does not show Push when commits are already on the tracked remote", () => { render( undefined} perRepoStatus={[{ repository_name: "", ahead: 0 }]} />, ); expect(screen.queryByTestId("commits-repo-push")).toBeNull(); fireEvent.click(screen.getByTestId(COMMITS_SECTION_TOGGLE_TID)); expect(screen.getAllByTestId(COMMIT_ROW_TID)).toHaveLength(2); expect(screen.queryByTestId("commits-repo-push")).toBeNull(); }); it("labels Push with the actual ahead count instead of the commit list count", () => { render( undefined} perRepoStatus={[{ repository_name: "", ahead: 1 }]} />, ); const push = screen.getByTestId("commits-repo-push"); expect(push.textContent).toContain("Push"); expect(push.textContent).toContain("1"); expect(push.textContent).not.toContain("3"); }); // Regression: previously isLatest was computed against the merged list, so // only ONE commit globally was marked latest. Each repo's newest unpushed // commit must be latest in its own group — otherwise revert/amend buttons // are absent on every repo except one, and clicking revert via context menu // hits the backend's "can only revert latest commit" gate. it("marks each repo's newest unpushed commit as latest (per-repo, not global)", () => { render( undefined} />, ); fireEvent.click(screen.getByTestId(COMMITS_SECTION_TOGGLE_TID)); const rows = screen.getAllByTestId(COMMIT_ROW_TID); const latestByShas = rows .filter((r) => r.getAttribute("data-is-latest") === "true") .map((r) => r.getAttribute("data-sha")); expect(latestByShas.sort()).toEqual(["backend-only", "frontend-new"]); }); }); describe("section auto-expand (defaultCollapsed prop)", () => { const PR_TOGGLE_TID = "pr-changes-section-collapse-toggle"; function prFile(path: string) { return { path, status: "modified" as const, plus: 1, minus: 0, oldPath: undefined }; } it("PR Changes is collapsed by default (no prop)", () => { render(); expect(screen.getByTestId(PR_TOGGLE_TID).getAttribute(ARIA_EXPANDED)).toBe("false"); expect(screen.queryByTestId("pr-files-list")).toBeNull(); }); it("PR Changes is expanded when defaultCollapsed={false}", () => { render( , ); expect(screen.getByTestId(PR_TOGGLE_TID).getAttribute(ARIA_EXPANDED)).toBe("true"); expect(screen.getByTestId("pr-files-list")).toBeTruthy(); }); it("Commits is expanded when defaultCollapsed={false}", () => { render( , ); const toggle = screen.getByTestId(COMMITS_SECTION_TOGGLE_TID); expect(toggle.getAttribute(ARIA_EXPANDED)).toBe("true"); expect(screen.getByTestId(COMMIT_ROW_TID)).toBeTruthy(); }); });