Files
fusion/packages/dashboard/app/__tests__/pull-request-view.test.tsx
gsxdsm 8a6a67a305 feat(pr): dashboard PR view, routes, node-state UI + user controls (U7)
Adds /api/pull-requests routes (list, detail, merge/approve/retry/close/
automerge — all re-fetch authoritative state before acting), a
PullRequestView rendering every entity state distinctly (creating/failed/
unverified/responding/await-review/conflict) with the action bar, live
auto-merge gate reason, and conflict CTA; TaskCard PR node-state badge +
link; and the R16 column-move-backward guard. User actions route through
the existing releaseHeldTaskByEvent primitives. Maps the new PR node kinds
in the workflow editor's kind resolver. 13 route tests + lazy-view guard
green; component test runs in CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 22:18:34 -07:00

161 lines
6.3 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { PullRequestView, type PrDetail } from "../components/PullRequestView";
// Icons → simple stubs so assertions key on text/testids, not SVG internals.
vi.mock("lucide-react", () => {
const Stub = () => <span />;
return new Proxy({}, { get: () => Stub });
});
function makeSummary(over: Partial<PrDetail["summary"]> = {}): PrDetail["summary"] {
return {
mergeable: "clean",
reviewDecision: "APPROVED",
checksRollup: "success",
conflicting: false,
autoMerge: false,
autoMergeReason: "Ready to merge",
autoMergeReady: true,
actionable: true,
active: true,
pendingThreads: 0,
disagreedThreads: 0,
...over,
};
}
function makeDetail(over: Partial<PrDetail> = {}): PrDetail {
return {
id: "PR-1",
sourceType: "task",
sourceId: "FN-1",
repo: "owner/repo",
headBranch: "feature/x",
state: "open",
prNumber: 42,
prUrl: "https://example/pr/42",
mergeable: "clean",
checksRollup: "success",
reviewDecision: "APPROVED",
autoMerge: false,
unverified: false,
responseRounds: 0,
threads: [],
summary: makeSummary(over.summary),
...over,
};
}
describe("PullRequestView per-node-state rendering", () => {
it("creating → 'Creating PR…' placeholder", () => {
render(<PullRequestView detail={makeDetail({ state: "creating" })} />);
expect(screen.getByTestId("pr-view").dataset.state).toBe("creating");
expect(screen.getByTestId("pr-creating").textContent).toContain("Creating PR");
});
it("failed → failure reason + Retry PR creation action", async () => {
const onAction = vi.fn(async () => makeDetail({ state: "creating" }));
render(
<PullRequestView
detail={makeDetail({ state: "failed", failureReason: "gh auth missing" })}
onAction={onAction}
/>,
);
expect(screen.getByTestId("pr-failed").textContent).toContain("gh auth missing");
fireEvent.click(screen.getByTestId("pr-retry-create"));
await waitFor(() => expect(onAction).toHaveBeenCalledWith("retry-create", "PR-1", undefined));
});
it("unverified → 'Verifying with GitHub…', checks/threads hidden, merge disabled", () => {
render(
<PullRequestView
detail={makeDetail({ unverified: true, threads: [
{ prEntityId: "PR-1", threadId: "T1", headOid: "a", outcome: "pending", updatedAt: 0 },
] })}
/>,
);
expect(screen.getByTestId("pr-unverified").textContent).toContain("Verifying with GitHub");
expect(screen.queryByTestId("pr-threads")).toBeNull();
expect(screen.queryByTestId("pr-summary")).toBeNull();
expect((screen.getByTestId("pr-merge") as HTMLButtonElement).disabled).toBe(true);
});
it("responding → banner with N pending threads, respond/retry disabled, per-thread pending markers", () => {
render(
<PullRequestView
detail={makeDetail({
state: "responding",
summary: makeSummary({ pendingThreads: 3 }),
threads: [
{ prEntityId: "PR-1", threadId: "T1", headOid: "a", outcome: "pending", updatedAt: 0 },
],
})}
/>,
);
expect(screen.getByTestId("pr-responding").textContent).toContain("3 threads pending");
expect((screen.getByTestId("pr-retry") as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByTestId("pr-thread-pending")).toBeTruthy();
});
it("open/await-review → action bar (Approve/Retry/Merge/Close) + auto-merge gate reason", () => {
render(<PullRequestView detail={makeDetail({ summary: makeSummary({ autoMergeReason: "Waiting for approval" }) })} />);
expect(screen.getByTestId("pr-action-bar")).toBeTruthy();
expect(screen.getByTestId("pr-approve")).toBeTruthy();
expect(screen.getByTestId("pr-retry")).toBeTruthy();
expect(screen.getByTestId("pr-merge")).toBeTruthy();
expect(screen.getByTestId("pr-close")).toBeTruthy();
expect(screen.getByTestId("pr-automerge-gate").textContent).toBe("Waiting for approval");
});
it("conflict → Merge disabled + 'Resolve conflicts on GitHub' link", () => {
render(
<PullRequestView
detail={makeDetail({
mergeable: "conflicting",
summary: makeSummary({ conflicting: true, autoMergeReason: "Blocked: conflict" }),
})}
/>,
);
expect((screen.getByTestId("pr-merge") as HTMLButtonElement).disabled).toBe(true);
const link = screen.getByTestId("pr-conflict-link") as HTMLAnchorElement;
expect(link.textContent).toContain("Resolve conflicts on GitHub");
expect(link.href).toContain("/pr/42");
});
it("agent disagreements are visually distinguished from human-awaiting threads", () => {
render(
<PullRequestView
detail={makeDetail({
threads: [
{ prEntityId: "PR-1", threadId: "T1", headOid: "a", outcome: "disagreed", updatedAt: 0 },
{ prEntityId: "PR-1", threadId: "T2", headOid: "a", outcome: "pending", updatedAt: 0 },
],
})}
/>,
);
const disagreed = screen.getByTestId("pr-thread-disagreed");
expect(disagreed.dataset.agentDisagreement).toBe("true");
expect(disagreed.className).toContain("pr-thread--agent-disagreement");
const pending = screen.getByTestId("pr-thread-pending");
expect(pending.dataset.agentDisagreement).toBe("false");
});
it("merge uses a single confirm step, then fires the merge action", async () => {
const onAction = vi.fn(async () => makeDetail());
render(<PullRequestView detail={makeDetail()} onAction={onAction} />);
fireEvent.click(screen.getByTestId("pr-merge"));
// Single-confirm: a confirm control appears (no heavy modal).
const confirm = await screen.findByTestId("pr-merge-confirm");
fireEvent.click(confirm);
await waitFor(() => expect(onAction).toHaveBeenCalledWith("merge", "PR-1", undefined));
});
it("auto-merge toggle dispatches the automerge action with enabled flag", async () => {
const onAction = vi.fn(async () => makeDetail({ autoMerge: true }));
render(<PullRequestView detail={makeDetail({ autoMerge: false })} onAction={onAction} />);
fireEvent.click(screen.getByTestId("pr-automerge").querySelector("input")!);
await waitFor(() => expect(onAction).toHaveBeenCalledWith("automerge", "PR-1", { enabled: true }));
});
});