1import { RegistryContext } from "@effect/atom-react";
2import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
3import { PullRequestOperationError, type PullRequestDiffResult } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import { Atom, AtomRegistry } from "effect/reactivity";
6import { act, useLayoutEffect, useState } from "react";
7import { createRoot } from "react-dom/client";
8import { expect, it, vi } from "vite-plus/test";
9
10const mocks = vi.hoisted(() => ({ diff: vi.fn() }));
11vi.mock("../../state/pull-requests", () => ({ composerPullRequests: { diff: mocks.diff } }));
12
13import { usePullRequestDiff, usePullRequestDiffEntry } from "./usePullRequestDiff";
14import * as reviewModel from "../review/reviewModel";
15import { getCachedNativeReviewDiffData } from "../review/nativeReviewDiffAdapter";
16
17function patch(path: string, nextCursor: string | null = null): PullRequestDiffResult {
18 return {
19 patch: `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-old\n+new\n`,
20 truncated: false,
21 nextCursor,
22 };
23}
24
25function setup() {
26 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
27 const registry = AtomRegistry.make();
28 const requests: {
29 cursor: string | null;
30 resolve: (value: PullRequestDiffResult) => void;
31 reject: (error: Error) => void;
32 }[] = [];
33 const queries = Atom.family((cursor: string | null) =>
34 Atom.make(
35 Effect.tryPromise({
36 try: () =>
37 new Promise<PullRequestDiffResult>((resolve, reject) => {
38 requests.push({ cursor, resolve, reject });
39 }),
40 catch: (cause) =>
41 new PullRequestOperationError({ operation: "diff", detail: "Connection lost", cause }),
42 }),
43 ).pipe(Atom.keepAlive),
44 );
45 mocks.diff.mockImplementation((target: { input: { cursor?: string } }) =>
46 queries(target.input.cursor ?? null),
47 );
48 const root = createRoot(document.createElement("div"));
49 let current: ReturnType<typeof usePullRequestDiff>;
50 function Probe() {
51 const result = usePullRequestDiff(
52 {
53 environmentId: "environment",
54 projectId: "project",
55 repository: "repo",
56 number: 1,
57 },
58 sourceControlClients.get("github").changeRequest,
59 );
60 useLayoutEffect(() => {
61 current = result;
62 });
63 return null;
64 }
65 act(() =>
66 root.render(
67 <RegistryContext.Provider value={registry}>
68 <Probe />
69 </RegistryContext.Provider>,
70 ),
71 );
72 return {
73 requests,
74 remount() {
75 act(() =>
76 root.render(
77 <RegistryContext.Provider value={registry}>
78 <Probe key="new-route" />
79 </RegistryContext.Provider>,
80 ),
81 );
82 },
83 get current() {
84 return current;
85 },
86 async succeed(index: number, result: PullRequestDiffResult) {
87 await act(async () => {
88 requests[index]!.resolve(result);
89 });
90 },
91 async fail(index: number) {
92 await act(async () => {
93 requests[index]!.reject(new Error("Connection lost"));
94 });
95 },
96 [Symbol.dispose]() {
97 act(() => root.unmount());
98 registry.dispose();
99 },
100 };
101}
102
103it("reuses the parsed snapshot when a new route reads the same query result", async () => {
104 using parse = vi.spyOn(reviewModel, "buildReviewParsedDiff");
105 using screen = setup();
106 await screen.succeed(0, patch("first.ts"));
107 const parsed = screen.current.parsed;
108 const parses = parse.mock.calls.length;
109 screen.remount();
110 expect(screen.current.parsed).toBe(parsed);
111 expect(parse).toHaveBeenCalledTimes(parses);
112 expect(screen.requests).toHaveLength(1);
113});
114
115function setupEntry() {
116 vi.useFakeTimers();
117 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
118 const frames = new Map<number, FrameRequestCallback>();
119 let frameId = 0;
120 const requestFrame = vi
121 .spyOn(globalThis, "requestAnimationFrame")
122 .mockImplementation((callback) => {
123 frames.set(++frameId, callback);
124 return frameId;
125 });
126 const cancelFrame = vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation((id) => {
127 if (id != null) frames.delete(id);
128 });
129 const listeners = new Map<string, (event: { data: { closing: boolean } }) => void>();
130 const navigation = {
131 addListener(
132 type: "transitionStart" | "transitionEnd",
133 listener: (event: { data: { closing: boolean } }) => void,
134 ) {
135 listeners.set(type, listener);
136 return () => {
137 listeners.delete(type);
138 };
139 },
140 };
141 const events: string[] = [];
142 const prepare = vi.fn(() => {
143 events.push("prepare");
144 return getCachedNativeReviewDiffData({
145 parsedDiff: reviewModel.buildReviewParsedDiff(
146 patch("first.ts").patch + patch("neighbor.ts").patch,
147 "entry",
148 ),
149 });
150 });
151 function Body() {
152 const [data] = useState(prepare);
153 return <div>{data.files.map((file) => file.path).join(",")}</div>;
154 }
155 function Entry() {
156 const ready = usePullRequestDiffEntry(navigation);
157 useLayoutEffect(() => {
158 events.push(ready ? "body-commit" : "shell-commit");
159 }, [ready]);
160 return ready ? <Body /> : <div>Opening changes</div>;
161 }
162 const container = document.createElement("div");
163 const root = createRoot(container);
164 act(() => root.render(<Entry />));
165 return {
166 prepare,
167 events,
168 container,
169 frame() {
170 const pending = [...frames.values()];
171 frames.clear();
172 act(() => {
173 for (const callback of pending) callback(0);
174 });
175 },
176 event(type: "transitionStart" | "transitionEnd", closing = false) {
177 act(() => listeners.get(type)?.({ data: { closing } }));
178 },
179 fallback() {
180 act(() => vi.runAllTimers());
181 },
182 [Symbol.dispose]() {
183 act(() => root.unmount());
184 requestFrame.mockRestore();
185 cancelFrame.mockRestore();
186 vi.useRealTimers();
187 },
188 };
189}
190
191it("commits the loading shell and waits for the opening transition before building the diff", () => {
192 using screen = setupEntry();
193 expect(screen.events).toEqual(["shell-commit"]);
194 screen.event("transitionStart");
195 screen.frame();
196 screen.frame();
197 screen.event("transitionEnd", true);
198 expect(screen.prepare).not.toHaveBeenCalled();
199 screen.event("transitionEnd");
200 expect(screen.events).toEqual(["shell-commit", "prepare", "body-commit"]);
201 expect(screen.container.textContent).toBe("first.ts,neighbor.ts");
202});
203
204it.each(["unanimated", "missing-completion"] as const)(
205 "opens the diff after a %s presentation without stranding the shell",
206 (presentation) => {
207 using screen = setupEntry();
208 if (presentation === "missing-completion") screen.event("transitionStart");
209 screen.frame();
210 expect(screen.prepare).not.toHaveBeenCalled();
211 screen.frame();
212 if (presentation === "missing-completion") {
213 expect(screen.prepare).not.toHaveBeenCalled();
214 screen.fallback();
215 }
216 expect(screen.prepare).toHaveBeenCalledOnce();
217 },
218);
219
220it("retries a failed continuation without discarding loaded files or restarting page one", async () => {
221 using screen = setup();
222 await screen.succeed(0, patch("first.ts", "second"));
223 act(() => screen.current.loadMore());
224 await screen.fail(1);
225 expect(screen.current.error).not.toBeNull();
226 expect(screen.current.patch).toContain("first.ts");
227
228 act(() => screen.current.refresh());
229 expect(screen.requests.map((request) => request.cursor)).toEqual([null, "second", "second"]);
230 expect(screen.current.patch).toContain("first.ts");
231 await screen.succeed(2, patch("second.ts"));
232 expect(screen.current.error).toBeNull();
233 expect(screen.current.parsed.kind).toBe("files");
234 if (screen.current.parsed.kind !== "files") throw new Error("Expected files");
235 expect(screen.current.parsed.files.map((file) => file.path)).toEqual(["first.ts", "second.ts"]);
236 expect(screen.current.nextCursor).toBeNull();
237});
238
239it("still refreshes from page one after successfully loading a continuation", async () => {
240 using screen = setup();
241 await screen.succeed(0, patch("first.ts", "second"));
242 act(() => screen.current.loadMore());
243 await screen.succeed(1, patch("second.ts"));
244 act(() => screen.current.refresh());
245 await screen.succeed(2, patch("refreshed.ts"));
246 if (screen.current.parsed.kind !== "files") throw new Error("Expected files");
247 expect(screen.current.parsed.files.map((file) => file.path)).toEqual(["refreshed.ts"]);
248});