/** * @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 commit sends the typed * message, 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 commits. expect(screen.getByRole("button", { name: /^Commit$/ })).toBeEnabled(); }); it("commits with the typed message and reports the change upward", async () => { const user = userEvent.setup(); const onChanged = vi.fn(); render(); await user.click(screen.getByRole("button", { name: /^Commit$/ })); await user.type(screen.getByLabelText("Commit message"), "fix: the thing"); await user.click(screen.getByRole("button", { name: /Commit 3 files/ })); await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", { message: "fix: the thing" }) ); expect(onChanged).toHaveBeenCalled(); }); it("offers no commit when nothing is uncommitted", () => { render(); expect(screen.getByRole("button", { name: /^Commit$/ })).toBeDisabled(); }); 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("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("commits only the index when asked", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: /^Commit$/ })); await user.type(screen.getByLabelText(/Commit message/), "fix: staged only"); await user.click(screen.getByRole("button", { name: "Commit staged" })); await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", { message: "fix: staged only", only: "staged", }) ); }); 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"); }); });