1b561c22d9
- Changes card beside lane detail with five modes: Files tree, overview, Review (whole diff, per-file collapse), single file, commit history. - LaneDiffView: unified / side-by-side with independent scroll on both axes and mirrored scrollLeft, expand/collapse context, intra-line emphasis. - Staged-only commit box; git actions (fetch/pull/push/stage/unstage/ discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js. - Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind. - Add lane: new/existing branch, current branch, worktree list, prefill from the git bar's "New lane here"; /api/run/cwds gains a repo kind. - GlobalTooltip upgrades every native title into a themed anchored bubble; chart/info popovers use theme tokens.
176 lines
6.4 KiB
TypeScript
176 lines
6.4 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 commit sends the typed
|
|
* message, 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 commits.
|
|
expect(screen.getByRole("button", { name: /^Commit$/ })).toBeEnabled();
|
|
});
|
|
|
|
it("commits with the typed message and reports the change upward", async () => {
|
|
const user = userEvent.setup();
|
|
const onChanged = vi.fn();
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
|
|
|
|
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
|
|
await user.type(screen.getByLabelText("Commit message"), "fix: the thing");
|
|
await user.click(screen.getByRole("button", { name: /Commit 3 files/ }));
|
|
|
|
await waitFor(() =>
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", { message: "fix: the thing" })
|
|
);
|
|
expect(onChanged).toHaveBeenCalled();
|
|
});
|
|
|
|
it("offers no commit when nothing is uncommitted", () => {
|
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
|
expect(screen.getByRole("button", { name: /^Commit$/ })).toBeDisabled();
|
|
});
|
|
|
|
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("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("commits only the index when asked", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
|
|
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
|
|
await user.type(screen.getByLabelText(/Commit message/), "fix: staged only");
|
|
await user.click(screen.getByRole("button", { name: "Commit staged" }));
|
|
|
|
await waitFor(() =>
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", {
|
|
message: "fix: staged only",
|
|
only: "staged",
|
|
})
|
|
);
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|