1// @vitest-environment jsdom
2import { EnvironmentId, ThreadId, type JjStack } from "@t3tools/contracts";
3import * as AsyncResult from "effect/reactivity/AsyncResult";
4import * as Cause from "effect/Cause";
5import { act, type ReactNode } from "react";
6import { createRoot, type Root } from "react-dom/client";
7import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
8
9import { JjReviewPanel } from "./JjReviewPanel";
10
11const state = vi.hoisted(() => ({
12 result: { _tag: "Initial" } as unknown,
13 diff: vi.fn(() => ({})),
14 refresh: vi.fn(),
15 queue: null as { pullRequests: Array<{ headSha: string; number: number }> } | null,
16}));
17
18vi.mock("@effect/atom-react", () => ({
19 useAtomValue: () => state.result,
20 useAtomRefresh: () => state.refresh,
21}));
22vi.mock("~/state/jj", () => ({ jjEnvironment: { diff: state.diff } }));
23vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
24vi.mock("~/state/entities", () => ({ useThreadShell: () => null }));
25vi.mock("~/state/mergebot", () => ({
26 mergebotEnvironment: {},
27 useMergebotQueue: () => state.queue,
28 useMergebotPullRequestStatus: () => null,
29}));
30vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
31vi.mock("~/hooks/useSettings", () => ({
32 useClientSettings: () => ({ diffLayout: "split", wordWrap: false }),
33 useUpdateClientSettings: () => vi.fn(),
34}));
35vi.mock("~/hooks/useLocalStorage", () => ({ useLocalStorage: () => [false, vi.fn()] }));
36vi.mock("~/hooks/usePullRequestLinking", () => ({ usePullRequestLinking: () => ({}) }));
37vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() }));
38vi.mock("../DiffPanelShell", () => ({
39 DiffPanelShell: ({ header, children }: { header: ReactNode; children: ReactNode }) => (
40 <>
41 {header}
42 {children}
43 </>
44 ),
45 DiffPanelLoadingState: () => <div role="status">Loading changes</div>,
46}));
47vi.mock("../diffs/AnnotatableCodeView", () => ({
48 AnnotatableCodeView: () => <div data-code-view />,
49}));
50vi.mock("./CommitMessageEditor", () => ({ CommitMessageEditor: () => null }));
51vi.mock("./JjStackViews", () => ({ StackRail: () => null, StackStrip: () => null }));
52
53let container: HTMLDivElement;
54let root: Root;
55const environmentId = EnvironmentId.make("review-test");
56const stack: JjStack = {
57 workspaceRoot: "/workspace",
58 repository: null,
59 trunk: null,
60 changes: [],
61};
62
63beforeEach(() => {
64 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
65 state.queue = null;
66 state.diff.mockClear();
67 state.refresh.mockClear();
68 container = document.createElement("div");
69 document.body.append(container);
70 root = createRoot(container);
71});
72
73afterEach(() => {
74 act(() => root.unmount());
75 container.remove();
76 vi.unstubAllGlobals();
77});
78
79function render(value: JjStack) {
80 act(() =>
81 root.render(
82 <JjReviewPanel
83 mode="embedded"
84 environmentId={environmentId}
85 threadRef={null}
86 cwd="/workspace"
87 stack={value}
88 composerDraftTarget={{ environmentId, threadId: ThreadId.make("thread-test") }}
89 />,
90 ),
91 );
92}
93
94it("shows an empty stack without requesting a diff for invalid commit IDs", () => {
95 state.result = AsyncResult.success(null);
96 render(stack);
97 expect(state.diff).not.toHaveBeenCalled();
98 expect(container.textContent).toContain("No changes off trunk yet.");
99});
100
101const change = {
102 changeId: "change",
103 shortId: "chan",
104 commitId: "commit",
105 parentCommitIds: ["parent"],
106 description: "feat: review",
107 trailers: [],
108 bookmarks: [],
109 immutable: false,
110 conflict: false,
111 empty: false,
112 workingCopy: false,
113 additions: 1,
114 deletions: 0,
115 authoredAt: "2026-10-15T12:00:00Z",
116};
117
118it("shows a failed diff and lets the user retry the selected range", () => {
119 state.result = AsyncResult.failure(Cause.fail(new Error("Connection lost")));
120 render({
121 ...stack,
122 changes: [change],
123 });
124 expect(container.querySelector('[role="alert"]')?.textContent).toContain(
125 "Couldn’t load changes.",
126 );
127 expect(container.querySelector("[data-code-view]")).toBeNull();
128 const retry = [...container.querySelectorAll<HTMLButtonElement>("button")].find(
129 (button) => button.textContent === "Try again",
130 );
131 expect(retry).toBeDefined();
132 act(() => retry!.click());
133 expect(state.refresh).toHaveBeenCalledOnce();
134});
135
136it("does not offer to create a duplicate PR while an existing PR's live status loads", () => {
137 state.result = AsyncResult.success(null);
138 state.queue = { pullRequests: [{ headSha: change.commitId, number: 42 }] };
139 render({ ...stack, repository: "acme/project", changes: [change] });
140 expect(container.querySelector('[aria-label="More ways to open a PR"]')).toBeNull();
141 expect(container.textContent).not.toContain("Auto-merge");
142});