/** * @file LaneGitBar.test.tsx * @description Pins the lane git action row: the ahead/behind readout, which * buttons a repo with no remote may press, that it offers no commit (the * Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server * refuses it too), and that a failed action shows git's own words rather than * a generic sentence. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { LaneGitBar } from "../LaneGitBar"; import { api } from "../../../lib/api"; import type { LaneGitFacts } from "../../../lib/types"; vi.mock("../../../lib/api", () => ({ api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } }, })); function facts(over: Partial> = {}): LaneGitFacts { return { available: true, branch: "feat/thing", head: "9b3e74a", subject: "wip", dirty: 2, untracked: 1, files: [], hasRemote: true, upstream: "origin/feat/thing", ahead: 2, behind: 1, ...over, }; } beforeEach(() => { vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({ action: "fetch", ok: true, output: "", }); vi.mocked(api.lanes.gitBranches) .mockReset() .mockResolvedValue({ available: true, branches: ["development", "feat/thing"], current: "feat/thing", worktrees: [], }); }); describe("LaneGitBar", () => { it("shows both sides of the upstream comparison", () => { render(); const tracking = screen.getByTestId("lane-git-tracking"); expect(tracking).toHaveTextContent("2"); expect(tracking).toHaveTextContent("1"); }); it("disables the network actions when the repo has no remote", () => { render( ); expect(screen.getByRole("button", { name: /Fetch/ })).toBeDisabled(); expect(screen.getByRole("button", { name: /Push/ })).toBeDisabled(); // Local actions stay available — a repo with no remote still branches. expect(screen.getByRole("button", { name: /Branch/ })).toBeEnabled(); }); it("offers no commit at all — that is the Changes panel's staged-only box", () => { render(); expect(screen.queryByRole("button", { name: /Commit/ })).toBeNull(); }); it("loads the branch list on demand and refuses to switch while dirty", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: /Branch/ })); await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3)); await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development"); // Picking is harmless; applying is what a dirty tree blocks. expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled(); expect(api.lanes.gitAction).not.toHaveBeenCalled(); }); it("switches branch on a clean tree", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: /Branch/ })); const select = await screen.findByLabelText("Switch branch…"); await user.selectOptions(select, "development"); await user.click(screen.getByRole("button", { name: "Switch branch" })); await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "development" }) ); }); it("re-reads the branch list after a switch, so the branch just left is not held", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: /Branch/ })); await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development"); await user.click(screen.getByRole("button", { name: "Switch branch" })); await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2)); }); it("creates a branch without switching, then offers a sibling lane on it", async () => { const user = userEvent.setup(); const onNewLane = vi.fn(); render( ); await user.click(screen.getByRole("button", { name: /Branch/ })); await user.type(await screen.findByLabelText("New branch name…"), "feat/sibling"); await user.click(screen.getByRole("button", { name: "Create" })); await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", { branch: "feat/sibling", }) ); await user.selectOptions(screen.getByLabelText("Switch branch…"), "development"); await user.click(screen.getByRole("button", { name: "New lane here" })); expect(onNewLane).toHaveBeenCalledWith("development"); }); it("offers a sibling lane only on a branch no worktree holds — the server refuses the rest", async () => { vi.mocked(api.lanes.gitBranches).mockResolvedValue({ available: true, branches: ["development", "feat/thing", "feat/free"], current: "feat/thing", worktrees: [ { path: "/repo/lane3", branch: "feat/thing" }, { path: "/repo/dev", branch: "development" }, ], }); const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: /Branch/ })); const select = await screen.findByLabelText("Switch branch…"); const newLane = () => screen.getByRole("button", { name: "New lane here" }); // This lane's own branch: its worktree is this lane. await user.selectOptions(select, "feat/thing"); expect(newLane()).toBeDisabled(); // Held by another worktree: git allows one worktree per branch (EBRANCHBUSY). await user.selectOptions(select, "development"); expect(newLane()).toBeDisabled(); expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev")); await user.selectOptions(select, "feat/free"); expect(newLane()).toBeEnabled(); }); it("prints git's own rejection text when an action fails", async () => { const user = userEvent.setup(); vi.mocked(api.lanes.gitAction).mockRejectedValue( new Error("git push failed\n! [rejected] main -> main (non-fast-forward)") ); render(); await user.click(screen.getByRole("button", { name: /Push/ })); expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent("non-fast-forward"); }); it("re-reads the branch list for the new lane instead of showing the old one's", async () => { const user = userEvent.setup(); const { rerender } = render(); await user.click(screen.getByRole("button", { name: /Branch/ })); await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development"); rerender(); expect(screen.queryByLabelText("Switch branch…")).toBeNull(); await user.click(screen.getByRole("button", { name: /Branch/ })); await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4)); expect(await screen.findByLabelText("Switch branch…")).toHaveValue(""); }); it("sends one branch-create for a double Enter", async () => { vi.mocked(api.lanes.gitAction).mockReturnValue(new Promise(() => {})); const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: /Branch/ })); await user.type(await screen.findByLabelText(/New branch/), "feat/once{Enter}{Enter}"); expect(api.lanes.gitAction).toHaveBeenCalledTimes(1); }); it("forgets which branches the previous lane's worktrees held once the lane changes", async () => { let resolveOld: (v: Awaited>) => void = () => {}; vi.mocked(api.lanes.gitBranches).mockReturnValueOnce( new Promise((r) => { resolveOld = r; }) ); const user = userEvent.setup(); const { rerender } = render( ); await user.click(screen.getByRole("button", { name: /Branch/ })); rerender(); await user.click(screen.getByRole("button", { name: /Branch/ })); const select = await screen.findByLabelText("Switch branch…"); await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4)); // Lane 3's answer lands late: its worktree held `development`. resolveOld({ available: true, branches: ["development", "feat/thing"], current: "feat/thing", worktrees: [{ path: "/repo/dev", branch: "development" }], }); await user.selectOptions(select, "development"); const newLane = screen.getByRole("button", { name: "New lane here" }); expect(newLane).toBeEnabled(); expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev")); }); it("claims no sync state when the upstream comparison could not be counted", () => { render( ); expect(screen.queryByText("in sync")).toBeNull(); expect(screen.queryByTestId("lane-git-tracking")).toBeNull(); }); it("explains a push with nowhere to go instead of the server's generic text", async () => { const user = userEvent.setup(); vi.mocked(api.lanes.gitAction).mockRejectedValue( Object.assign(new Error("this repository has no remote to push to"), { code: "ENOREMOTE" }) ); render(); await user.click(screen.getByRole("button", { name: /Push/ })); expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent( "No remote to push to or pull from" ); }); });