1import { hydratePartialDiff } from "@pierre/diffs";
2import { describe, expect, it } from "vite-plus/test";
3import { resolveDiffReviewPosition } from "../reviewCommentContext";
4import {
5 buildFileDiffContentVersion,
6 buildFileDiffIdentityKey,
7 buildFileDiffRenderKey,
8 buildPatchCacheKey,
9 getDiffLineStat,
10 getRenderablePatch,
11 resolveFileDiffPath,
12 resolveFileDiffPreviousPath,
13} from "./diffRendering";
14
15describe("buildPatchCacheKey", () => {
16 it("normalizes outer whitespace before hashing", () => {
17 const patch = "diff --git a/a.ts b/a.ts\n+console.log('hello')";
18
19 expect(buildPatchCacheKey(`\n${patch}\n`)).toBe(buildPatchCacheKey(patch));
20 });
21
22 it("changes when diff content changes", () => {
23 const before = "diff --git a/a.ts b/a.ts\n+console.log('hello')";
24 const after = "diff --git a/a.ts b/a.ts\n+console.log('hello world')";
25
26 expect(buildPatchCacheKey(before)).not.toBe(buildPatchCacheKey(after));
27 });
28
29 it("changes when cache scope changes", () => {
30 const patch = "diff --git a/a.ts b/a.ts\n+console.log('hello')";
31
32 expect(buildPatchCacheKey(patch, "diff-panel:light")).not.toBe(
33 buildPatchCacheKey(patch, "diff-panel:dark"),
34 );
35 });
36});
37
38describe("getRenderablePatch", () => {
39 it("hides indentation changes around inserted JSX without moving review lines", () => {
40 const patch = [
41 "diff --git a/item.tsx b/item.tsx",
42 "--- a/item.tsx",
43 "+++ b/item.tsx",
44 "@@ -40,5 +40,7 @@",
45 ' <ItemContent className="min-w-0">',
46 "- <ItemTitle>",
47 '- <h4 className="wrap-break-word">{name}</h4>',
48 "- </ItemTitle>",
49 "+ {showName && (",
50 "+ <ItemTitle>",
51 '+ <h4 className="wrap-break-word">{name}</h4>',
52 "+ </ItemTitle>",
53 "+ )}",
54 " </ItemContent>",
55 "@@ -80 +82 @@",
56 "-const value = 1;",
57 "+const value = 2;",
58 ].join("\n");
59 const shown = getRenderablePatch(patch, "pr", { compactPartialHunkOffsets: true });
60 const hidden = getRenderablePatch(patch, "pr", {
61 compactPartialHunkOffsets: true,
62 ignoreWhitespace: true,
63 });
64 expect(shown?.kind).toBe("files");
65 expect(hidden?.kind).toBe("files");
66 if (shown?.kind !== "files" || hidden?.kind !== "files") return;
67 expect(getDiffLineStat(shown.files)).toEqual({ additions: 6, deletions: 4 });
68 expect(getDiffLineStat(hidden.files)).toEqual({ additions: 3, deletions: 1 });
69 const file = hidden.files[0]!;
70 expect(file.additionLines).toEqual(shown.files[0]!.additionLines);
71 expect(file.deletionLines).toEqual(shown.files[0]!.deletionLines);
72 expect(file.cacheKey).not.toBe(shown.files[0]!.cacheKey);
73 expect(resolveDiffReviewPosition(hidden.sourceFiles[0]!, 43, "additions")).toEqual({
74 kind: "added",
75 newLine: 43,
76 });
77 expect(resolveDiffReviewPosition(hidden.sourceFiles[0]!, 42, "deletions")).toEqual({
78 kind: "deleted",
79 oldLine: 42,
80 });
81 expect(file.hunks[0]?.hunkContent).toContainEqual({
82 type: "context",
83 lines: 3,
84 additionLineIndex: 2,
85 deletionLineIndex: 1,
86 });
87 expect(resolveDiffReviewPosition(file, 41, "additions")).toEqual({
88 kind: "added",
89 newLine: 41,
90 });
91 expect(resolveDiffReviewPosition(file, 43, "additions")).toEqual({
92 kind: "context",
93 oldLine: 42,
94 newLine: 43,
95 side: "right",
96 });
97 expect(resolveDiffReviewPosition(file, 42, "deletions")).toEqual({
98 kind: "context",
99 oldLine: 42,
100 newLine: 43,
101 side: "left",
102 });
103 expect(file.hunks[1]).toMatchObject({
104 additionStart: 82,
105 deletionStart: 80,
106 splitLineStart: 7,
107 unifiedLineStart: 7,
108 });
109 const prefix = "unchanged\n".repeat(39);
110 const gap = "unchanged\n".repeat(35);
111 const hydrated = hydratePartialDiff("clone", file, {
112 oldFile: {
113 name: file.name,
114 contents: prefix + file.deletionLines.slice(0, 5).join("") + gap + "const value = 1;\n",
115 },
116 newFile: {
117 name: file.name,
118 contents: prefix + file.additionLines.slice(0, 7).join("") + gap + "const value = 2;\n",
119 },
120 });
121 expect(getDiffLineStat([hydrated])).toEqual({ additions: 3, deletions: 1 });
122 expect(resolveDiffReviewPosition(hydrated, 43, "additions")).toEqual(
123 resolveDiffReviewPosition(file, 43, "additions"),
124 );
125 });
126
127 it.each([
128 [" const x = 1;\t", "\tconst x=1;", 0, 0],
129 ["const x = 1;", "const x = 2;", 1, 1],
130 ["const x = 1;", "const x = 1;\n", 1, 0],
131 ])("filters whitespace in %j to %j", (before, after, additions, deletions) => {
132 const patch = [
133 "diff --git a/example.ts b/example.ts",
134 "--- a/example.ts",
135 "+++ b/example.ts",
136 `@@ -1 +1,${after.split("\n").length} @@`,
137 `-${before}`,
138 ...after.split("\n").map((line) => `+${line}`),
139 "",
140 ].join("\n");
141 const filtered = getRenderablePatch(patch, "pr", { ignoreWhitespace: true });
142 expect(filtered?.kind).toBe("files");
143 if (filtered?.kind !== "files") return;
144 expect(getDiffLineStat(filtered.files)).toEqual({ additions, deletions });
145 });
146
147 it.each(["+", "-"])("keeps %s blank lines without a final newline", (sign) => {
148 const parsed = getRenderablePatch(
149 [
150 "diff --git a/blank.txt b/blank.txt",
151 "--- a/blank.txt",
152 "+++ b/blank.txt",
153 sign === "+" ? "@@ -0,0 +1 @@" : "@@ -1 +0,0 @@",
154 `${sign} `,
155 "\\ No newline at end of file",
156 ].join("\n"),
157 "pr",
158 { ignoreWhitespace: true },
159 );
160 expect(parsed?.kind).toBe("files");
161 if (parsed?.kind !== "files") return;
162 expect(getDiffLineStat(parsed.files)).toEqual({
163 additions: sign === "+" ? 1 : 0,
164 deletions: sign === "-" ? 1 : 0,
165 });
166 });
167
168 it.each([
169 ["a/example.ts", "a/example.ts", "change"],
170 ["b/example.ts", "b/example.ts", "change"],
171 ["a/before.ts", "b/after.ts", "rename-changed"],
172 ])("preserves repository paths from %s to %s", (previousPath, path, type) => {
173 const parsed = getRenderablePatch(
174 [
175 `diff --git a/${previousPath} b/${path}`,
176 ...(previousPath === path
177 ? []
178 : ["similarity index 50%", `rename from ${previousPath}`, `rename to ${path}`]),
179 `--- a/${previousPath}`,
180 `+++ b/${path}`,
181 "@@ -1 +1 @@",
182 "-before",
183 "+after",
184 ].join("\n"),
185 );
186 expect(parsed?.kind).toBe("files");
187 if (parsed?.kind !== "files") return;
188 const file = parsed.files[0];
189 expect(file).toBeDefined();
190 if (!file) return;
191 expect(resolveFileDiffPath(file)).toBe(path);
192 expect(resolveFileDiffPreviousPath(file)).toBe(previousPath);
193 expect(buildFileDiffIdentityKey(file)).toBe(`${previousPath}\0${path}\0${type}`);
194 });
195
196 it("compacts partial hunk render offsets for virtualized review diffs", () => {
197 const patch = [
198 "diff --git a/example.ts b/example.ts",
199 "index 1111111..2222222 100644",
200 "--- a/example.ts",
201 "+++ b/example.ts",
202 "@@ -48,4 +48,4 @@",
203 " context",
204 "-before",
205 "+after",
206 " context",
207 " context",
208 "@@ -80,3 +80,4 @@",
209 " context",
210 "+added",
211 " context",
212 " context",
213 ].join("\n");
214
215 const parsed = getRenderablePatch(patch, "review", {
216 compactPartialHunkOffsets: true,
217 });
218 expect(parsed?.kind).toBe("files");
219 if (parsed?.kind !== "files") return;
220
221 const file = parsed.files[0];
222 expect(file?.hunks[0]?.collapsedBefore).toBe(47);
223 expect(file?.hunks[0]?.unifiedLineStart).toBe(0);
224 expect(file?.hunks[1]?.collapsedBefore).toBeGreaterThan(0);
225 expect(file?.hunks[1]?.unifiedLineStart).toBe(file?.hunks[0]?.unifiedLineCount);
226 expect(file?.unifiedLineCount).toBe(
227 file?.hunks.reduce((total, hunk) => total + hunk.unifiedLineCount, 0),
228 );
229 });
230
231 it("retains source-file offsets for checkpoint diffs", () => {
232 const patch = [
233 "diff --git a/example.ts b/example.ts",
234 "--- a/example.ts",
235 "+++ b/example.ts",
236 "@@ -48,1 +48,1 @@",
237 "-before",
238 "+after",
239 ].join("\n");
240
241 const parsed = getRenderablePatch(patch, "checkpoint");
242 expect(parsed?.kind).toBe("files");
243 if (parsed?.kind !== "files") return;
244 expect(parsed.files[0]?.hunks[0]?.unifiedLineStart).toBe(47);
245 });
246});
247
248describe("diff file reconciliation", () => {
249 it("keeps Pierre's render key stable when a partial diff hydrates", () => {
250 const patch = [
251 "diff --git a/example.ts b/example.ts",
252 "--- a/example.ts",
253 "+++ b/example.ts",
254 "@@ -1 +1 @@",
255 "-before",
256 "+after",
257 ].join("\n");
258 const parsed = getRenderablePatch(patch, "hydrated-key");
259 expect(parsed?.kind).toBe("files");
260 if (parsed?.kind !== "files") return;
261
262 const file = parsed.files[0];
263 expect(file).toBeDefined();
264 if (!file) return;
265 const key = buildFileDiffRenderKey(file);
266 file.cacheKey = `${file.cacheKey}:hydrated`;
267
268 expect(buildFileDiffRenderKey(file)).toBe(key);
269 });
270
271 it("gives a type change its own identity per block", () => {
272 const patch = [
273 "diff --git a/AGENTS.md b/AGENTS.md",
274 "deleted file mode 100644",
275 "--- a/AGENTS.md",
276 "+++ /dev/null",
277 "@@ -1 +0,0 @@",
278 "-duplicated instructions",
279 "diff --git a/AGENTS.md b/AGENTS.md",
280 "new file mode 120000",
281 "--- /dev/null",
282 "+++ b/AGENTS.md",
283 "@@ -0,0 +1 @@",
284 "+CLAUDE.md",
285 ].join("\n");
286 const parsed = getRenderablePatch(patch, "type-change");
287 expect(parsed?.kind).toBe("files");
288 if (parsed?.kind !== "files") return;
289 const [deleted, added] = parsed.files;
290 expect(deleted?.type).toBe("deleted");
291 expect(added?.type).toBe("new");
292 if (!deleted || !added) return;
293
294 expect(buildFileDiffIdentityKey(deleted)).not.toBe(buildFileDiffIdentityKey(added));
295 expect(new Set(parsed.files.map(buildFileDiffIdentityKey)).size).toBe(parsed.files.length);
296 });
297
298 it("keeps identities stable and versions local to the changed file", () => {
299 const patch = (secondLine: string) =>
300 [
301 "diff --git a/unchanged.ts b/unchanged.ts",
302 "--- a/unchanged.ts",
303 "+++ b/unchanged.ts",
304 "@@ -1 +1 @@",
305 "-before",
306 "+after",
307 "diff --git a/changed.ts b/changed.ts",
308 "--- a/changed.ts",
309 "+++ b/changed.ts",
310 "@@ -1 +1 @@",
311 "-old",
312 `+${secondLine}`,
313 ].join("\n");
314 const before = getRenderablePatch(patch("new"), "before");
315 const after = getRenderablePatch(patch("newer"), "after");
316 expect(before?.kind).toBe("files");
317 expect(after?.kind).toBe("files");
318 if (before?.kind !== "files" || after?.kind !== "files") return;
319
320 const [beforeUnchanged, beforeChanged] = before.files;
321 const [afterUnchanged, afterChanged] = after.files;
322 expect(beforeUnchanged).toBeDefined();
323 expect(beforeChanged).toBeDefined();
324 expect(afterUnchanged).toBeDefined();
325 expect(afterChanged).toBeDefined();
326 if (!beforeUnchanged || !beforeChanged || !afterUnchanged || !afterChanged) return;
327
328 expect(buildFileDiffIdentityKey(afterUnchanged)).toBe(
329 buildFileDiffIdentityKey(beforeUnchanged),
330 );
331 expect(buildFileDiffIdentityKey(afterChanged)).toBe(buildFileDiffIdentityKey(beforeChanged));
332 expect(buildFileDiffContentVersion(afterUnchanged)).toBe(
333 buildFileDiffContentVersion(beforeUnchanged),
334 );
335 expect(buildFileDiffContentVersion(afterChanged)).not.toBe(
336 buildFileDiffContentVersion(beforeChanged),
337 );
338 });
339});
340
341describe("getDiffLineStat", () => {
342 it("totals additions and deletions across every file and hunk", () => {
343 const patch = [
344 "diff --git a/example.ts b/example.ts",
345 "--- a/example.ts",
346 "+++ b/example.ts",
347 "@@ -1,2 +1,3 @@",
348 "-before",
349 "+after",
350 "+added",
351 " context",
352 "@@ -10,2 +11,1 @@",
353 "-removed",
354 " context",
355 "diff --git a/README.md b/README.md",
356 "--- a/README.md",
357 "+++ b/README.md",
358 "@@ -1 +1,2 @@",
359 " title",
360 "+description",
361 ].join("\n");
362
363 const parsed = getRenderablePatch(patch);
364 expect(parsed?.kind).toBe("files");
365 if (parsed?.kind !== "files") return;
366
367 expect(getDiffLineStat(parsed.files)).toEqual({ additions: 3, deletions: 2 });
368 });
369});
370
371describe("a file whose name a patch header cannot carry plainly", () => {
372 /** How git writes such a name, and so how every provider's patch arrives here. */
373 const quotedPatch = (written: string) =>
374 [
375 `diff --git "a/${written}" "b/${written}"`,
376 "index 1111111..2222222 100644",
377 `--- "a/${written}"`,
378 `+++ "b/${written}"`,
379 "@@ -1 +1 @@",
380 "-before",
381 "+after",
382 "",
383 ].join("\n");
384
385 const pathOf = (patch: string) => {
386 const parsed = getRenderablePatch(patch, "review");
387 expect(parsed?.kind).toBe("files");
388 if (parsed?.kind !== "files") throw new Error("patch did not parse as files");
389 const file = parsed.files[0];
390 expect(file).toBeDefined();
391 if (!file) throw new Error("patch carried no file");
392 return resolveFileDiffPath(file);
393 };
394
395 it("is the name the host knows, not the part of it before the tab", () => {
396 // The path is what a viewed mark, a review comment and a file read are all asked for by, so a
397 // name read short is a mark put on a path the host has never heard of.
398 expect(pathOf(quotedPatch("tab\\tfile.txt"))).toBe("tab\tfile.txt");
399 });
400
401 it("is the name the host knows, not the part of it before the newline", () => {
402 expect(pathOf(quotedPatch("line\\nfile.txt"))).toBe("line\nfile.txt");
403 });
404
405 it("keeps a backslash the name really has", () => {
406 expect(pathOf(quotedPatch("back\\\\slash.txt"))).toBe("back\\slash.txt");
407 });
408
409 it("reads the octal a host with core.quotePath on writes for a name outside ASCII", () => {
410 expect(pathOf(quotedPatch("caf\\303\\251/r\\303\\251sum\\303\\251.ts"))).toBe("café/résumé.ts");
411 });
412
413 it("reads both sides of a rename under the names they really have", () => {
414 const patch = [
415 'diff --git "a/old\\tname.ts" "b/new\\tname.ts"',
416 "similarity index 90%",
417 'rename from "old\\tname.ts"',
418 'rename to "new\\tname.ts"',
419 "",
420 ].join("\n");
421
422 const parsed = getRenderablePatch(patch, "review");
423 expect(parsed?.kind).toBe("files");
424 if (parsed?.kind !== "files") return;
425 const file = parsed.files[0];
426 expect(file).toBeDefined();
427 if (!file) return;
428
429 expect(resolveFileDiffPath(file)).toBe("new\tname.ts");
430 expect(resolveFileDiffPreviousPath(file)).toBe("old\tname.ts");
431 });
432});