faa56f5c75
- The branch line, the uncommitted-files hover panel and the git action row (fetch/pull/push/branch) move from the lane card to the top of the Changes panel (new LaneGitHeader.tsx); the card has no git row now. - Header and panel share one git-facts read: stage/unstage/discard/commit and Refresh re-read it, and a git bar action re-reads the diff, the history and the file tree. - The git bar no longer commits: the panel's staged-only box is the one commit path. Orphaned gitBar.commit*/cancel strings removed (en, vi). - "New lane here" is wired from Workspace to the panel.
258 lines
10 KiB
TypeScript
258 lines
10 KiB
TypeScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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<Extract<LaneGitFacts, { available: true }>> = {}): 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(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
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(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={facts({ hasRemote: false, upstream: null })}
|
|
onChanged={vi.fn()}
|
|
/>
|
|
);
|
|
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(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
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(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
|
|
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(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
|
|
|
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(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
|
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(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={facts({ dirty: 0, untracked: 0 })}
|
|
onChanged={vi.fn()}
|
|
onNewLane={onNewLane}
|
|
/>
|
|
);
|
|
|
|
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(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
|
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(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
|
|
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(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
|
|
|
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
|
|
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(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
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<ReturnType<typeof api.lanes.gitBranches>>) => void = () => {};
|
|
vi.mocked(api.lanes.gitBranches).mockReturnValueOnce(
|
|
new Promise((r) => {
|
|
resolveOld = r;
|
|
})
|
|
);
|
|
const user = userEvent.setup();
|
|
const { rerender } = render(
|
|
<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />
|
|
);
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
|
|
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
|
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(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={facts({ upstream: "origin/gone", ahead: null, behind: null })}
|
|
onChanged={vi.fn()}
|
|
/>
|
|
);
|
|
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(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
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"
|
|
);
|
|
});
|
|
});
|