Files
AgentHub/client/src/components/lanes/git/__tests__/GitTools.test.tsx
T
nntrivi2001 2b5d7268ed feat(git): 3-way merge editor, reflog, rebase/interactive rebase, reset with undo, force-with-lease push, remote branches and remotes
Server: git-rewrite.js (rebase + continue/skip/abort, rebase-interactive with
no editor, reset soft/mixed/keep/hard, undo-rewrite, force-push with lease,
remote-branch-delete, remote add/rename/remove) and git-conflicts.js
(conflict stages read + conflict-save, reflog), dispatched from runGitAction;
operationInProgress now reports a rebase and only ALLOWED_WHILE_REBASING
writes run during one. New reads: /git/conflict, /git/reflog, /git/remotes,
/git/rewrite-plan. Client: History tools menu, MergeEditor, ReflogView,
RebaseDialog, InteractiveRebase, ResetDialog, ForcePushDialog, RemotesDialog,
rebase banner Skip. CLAUDE.md git-writes rule rewritten for the new actions.
2026-11-02 12:12:45 +07:00

321 lines
11 KiB
TypeScript

/**
* @file Tests for the Changes panel's history tools: the 3-way merge editor
* (three panes, per-block accept/both/edit, "N left", Save only when every
* block is chosen and it sends the composed result with the opened sha), the
* rebase banner's Skip, the interactive-rebase list (pushed warning + tick,
* reorder, squash/reword/drop in the todo, two-click run, Undo after) and the
* reset dialog (mode, hard needs a clean tree and the typed branch name).
* @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 { MergeEditor, composeResult } from "../MergeEditor";
import { InteractiveRebase, todoProblem } from "../InteractiveRebase";
import { ResetDialog } from "../ResetDialog";
import { RebaseSkipButton } from "../GitTools";
import { gitRewrite } from "../../../../lib/gitRewrite";
import { parseConflicts } from "../../../../lib/conflicts";
vi.mock("../../../../lib/gitRewrite", async (orig) => {
const real = await orig<typeof import("../../../../lib/gitRewrite")>();
return {
...real,
gitRewrite: {
conflict: vi.fn(),
planInteractive: vi.fn(),
planReset: vi.fn(),
run: vi.fn(),
},
};
});
vi.mock("../../../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
const WORKING = [
"top",
"<<<<<<< HEAD",
"mine",
"=======",
"theirs",
">>>>>>> feat",
"middle",
"<<<<<<< HEAD",
"a",
"=======",
"b",
">>>>>>> feat",
"",
].join("\n");
const HEAD = "a".repeat(40);
const commit = (n: number, pushed = false) => ({
sha: String(n).repeat(40),
short: String(n).repeat(7),
subject: `c${n}`,
message: `c${n}`,
pushed,
});
beforeEach(() => {
vi.mocked(gitRewrite.conflict)
.mockReset()
.mockResolvedValue({
available: true,
path: "a.txt",
operation: "merge",
branch: "main",
incoming: { sha: "f".repeat(40), short: "fffffff", subject: "feat work" },
base: "top\nbase\n",
ours: "top\nmine\n",
theirs: "top\ntheirs\n",
working: WORKING,
sha: "s1",
usable: true,
binary: false,
tooBig: false,
});
vi.mocked(gitRewrite.run).mockReset().mockResolvedValue({ action: "x", ok: true, output: "" });
});
describe("MergeEditor", () => {
it("shows three panes and saves the composed result once every block is chosen", async () => {
const user = userEvent.setup();
const onResolved = vi.fn();
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={onResolved} />);
const incoming = await screen.findByTestId("merge-incoming");
expect(incoming).toHaveTextContent("Incoming: fffffff feat work");
expect(incoming).toHaveTextContent("theirs");
expect(screen.getByTestId("merge-current")).toHaveTextContent("Current: main");
expect(screen.getByTestId("merge-left")).toHaveTextContent("2 of 2 conflicts left");
const save = screen.getByTestId("merge-save");
expect(save).toBeDisabled();
const [first, second] = screen.getAllByTestId("merge-block") as [HTMLElement, HTMLElement];
await user.click(within(first).getByTestId("merge-accept-incoming"));
expect(screen.getByTestId("merge-left")).toHaveTextContent("1 of 2 conflicts left");
await user.click(within(second).getByTestId("merge-both-ic"));
expect(screen.getByTestId("merge-left")).toHaveTextContent("Every conflict has a choice");
await user.click(save);
await waitFor(() =>
expect(gitRewrite.run).toHaveBeenCalledWith(3, "conflict-save", {
path: "a.txt",
content: "top\ntheirs\nmiddle\nb\na\n",
baseSha: "s1",
})
);
expect(onResolved).toHaveBeenCalled();
});
it("edits one block by hand, and the base pane is one click away", async () => {
const user = userEvent.setup();
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
const [first] = (await screen.findAllByTestId("merge-block")) as [HTMLElement];
await user.click(within(first).getByTestId("merge-edit-block"));
const box = within(first).getByTestId("merge-block-text");
await user.clear(box);
await user.type(box, "hand");
await user.click(within(first).getByRole("button", { name: "Done" }));
expect(first).toHaveTextContent("hand");
expect(screen.queryByTestId("merge-base")).toBeNull();
await user.click(screen.getByLabelText("Show base"));
expect(screen.getByTestId("merge-base")).toHaveTextContent("base");
});
it("labels the sides for a rebase and explains a refused save", async () => {
vi.mocked(gitRewrite.conflict).mockResolvedValueOnce({
...(await vi.mocked(gitRewrite.conflict).getMockImplementation()!(3, "a.txt")),
operation: "rebase",
});
vi.mocked(gitRewrite.run).mockRejectedValueOnce(
Object.assign(new Error("x"), { code: "EMARKERS" })
);
const user = userEvent.setup();
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
expect(await screen.findByTestId("merge-current")).toHaveTextContent(
"the branch you are rebasing onto"
);
await user.click(screen.getByTestId("merge-whole-toggle"));
await user.click(screen.getByTestId("merge-save"));
expect(await screen.findByRole("alert")).toHaveTextContent("still has conflict markers");
});
it("composeResult keeps markers on an unresolved block", () => {
const parts = parseConflicts(WORKING.split("\n"))!;
expect(composeResult(parts, [{ kind: "ours" }])).toBe(
"top\nmine\nmiddle\n<<<<<<< HEAD\na\n=======\nb\n>>>>>>> feat\n"
);
});
});
describe("RebaseSkipButton", () => {
it("skips the stopped commit and re-reads", async () => {
const user = userEvent.setup();
const onChanged = vi.fn();
render(<RebaseSkipButton target={3} onChanged={onChanged} />);
await user.click(screen.getByTestId("rebase-skip"));
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-skip", {});
});
});
describe("InteractiveRebase", () => {
beforeEach(() => {
vi.mocked(gitRewrite.planInteractive)
.mockReset()
.mockResolvedValue({
available: true,
kind: "interactive",
head: HEAD,
branch: "feat",
subdir: false,
operation: null,
dirty: false,
base: "b".repeat(40),
merges: false,
commits: [commit(1, true), commit(2), commit(3)],
pushed: 1,
});
});
it("warns about pushed commits, needs the tick, and sends the edited todo", async () => {
const user = userEvent.setup();
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
action: "rebase-interactive",
ok: true,
output: "",
before: HEAD,
after: "c".repeat(40),
conflicts: [],
stopped: false,
needsForcePush: true,
});
render(<InteractiveRebase target={3} onClose={vi.fn()} onChanged={vi.fn()} />);
expect(await screen.findByTestId("interactive-summary")).toHaveTextContent(
"This rewrites 3 commits; 1 are already pushed, so a force-push will be needed."
);
const run = screen.getByTestId("interactive-run");
expect(run).toBeDisabled(); // unchanged + pushed not acknowledged
const rows = screen.getAllByTestId("interactive-row") as HTMLElement[];
await user.selectOptions(within(rows[1]!).getByTestId("interactive-action"), "squash");
await user.selectOptions(within(rows[2]!).getByTestId("interactive-action"), "reword");
const msg = within(rows[2]!).getByTestId("interactive-message");
await user.clear(msg);
await user.type(msg, "new words");
await user.click(within(rows[2]!).getByRole("button", { name: "Move up" }));
expect(run).toBeDisabled();
await user.click(screen.getByTestId("interactive-force"));
expect(run).toBeEnabled();
await user.click(run);
expect(gitRewrite.run).not.toHaveBeenCalled(); // armed only
await user.click(screen.getByRole("button", { name: "Click again to rewrite" }));
await waitFor(() =>
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-interactive", {
head: HEAD,
confirm: true,
force: true,
todo: [
{ sha: commit(1).sha, action: "pick" },
{ sha: commit(3).sha, action: "reword", message: "new words" },
{ sha: commit(2).sha, action: "squash" },
],
})
);
expect(await screen.findByTestId("git-rewrite-done")).toHaveTextContent(
"Force push (with lease)"
);
expect(screen.getByTestId("git-rewrite-undo")).toBeInTheDocument();
});
it("todoProblem refuses a squash with nothing above and an empty reword", () => {
const c = commit(1);
expect(todoProblem([{ commit: c, action: "squash", message: "" }])).toBe("first");
expect(todoProblem([{ commit: c, action: "drop", message: "" }])).toBe("allDropped");
expect(todoProblem([{ commit: c, action: "reword", message: " " }])).toBe("reword");
expect(todoProblem([{ commit: c, action: "pick", message: "" }])).toBeNull();
});
});
describe("ResetDialog", () => {
const plan = (over = {}) => ({
available: true,
kind: "reset" as const,
head: HEAD,
branch: "feat",
subdir: false,
operation: null,
dirty: false,
target: "d".repeat(40),
related: true,
dropped: 2,
added: 0,
pushed: 0,
...over,
});
it("hard needs the branch name typed back", async () => {
vi.mocked(gitRewrite.planReset).mockReset().mockResolvedValue(plan());
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
action: "reset",
ok: true,
output: "",
before: HEAD,
after: "d".repeat(40),
});
const user = userEvent.setup();
render(
<ResetDialog
target={3}
commit={{ sha: "d".repeat(40), short: "ddddddd", subject: "old" }}
onClose={vi.fn()}
onChanged={vi.fn()}
/>
);
expect(await screen.findByTestId("reset-summary")).toHaveTextContent("2 commit(s) leave feat");
await user.click(screen.getByTestId("reset-mode-hard"));
const run = screen.getByTestId("reset-run");
expect(run).toBeDisabled();
await user.type(screen.getByTestId("reset-confirm-branch"), "feat");
expect(run).toBeEnabled();
await user.click(run);
await waitFor(() =>
expect(gitRewrite.run).toHaveBeenCalledWith(3, "reset", {
target: "d".repeat(40),
mode: "hard",
head: HEAD,
confirm: true,
confirmBranch: "feat",
})
);
expect(await screen.findByTestId("git-rewrite-undo")).toBeInTheDocument();
});
it("refuses hard on a dirty tree and asks for the tick on pushed commits", async () => {
vi.mocked(gitRewrite.planReset)
.mockReset()
.mockResolvedValue(plan({ dirty: true, pushed: 1 }));
const user = userEvent.setup();
render(
<ResetDialog
target={3}
commit={{ sha: "d".repeat(40) }}
onClose={vi.fn()}
onChanged={vi.fn()}
/>
);
await screen.findByTestId("reset-pushed");
const run = screen.getByTestId("reset-run");
expect(run).toBeDisabled();
await user.click(screen.getByTestId("reset-force"));
expect(run).toBeEnabled(); // mixed works on a dirty tree
await user.click(screen.getByTestId("reset-mode-hard"));
expect(screen.getByTestId("reset-hard-dirty")).toBeInTheDocument();
expect(run).toBeDisabled();
});
});