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