/** * @file LaneGitBar.test.tsx * @description Pins the lane git action row: the ahead/behind readout, which * buttons a repo with no remote may press (and that it may publish to a typed URL), * what an unborn repo and a branch with no upstream show, 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", unborn: false, 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("shows an unborn repo's branch and asks for a first commit instead of a push", () => { render( ); expect(screen.getByTestId("lane-git-bar")).toHaveTextContent("no commits yet"); expect(screen.getByText(/Commit first, then publish/)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Fetch|Pull|Push|Publish/ })).toBeNull(); }); it("publishes a repo with no remote to a typed URL", async () => { const user = userEvent.setup(); const onChanged = vi.fn(); render( ); await user.click(screen.getByRole("button", { name: "Publish…" })); await user.type(screen.getByLabelText(/Remote URL/), "git@host:owner/repo.git"); await user.click(screen.getByRole("button", { name: "Publish" })); await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "publish", { url: "git@host:owner/repo.git", }) ); expect(onChanged).toHaveBeenCalled(); }); it("calls a first push of a branch with no upstream 'Publish branch'", async () => { const user = userEvent.setup(); render( ); expect(screen.queryByRole("button", { name: /^Push$/ })).toBeNull(); await user.click(screen.getByRole("button", { name: "Publish branch" })); await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {})); }); 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" ); }); }); describe("git login (issue #45)", () => { const authErr = (rejected = false) => Object.assign(new Error("git.smartgift.io.vn needs a username and password or access token"), { code: "EGITAUTH", auth: { protocol: "https", host: "git.smartgift.io.vn", username: "vi", rejected }, }); it("asks for a login when git needs one, then resends the same action with it", async () => { vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(authErr()); const user = userEvent.setup(); const onChanged = vi.fn(); render(); await user.click(screen.getByRole("button", { name: /Push/ })); const form = await screen.findByTestId("lane-git-login"); expect(form).toHaveTextContent("git.smartgift.io.vn"); expect(screen.getByLabelText("Username")).toHaveValue("vi"); expect(screen.getByLabelText(/Remember for everyone/)).toBeChecked(); await user.type(screen.getByLabelText("Password or access token"), "s3cret"); await user.click(screen.getByRole("button", { name: "Log in and retry" })); await waitFor(() => expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "push", { credentials: { protocol: "https", host: "git.smartgift.io.vn", username: "vi", password: "s3cret", remember: true, }, }) ); await waitFor(() => expect(screen.queryByTestId("lane-git-login")).toBeNull()); expect(onChanged).toHaveBeenCalled(); expect(document.body.textContent).not.toContain("s3cret"); }); it("says the login was wrong when the host rejected it, and keeps the form", async () => { vi.mocked(api.lanes.gitAction) .mockRejectedValueOnce(authErr()) .mockRejectedValueOnce(authErr(true)); const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: /Fetch/ })); await user.type(await screen.findByLabelText("Password or access token"), "bad"); await user.click(screen.getByRole("button", { name: "Log in and retry" })); expect(await screen.findByTestId("lane-git-login")).toHaveTextContent(/rejected/i); expect(screen.getByLabelText("Password or access token")).toHaveValue(""); }); }); describe("merge (issue #45)", () => { it("merges the picked branch into the current one after a confirm", async () => { const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); 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: "Merge into current" })); await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" }) ); confirm.mockRestore(); }); it("offers to merge the upstream when the branch has diverged", async () => { vi.spyOn(window, "confirm").mockReturnValue(true); const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: "Merge origin/feat/thing" })); await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "origin/feat/thing" }) ); }); it("disables pull and switch while a merge is in progress", () => { render( ); expect(screen.getByRole("button", { name: /Pull/ })).toBeDisabled(); expect(screen.getByRole("button", { name: /Branch/ })).toBeDisabled(); }); });