Files
AgentHub/client/src/components/lanes/__tests__/BranchManager.test.tsx
T
nntrivi2001 2cc492bb88 feat(git): Branches & tags panel — rename, branch from any ref, upstream, tags
GET /git/refs lists local branches (upstream, ahead/behind, last commit,
merged, held by a worktree, protected), remote branches grouped by remote
and tags (target, annotated message/tagger/date). New writes in
server/lib/git-refs.js, dispatched from git-ops.js: branch-rename,
branch-upstream (set/unset), tag-create (light/annotated), tag-delete
(confirm), tag-push (one tag to origin, no --tags, no force), and
branch-create with a looked-up from + switch on a clean tree. Every ref is
looked up, never passed through; repo-wide writes are repo-root only
(ESUBDIR) and refused during any operation (EMERGING).

Client: git/BranchManager + TagManager dialog opened from the git bar's
tags icon, en+vi (gitRefs namespace), keyboard navigable.

# Conflicts:
#	CLAUDE.md
#	client/src/components/lanes/LaneGitBar.tsx
#	docs/API.md
#	server/__tests__/lanes-api.test.js
#	server/routes/lanes.js
2026-11-02 12:00:55 +07:00

297 lines
11 KiB
TypeScript

/**
* @file BranchManager.test.tsx
* @description Pins the git bar's Branches panel: local branches with the
* current one marked and ahead/behind shown, remote branches grouped by
* remote, the search box, arrow-key walking, and each action's request —
* rename, new branch from a remote branch (switch only offered on a clean
* tree), set/unset upstream, merge into current, delete refused with its
* reason — plus the Tags tab: annotated create, push one tag, delete with
* `confirm: true` after the themed confirm, and git's error shown verbatim.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BranchManager } from "../git/BranchManager";
import { api, type GitRefs } from "../../../lib/api";
import { confirmDialog } from "../../../lib/confirm";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number) => lane,
api: { lanes: { gitRefs: vi.fn(), gitRefAction: vi.fn() } },
}));
const REFS: GitRefs = {
available: true,
current: "main",
head: "aaaaaaa",
truncated: false,
local: [
{
name: "main",
sha: "a1",
current: true,
upstream: "origin/main",
ahead: 2,
behind: 1,
gone: false,
subject: "latest work",
date: null,
merged: true,
worktree: null,
protected: true,
},
{
name: "feature/very-long-branch-name",
sha: "b2",
current: false,
upstream: null,
ahead: null,
behind: null,
gone: false,
subject: "feature commit",
date: null,
merged: false,
worktree: null,
protected: false,
},
{
name: "held",
sha: "c3",
current: false,
upstream: null,
ahead: null,
behind: null,
gone: false,
subject: "x",
date: null,
merged: true,
worktree: "/tmp/other",
protected: false,
},
],
remotes: [
{
remote: "origin",
branches: [
{ name: "origin/main", branch: "main", sha: "a1", subject: "s", date: null },
{ name: "origin/remote-only", branch: "remote-only", sha: "d4", subject: "r", date: null },
],
},
],
tags: [
{
name: "v1",
sha: "a1b2c3d4",
annotated: true,
message: "release one",
tagger: "T",
date: null,
},
],
};
function setup(over: { dirty?: number } = {}) {
const onChanged = vi.fn();
render(
<BranchManager
laneId={3}
dirty={over.dirty ?? 0}
merging={false}
onClose={vi.fn()}
onChanged={onChanged}
/>
);
return { onChanged };
}
beforeEach(() => {
vi.mocked(api.lanes.gitRefs).mockReset().mockResolvedValue(REFS);
vi.mocked(api.lanes.gitRefAction)
.mockReset()
.mockResolvedValue({ action: "x", ok: true, output: "" });
vi.mocked(confirmDialog).mockClear();
});
const row = (name: RegExp) => screen.getByRole("button", { name });
describe("BranchManager", () => {
it("lists local branches with the current marked and remote ones grouped", async () => {
setup();
await screen.findByText("feature/very-long-branch-name");
const main = row(/^main/);
expect(within(main).getByText("current")).toBeInTheDocument();
expect(main).toHaveTextContent("2");
expect(main).toHaveTextContent("1");
expect(screen.getByText("Remote · origin")).toBeInTheDocument();
expect(screen.getByText("origin/remote-only")).toBeInTheDocument();
// A long name truncates with its full text as the tooltip.
expect(screen.getByText("feature/very-long-branch-name")).toHaveAttribute(
"title",
"feature/very-long-branch-name"
);
});
it("filters by the search box and walks rows with the arrow keys", async () => {
setup();
await screen.findByText("held");
await userEvent.type(screen.getByRole("textbox", { name: "Search branches" }), "remote");
expect(screen.queryByText("held")).toBeNull();
expect(screen.getByText("origin/remote-only")).toBeInTheDocument();
await userEvent.clear(screen.getByRole("textbox", { name: "Search branches" }));
await userEvent.keyboard("{ArrowDown}");
expect(row(/^main/)).toHaveFocus();
await userEvent.keyboard("{ArrowDown}");
expect(row(/^feature\/very-long/)).toHaveFocus();
});
it("renames a branch and re-reads the list", async () => {
const { onChanged } = setup();
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
await userEvent.click(screen.getByRole("button", { name: /^Rename$/ }));
const input = screen.getByRole("textbox", { name: "New name" });
await userEvent.clear(input);
await userEvent.type(input, "feature/short{Enter}");
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-rename", {
branch: "feature/very-long-branch-name",
name: "feature/short",
});
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(api.lanes.gitRefs).toHaveBeenCalledTimes(2);
});
it("refuses rename and delete of a protected current branch, with the reason", async () => {
setup();
await userEvent.click(await screen.findByText("main"));
expect(screen.getByRole("button", { name: /^Rename$/ })).toBeDisabled();
expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled();
expect(screen.getByText(/You are on this branch/)).toBeInTheDocument();
});
it("explains a branch another worktree holds", async () => {
setup();
await userEvent.click(await screen.findByText("held"));
expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled();
expect(screen.getByText(/another worktree: \/tmp\/other/)).toBeInTheDocument();
});
it("deletes an unmerged branch only after the confirm warns", async () => {
setup();
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
await userEvent.click(screen.getByRole("button", { name: /^Delete$/ }));
expect(vi.mocked(confirmDialog).mock.calls[0]?.[0].message).toMatch(/not merged/);
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-delete", {
branch: "feature/very-long-branch-name",
});
});
it("creates a branch from a remote branch, switch offered only on a clean tree", async () => {
setup({ dirty: 2 });
await userEvent.click(await screen.findByText("origin/remote-only"));
await userEvent.click(screen.getByRole("button", { name: /New branch from here/ }));
expect(screen.getByRole("checkbox", { name: "Switch to it" })).toBeDisabled();
const input = screen.getByRole("textbox", { name: "New branch name" });
expect(input).toHaveValue("remote-only");
await userEvent.type(input, "{Enter}");
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-create", {
branch: "remote-only",
from: "origin/remote-only",
});
});
it("sets and unsets an upstream, and merges into the current branch", async () => {
setup();
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
await userEvent.click(screen.getByRole("button", { name: /Set upstream/ }));
await userEvent.selectOptions(screen.getByRole("combobox"), "origin/remote-only");
await userEvent.click(screen.getByRole("button", { name: /Apply/ }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", {
branch: "feature/very-long-branch-name",
upstream: "origin/remote-only",
});
await userEvent.click(screen.getByRole("button", { name: /Merge into main/ }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "merge", {
branch: "feature/very-long-branch-name",
});
await userEvent.click(screen.getByText("main"));
await userEvent.click(screen.getByRole("button", { name: /Unset upstream/ }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", { branch: "main" });
});
it("shows git's own words when a write is refused", async () => {
vi.mocked(api.lanes.gitRefAction).mockRejectedValueOnce(
Object.assign(new Error("branch x already exists"), { code: "EBRANCHEXISTS" })
);
setup();
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
await userEvent.click(screen.getByRole("button", { name: /^Rename$/ }));
await userEvent.type(screen.getByRole("textbox", { name: "New name" }), "x{Enter}");
expect(await screen.findByTestId("branch-manager-error")).toHaveTextContent(
"branch x already exists"
);
});
});
describe("TagManager (Tags tab)", () => {
const openTags = async () => {
setup();
await screen.findByText("main");
await userEvent.click(screen.getByRole("tab", { name: /Tags/ }));
};
it("lists tags with their message and creates an annotated one", async () => {
await openTags();
expect(screen.getByText("v1")).toBeInTheDocument();
expect(screen.getByText(/release one/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /New tag/ }));
await userEvent.type(screen.getByRole("textbox", { name: /Tag name/ }), "v2");
await userEvent.type(
screen.getByRole("textbox", { name: /^On / }),
"feature/very-long-branch-name"
);
await userEvent.type(screen.getByRole("textbox", { name: /Message/ }), "notes");
await userEvent.click(screen.getByRole("button", { name: "Create annotated tag" }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-create", {
tag: "v2",
message: "notes",
from: "feature/very-long-branch-name",
});
});
it("pushes one tag and deletes one only with confirm: true", async () => {
await openTags();
await userEvent.click(screen.getByText("v1"));
await userEvent.click(screen.getByRole("button", { name: /Push to origin/ }));
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-push", { tag: "v1" });
await userEvent.click(screen.getByRole("button", { name: /Delete tag/ }));
expect(vi.mocked(confirmDialog).mock.lastCall?.[0].destructive).toBe(true);
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-delete", {
tag: "v1",
confirm: true,
});
});
it("opens from the git bar's button and closes on Escape", async () => {
const { BranchesButton } = await import("../git/BranchesButton");
render(<BranchesButton laneId={3} dirty={0} merging={false} onChanged={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: "Manage branches & tags" }));
expect(await screen.findByTestId("branch-manager")).toBeInTheDocument();
await screen.findByText("main");
await userEvent.keyboard("{Escape}");
expect(screen.queryByTestId("branch-manager")).toBeNull();
});
it("does not delete when the confirm is cancelled", async () => {
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await openTags();
await userEvent.click(screen.getByText("v1"));
await userEvent.click(screen.getByRole("button", { name: /Delete tag/ }));
expect(api.lanes.gitRefAction).not.toHaveBeenCalled();
});
});