Skip to content
Source
Sign inSign up

pullRequestDiffHighlightFiles.test.ts

445 lines16.7 KB

1import { highlightCode } from "../../lib/highlights";
2​
3import { EnvironmentId, ProjectId } from "@t3tools/contracts";
4import { expect, it, vi } from "vite-plus/test";
5import { AsyncResult } from "effect/reactivity";
6​
7import { highlightNativeReviewDiffVisibleRows } from "../diffs/nativeReviewDiffHighlighter";
8import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes";
9import type { ReviewParsedDiff, ReviewRenderableFile } from "../review/reviewModel";
10import { combinePullRequestDiffPages } from "./pullRequestDiffPages";
11import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
12import {
13 createPullRequestDiffHighlightCache,
14 createPullRequestDiffHighlightFiles,
15} from "./pullRequestDiffHighlightFiles";
16​
17vi.mock("react-native-nitro-modules", async () => {
18 const { createWasmHighlightsModule } = await import("../../lib/highlights.testing");
19 const highlights = createWasmHighlightsModule();
20 return { NitroModules: { hasHybridObject: () => true, createHybridObject: () => highlights } };
21});
22​
23const target = {
24 environmentId: EnvironmentId.make("remote-environment"),
25 input: {
26 projectId: ProjectId.make("project"),
27 repository: "org/repo",
28 number: 23,
29 host: "forge.example.com",
30 },
31};
32​
33function file(id: string, path: string, previousPath: string | null): ReviewRenderableFile {
34 return {
35 id,
36 path,
37 previousPath,
38 cacheKey: id,
39 changeType: previousPath ? "rename-changed" : "change",
40 additions: 1,
41 deletions: 1,
42 languageHint: "typescript",
43 additionLines: [],
44 deletionLines: [],
45 rows: [],
46 };
48​
49function setup() {
50 const parsed = {
51 kind: "files",
52 fileCount: 2,
53 additions: 2,
54 deletions: 2,
55 notice: null,
56 files: [file("selected", "renderer.ts", "old-renderer.ts"), file("unseen", "unseen.ts", null)],
57 } satisfies ReviewParsedDiff;
58 const files: NativeReviewDiffFile[] = parsed.files.map((file) => ({
59 id: file.id,
60 path: file.path,
61 language: "typescript",
62 additions: 1,
63 deletions: 1,
64 }));
65 return { parsed, files };
67​
68it("loads only the selected PR file using its remote target, commit, and rename paths", async () => {
69 const { parsed, files } = setup();
70 const contents = {
71 oldContents: "/* Previous renderer\nconst removed = 1;\n*/",
72 newContents: "const heading = `Current renderer\nWelcome to the workspace.\n`;",
73 };
74 const fetch = vi.fn(async () => AsyncResult.success(contents));
75 const loaded = createPullRequestDiffHighlightFiles(
76 { target, commit: "abc123" },
77 parsed,
78 files,
79 fetch,
80 );
81 expect(fetch).not.toHaveBeenCalled();
82 const rows = [
83 {
84 kind: "line" as const,
85 id: "removed",
86 fileId: "selected",
87 change: "delete" as const,
88 oldLineNumber: 2,
89 newLineNumber: null,
90 content: "const removed = 1;",
91 },
92 {
93 kind: "line" as const,
94 id: "added",
95 fileId: "selected",
96 change: "add" as const,
97 oldLineNumber: null,
98 newLineNumber: 2,
99 content: "Welcome to the workspace.",
100 },
101 {
102 kind: "line" as const,
103 id: "unseen",
104 fileId: "unseen",
105 change: "add" as const,
106 oldLineNumber: null,
107 newLineNumber: 1,
108 content: "export const unseen = true;",
109 },
110 ];
111 const result = await highlightNativeReviewDiffVisibleRows({
112 rows,
113 files: loaded,
114 scheme: "dark",
115 firstRowIndex: 0,
116 lastRowIndex: 1,
117 overscanRows: 0,
118 });
119 expect(fetch.mock.calls).toEqual([
120 [
121 {
122 ...target,
123 input: {
124 ...target.input,
125 commit: "abc123",
126 changeType: "rename-changed",
127 oldPath: "old-renderer.ts",
128 newPath: "renderer.ts",
129 },
130 },
131 ],
132 ]);
133 expect(result.tokensByRowId.removed).toEqual(
134 highlightCode(contents.oldContents, "typescript", "dark")[1],
135 );
136 expect(result.tokensByRowId.added).toEqual(
137 highlightCode(contents.newContents, "typescript", "dark")[1],
138 );
139 expect(result.tokensByRowId.unseen).toBeUndefined();
140 expect(await loaded[0]!.loadContents!()).toBe(contents);
141 expect(fetch).toHaveBeenCalledTimes(1);
142});
143​
144it.each([null, "Some file contents are unavailable"])(
145 "retains fetched and unavailable source when another diff page is appended (notice: %s)",
146 async (notice) => {
147 const { parsed, files } = setup();
148 const contents = { oldContents: "before", newContents: "after" };
149 const fetch = vi.fn(async ({ input }: { input: { newPath: string } }) => {
150 if (input.newPath === "unseen.ts") throw { reason: "unsupported" };
151 return AsyncResult.success(contents);
152 });
153 const cache = createPullRequestDiffHighlightCache();
154 const first = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch, cache);
155 expect(await first[0]!.loadContents!()).toBe(contents);
156 expect(await first[1]!.loadContents!()).toBeNull();
157 const appended = file("appended", "appended.ts", null);
158 const next = createPullRequestDiffHighlightFiles(
159 { target },
160 { ...parsed, files: [...parsed.files, appended], fileCount: 3, notice },
161 [...files, { ...files[1]!, id: appended.id, path: appended.path }],
162 fetch,
163 cache,
164 );
165 expect(await next[0]!.loadContents!()).toBe(contents);
166 expect(await next[1]!.loadContents!()).toBeNull();
167 expect(fetch).toHaveBeenCalledTimes(2);
168 },
169);
170​
171it("does not reuse source from another diff snapshot", async () => {
172 const { parsed, files } = setup();
173 const fetch = vi.fn(async () =>
174 AsyncResult.success({ oldContents: "", newContents: "const current = true;" }),
175 );
176 const cache = createPullRequestDiffHighlightCache();
177 const first = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch, cache);
178 const second = createPullRequestDiffHighlightFiles(
179 { target },
180 { ...parsed, files: parsed.files.map((file) => ({ ...file })) },
181 files,
182 fetch,
183 cache,
184 );
185 await first[0]!.loadContents!();
186 await second[0]!.loadContents!();
187 expect(fetch).toHaveBeenCalledTimes(2);
188});
189​
190it.each([
191 [
192 "environment",
193 { target: { ...target, environmentId: EnvironmentId.make("other") }, commit: "abc" },
194 ],
195 [
196 "project",
197 {
198 target: { ...target, input: { ...target.input, projectId: ProjectId.make("other") } },
199 commit: "abc",
200 },
201 ],
202 [
203 "repository",
204 { target: { ...target, input: { ...target.input, repository: "other/repo" } }, commit: "abc" },
205 ],
206 ["PR", { target: { ...target, input: { ...target.input, number: 24 } }, commit: "abc" }],
207 [
208 "host",
209 { target: { ...target, input: { ...target.input, host: "other.example.com" } }, commit: "abc" },
210 ],
211 ["commit", { target, commit: "def" }],
212 ["unpinned commit", { target }],
213])("isolates retained file objects after changing the %s", async (_name, source) => {
214 const { parsed, files } = setup();
215 const cache = createPullRequestDiffHighlightCache();
216 const previous = { oldContents: "", newContents: "previous" };
217 const current = { oldContents: "", newContents: "current" };
218 const fetch = vi
219 .fn()
220 .mockResolvedValueOnce(AsyncResult.success(previous))
221 .mockResolvedValueOnce(AsyncResult.success(current));
222 const first = createPullRequestDiffHighlightFiles(
223 { target, commit: "abc" },
224 parsed,
225 files,
226 fetch,
227 cache,
228 );
229 expect(await first[0]!.loadContents!()).toBe(previous);
230 const second = createPullRequestDiffHighlightFiles(source, parsed, files, fetch, cache);
231 expect(await second[0]!.loadContents!()).toBe(current);
232 expect(await first[0]!.loadContents!()).toBe(previous);
233 expect(fetch).toHaveBeenCalledTimes(2);
234});
235​
236it("does not reuse a replaced later-page file with the same ID", async () => {
237 const { parsed, files } = setup();
238 const cache = createPullRequestDiffHighlightCache();
239 const previous = { oldContents: "", newContents: "previous" };
240 const current = { oldContents: "", newContents: "current" };
241 const fetch = vi
242 .fn()
243 .mockResolvedValueOnce(AsyncResult.success(previous))
244 .mockResolvedValueOnce(AsyncResult.success(current));
245 const first = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch, cache);
246 expect(await first[1]!.loadContents!()).toBe(previous);
247 const second = createPullRequestDiffHighlightFiles(
248 { target },
249 { ...parsed, files: [parsed.files[0]!, { ...parsed.files[1]! }] },
250 files,
251 fetch,
252 cache,
253 );
254 expect(await second[1]!.loadContents!()).toBe(current);
255 expect(fetch).toHaveBeenCalledTimes(2);
256});
257​
258it.each(["permanent", "transient"])(
259 "does not reuse a replaced later-page file's %s failure with the same ID",
260 async (failure) => {
261 const { parsed, files } = setup();
262 const cache = createPullRequestDiffHighlightCache();
263 const contents = { oldContents: "", newContents: "current" };
264 const fetch = vi.fn(async () => AsyncResult.success(contents));
265 const error = failure === "permanent" ? { reason: "unsupported" } : new Error("Offline");
266 fetch.mockRejectedValueOnce(error);
267 if (failure === "transient") fetch.mockRejectedValueOnce(error);
268 const first = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch, cache);
269 if (failure === "transient") {
270 await expect(first[1]!.loadContents!()).rejects.toThrow("Offline");
271 await expect(first[1]!.loadContents!()).rejects.toThrow("Offline");
272 } else {
273 expect(await first[1]!.loadContents!()).toBeNull();
274 }
275 const second = createPullRequestDiffHighlightFiles(
276 { target },
277 { ...parsed, files: [parsed.files[0]!, { ...parsed.files[1]! }] },
278 files,
279 fetch,
280 cache,
281 );
282 expect(await second[1]!.loadContents!()).toBe(contents);
283 expect(await first[1]!.loadContents!()).toBe(failure === "permanent" ? null : contents);
284 expect(fetch).toHaveBeenCalledTimes(failure === "permanent" ? 2 : 4);
285 },
286);
287​
288it("does not reuse reconstructed source after an appended page makes completeness uncertain", async () => {
289 const { parsed, files } = setup();
290 const cache = createPullRequestDiffHighlightCache();
291 const reconstructed = { oldContents: "", newContents: "reconstructed" };
292 const authoritative = { oldContents: "", newContents: "authoritative" };
293 parsed.files[0] = { ...parsed.files[0]!, completePatchSource: () => reconstructed };
294 const fetch = vi.fn(async () => AsyncResult.success(authoritative));
295 const first = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch, cache);
296 expect(await first[0]!.loadContents!()).toBe(reconstructed);
297 const next = createPullRequestDiffHighlightFiles(
298 { target },
299 { ...parsed, notice: "Some file contents are unavailable" },
300 files,
301 fetch,
302 cache,
303 );
304 expect(await next[0]!.loadContents!()).toBe(authoritative);
305 expect(fetch).toHaveBeenCalledOnce();
306});
307​
308it.each([
309 ["characters", 2, 4_500_000],
310 ["file count", 5, 1],
311])(
312 "shares the source cache's aggregate %s budget across appended pages",
313 async (_name, count, size) => {
314 const { parsed, files } = setup();
315 const cache = createPullRequestDiffHighlightCache();
316 const contents = { oldContents: "", newContents: "x".repeat(size) };
317 const fetch = vi.fn(async () => AsyncResult.success(contents));
318 const first = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch, cache);
319 expect(await first[0]!.loadContents!()).toBe(contents);
320 let next = first;
321 for (let i = 1; i < count; i++) {
322 const appended = file(`page-${i}`, `page-${i}.ts`, null);
323 parsed.files.push(appended);
324 files.push({ ...files[0]!, id: appended.id, path: appended.path });
325 next = createPullRequestDiffHighlightFiles(
326 { target },
327 { ...parsed },
328 [...files],
329 fetch,
330 cache,
331 );
332 expect(await next.at(-1)!.loadContents!()).toBe(contents);
333 }
334 expect(fetch).toHaveBeenCalledTimes(count);
335 expect(await next[0]!.loadContents!()).toBe(contents);
336 expect(fetch).toHaveBeenCalledTimes(count + 1);
337 },
338);
339​
340function addedPatch(body: string, header = "@@ -0,0 +1,2 @@") {
341 return `diff --git a/file.c b/file.c\nnew file mode 100644\n--- /dev/null\n+++ b/file.c\n${header}\n${body}`;
343​
344function loadPatch(patch: string, truncated = false) {
345 const parsed = combinePullRequestDiffPages(
346 [{ cursor: null, result: { patch, truncated, nextCursor: null } }],
347 new WeakMap(),
348 "complete-source",
349 sourceControlClients.get("github").changeRequest,
350 );
351 if (parsed.kind !== "files") throw new Error("Expected parsed files");
352 const files = parsed.files.map((file) => ({
353 id: file.id,
354 path: file.path,
355 language: "c" as const,
356 additions: file.additions,
357 deletions: file.deletions,
358 }));
359 const authoritative = { oldContents: "provider old", newContents: "provider new" };
360 const fetch = vi.fn(async () => AsyncResult.success(authoritative));
361 const loaded = createPullRequestDiffHighlightFiles({ target }, parsed, files, fetch);
362 return { loaded, fetch, authoritative };
364​
365it.each([0, 1])(
366 "loads a complete 100k-line addition with empty-side start %s beyond the RPC cap",
367 async (emptyStart) => {
368 const source = "/* generated C */ int value = 1;\n".repeat(100_000);
369 const body = source.replace(/^/gm, "+").slice(0, -1);
370 const { loaded, fetch } = loadPatch(addedPatch(body, `@@ -${emptyStart},0 +1,100000 @@`));
371 expect(source.length).toBeGreaterThan(1_048_576);
372 const contents = await loaded[0]!.loadContents!();
373 expect(contents?.oldContents).toBe("");
374 expect(contents?.newContents.length).toBe(source.length);
375 expect(contents?.newContents === source).toBe(true);
376 expect(fetch).not.toHaveBeenCalled();
377 },
378);
379​
380it.each([
381 ["LF", "+first\n+last\n", "first\nlast\n"],
382 ["CRLF", "+first\r\n+last\r\n", "first\r\nlast\r\n"],
383 ["missing EOF newline", "+first\r\n+last\n\\ No newline at end of file\n", "first\r\nlast"],
384 ["final bare CR", "+first\r\n+last\r\n\\ No newline at end of file\n", "first\r\nlast\r"],
385])("preserves %s for complete additions and deletions", async (_name, body, source) => {
386 for (const emptyStart of [0, 1]) {
387 const added = addedPatch(body, `@@ -${emptyStart},0 +1,2 @@`);
388 const deleted = added
389 .replace("new file mode", "deleted file mode")
390 .replace("--- /dev/null\n+++ b/file.c", "--- a/file.c\n+++ /dev/null")
391 .replace(`@@ -${emptyStart},0 +1,2 @@`, `@@ -1,2 +${emptyStart},0 @@`);
392 const deletionPatch = deleted.replace(body, body.replace(/\+/g, "-"));
393 for (const [patch, expected] of [
394 [added, { oldContents: "", newContents: source }],
395 [deletionPatch, { oldContents: source, newContents: "" }],
396 ] as const) {
397 const { loaded, fetch } = loadPatch(patch);
398 expect(await loaded[0]!.loadContents!()).toEqual(expected);
399 expect(fetch).not.toHaveBeenCalled();
400 }
401 }
402});
403​
404it.each([
405 ["too few lines", addedPatch("+first\n", "@@ -0,0 +1,2 @@")],
406 ["too many lines", addedPatch("+first\n+last\n", "@@ -0,0 +1 @@")],
407 ["zero repaired count", addedPatch("+first\n+last\n", "@@ -0,0 +1,0 @@")],
408 ["unterminated patch line", addedPatch("+first\n+last")],
409 ["missing count", addedPatch("+first\n+last\n", "@@ -0,0 +1, @@")],
410 ["gap", addedPatch("+first\n+last\n", "@@ -0,0 +2,2 @@")],
411 ["invalid empty-side start", addedPatch("+first\n+last\n", "@@ -2,0 +1,2 @@")],
412 ["converted underfull hunk", addedPatch("+first\n", "@@ -1,0 +1,2 @@")],
413 ["converted overfull hunk", addedPatch("+first\n+last\n", "@@ -1,0 +1 @@")],
414 ["converted nonempty old side", addedPatch("+first\n+last\n", "@@ -1,1 +1,2 @@")],
415 ["context", addedPatch(" first\n+last\n", "@@ -1 +1,2 @@")],
416 ["mixed sides", addedPatch("-first\n+last\n", "@@ -1 +1 @@")],
417 ["second hunk", addedPatch("+first\n@@ -0,0 +2 @@\n+last\n", "@@ -0,0 +1 @@")],
418 ["early EOF marker", addedPatch("+first\n\\ No newline at end of file\n+last\n")],
419 ["unknown marker", addedPatch("+first\n+last\n\\ unknown\n")],
420 ["trailing garbage", addedPatch("+first\n+last\nignored\n")],
421 ["submodule", addedPatch("+first\n+last\n").replace("100644", "160000")],
422 [
423 "binary",
424 addedPatch("+first\n+last\n").replace("--- /dev/null", "Binary files differ\n--- /dev/null"),
425 ],
426 ["changed", addedPatch("+first\n+last\n").replace("new file mode 100644\n", "")],
427 [
428 "rename",
429 addedPatch("+first\n+last\n").replace(
430 "new file mode 100644",
431 "similarity index 50%\nrename from old.c\nrename to file.c",
432 ),
433 ],
434 ["truncation marker", addedPatch("+first\n+last\n\n[truncated]\n")],
435])("uses authoritative RPC source when completeness is unproven: %s", async (_name, patch) => {
436 const { loaded, fetch, authoritative } = loadPatch(patch);
437 expect(await loaded[0]!.loadContents!()).toBe(authoritative);
438 expect(fetch).toHaveBeenCalledOnce();
439});
440​
441it("rejects a complete-looking patch when the provider marks any page truncated", async () => {
442 const { loaded, fetch, authoritative } = loadPatch(addedPatch("+first\n+last\n"), true);
443 expect(await loaded[0]!.loadContents!()).toBe(authoritative);
444 expect(fetch).toHaveBeenCalledOnce();
445});