Files
AgentHub/client/src/components/lanes/__tests__/LaneShelves.test.tsx
T
nntrivi2001 f6eb39eb73 feat(git): switch with uncommitted changes (carry / shelve), Create & switch, and shelves with Restore/Delete
- switch takes changes: carry (plain git switch, 409 ECARRYCONFLICT with files) or shelve (shelf first, re-applied if the switch fails)
- branch-create switch: true runs git switch -c, the changes come along
- shelve / unshelve / shelf-drop + GET /git/shelves: only agenthub-shelf: stash entries, addressed by sha, repo root only, never mid-operation; a clean restore drops the shelf, a conflicted one keeps it
- 409 EAGENTRUNNING until confirm: true while the workspace's agent is working
- git bar: Bring my changes / Shelve and switch choice, Create & switch, restore offer after a switch; Changes panel: Shelve (n) and the Shelves list
- fix: after a switch the Changes panel showed the old branch — the post-write re-read joined an in-flight GET started before the write (request() dedupe); writes now close in-flight GETs, and every git write fires agenthub:git-changed so every view of the workspace re-reads
2026-11-02 11:44:35 +07:00

107 lines
3.9 KiB
TypeScript

/**
* @file LaneShelves.test.tsx
* @description Pins the Changes panel's Shelves list: hidden while there are
* none, one collapsed line otherwise, each shelf's branch, note and files,
* Restore sending `unshelve` by sha (and the conflict outcome told in a
* toast), Delete needing a second click before `shelf-drop` with
* `confirm: true`.
* @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 { LaneShelves } from "../LaneShelves";
import { api } from "../../../lib/api";
import { toast } from "../../../lib/toast";
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", () => ({
api: { lanes: { gitAction: vi.fn(), gitShelves: vi.fn() } },
}));
const shelf = {
ref: "stash@{0}",
sha: "abc1234def5678",
branch: "fix/ac-515-a-rather-long-branch-name",
note: "half way through the login fix",
createdAt: "2026-11-02T03:04:05.000Z",
files: [
{ path: "src/login.ts", status: "M" },
{ path: "src/new.ts", status: "??" },
],
};
beforeEach(() => {
vi.mocked(api.lanes.gitShelves)
.mockReset()
.mockResolvedValue({
available: true,
shelves: [shelf],
});
vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({
action: "unshelve",
ok: true,
output: "",
conflicts: [],
});
});
describe("LaneShelves", () => {
it("renders nothing while there are no shelves", async () => {
vi.mocked(api.lanes.gitShelves).mockResolvedValue({ available: true, shelves: [] });
const { container } = render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
await waitFor(() => expect(api.lanes.gitShelves).toHaveBeenCalledWith(3));
expect(container).toBeEmptyDOMElement();
});
it("lists a shelf with its branch, note and files", async () => {
const user = userEvent.setup();
render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
await user.click(await screen.findByRole("button", { name: /Shelves \(1\)/ }));
const row = screen.getByTestId("lane-shelf");
expect(row).toHaveTextContent(shelf.branch);
expect(row).toHaveTextContent(shelf.note);
await user.click(screen.getByRole("button", { name: "2 file(s)" }));
expect(screen.getByTestId("lane-shelf-files")).toHaveTextContent("src/new.ts");
});
it("restores by sha and reports a conflicted restore", async () => {
vi.mocked(api.lanes.gitAction).mockResolvedValueOnce({
action: "unshelve",
ok: true,
output: "",
conflicts: ["src/login.ts"],
kept: true,
});
const user = userEvent.setup();
const onChanged = vi.fn();
render(<LaneShelves target={3} reread={0} onChanged={onChanged} />);
await user.click(await screen.findByRole("button", { name: /Shelves/ }));
await user.click(screen.getByRole("button", { name: "Restore" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "unshelve", { sha: shelf.sha })
);
expect(onChanged).toHaveBeenCalled();
expect(toast.info).toHaveBeenCalledWith(expect.stringContaining("1 conflicted file"));
});
it("deletes only on the second click, with confirm", async () => {
const user = userEvent.setup();
render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
await user.click(await screen.findByRole("button", { name: /Shelves/ }));
await user.click(screen.getByRole("button", { name: "Delete" }));
expect(api.lanes.gitAction).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Delete for good?" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "shelf-drop", {
sha: shelf.sha,
confirm: true,
})
);
});
});