/** * @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ĩ */ 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(); 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(); 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(); 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(); 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(); 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(); 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( ); 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( ); 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(); }); });