1import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 azureDevOpsFilePatch,
6 azureDevOpsUnreadableFilePatch,
7 byteLength,
8 formatAzureDevOpsDiffCursor,
9 MAX_FILE_DIFF_EDITS,
10 parseAzureDevOpsDiffCursor,
11} from "./azureDevOpsDiff.ts";
12import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts";
13
14function change(overrides: Partial<AzureDevOpsChangeEntry> = {}): AzureDevOpsChangeEntry {
15 return {
16 path: "README.md",
17 oldPath: "README.md",
18 changeKind: "change",
19 objectId: "8f80",
20 originalObjectId: "0ca4",
21 ...overrides,
22 };
23}
24
25function texts(oldContents: string, newContents: string, binary = false) {
26 return { oldContents, newContents, binary };
27}
28
29describe("azureDevOpsFilePatch", () => {
30 it("writes a changed file as the unified patch every diff viewer already reads", () => {
31 const patch = azureDevOpsFilePatch({
32 change: change(),
33 texts: texts("one\ntwo\nthree\n", "one\ntwo again\nthree\n"),
34 });
35
36 expect(patch.truncated).toBe(false);
37 expect(patch.section).toBe(
38 [
39 "diff --git a/README.md b/README.md",
40 "--- a/README.md",
41 "+++ b/README.md",
42 "@@ -1,3 +1,3 @@",
43 " one",
44 "-two",
45 "+two again",
46 " three",
47 "",
48 ].join("\n"),
49 );
50 });
51
52 it("names the side a new file does not have as /dev/null", () => {
53 const patch = azureDevOpsFilePatch({
54 change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }),
55 texts: texts("", "hello\n"),
56 });
57
58 expect(patch.section).toContain("new file mode 100644");
59 expect(patch.section).toContain("--- /dev/null");
60 expect(patch.section).toContain("+++ b/DEMO.md");
61 // Git points the range a new file does not have at line zero, not at line one.
62 expect(patch.section).toContain("@@ -0,0 +1 @@");
63 expect(patch.section).toContain("+hello");
64 });
65
66 it("names the side a deleted file no longer has as /dev/null", () => {
67 const patch = azureDevOpsFilePatch({
68 change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }),
69 texts: texts("gone\n", ""),
70 });
71
72 expect(patch.section).toContain("deleted file mode 100644");
73 expect(patch.section).toContain("--- a/OLD.md");
74 expect(patch.section).toContain("+++ /dev/null");
75 expect(patch.section).toContain("@@ -1 +0,0 @@");
76 expect(patch.section).toContain("-gone");
77 });
78
79 it("keeps the carriage returns of a file with Windows line endings", () => {
80 // They are part of the line rather than around it, so a patch that dropped them would ask
81 // the reader to look at a change that is not the one on the host.
82 const patch = azureDevOpsFilePatch({
83 change: change(),
84 texts: texts("one\r\ntwo\r\n", "one\r\ntwo again\r\n"),
85 });
86
87 expect(patch.section).toContain("-two\r");
88 expect(patch.section).toContain("+two again\r");
89 });
90
91 it("keeps a file that only moved, which has no hunks to give", () => {
92 const patch = azureDevOpsFilePatch({
93 change: change({ path: "docs/new.md", oldPath: "docs/old.md", changeKind: "rename-pure" }),
94 texts: texts("same\n", "same\n"),
95 });
96
97 expect(patch.truncated).toBe(false);
98 expect(patch.section).toBe(
99 [
100 "diff --git a/docs/old.md b/docs/new.md",
101 "rename from docs/old.md",
102 "rename to docs/new.md",
103 "--- a/docs/old.md",
104 "+++ b/docs/new.md",
105 "",
106 ].join("\n"),
107 );
108 });
109
110 it("reports a binary file as changed rather than spelling it out", () => {
111 const patch = azureDevOpsFilePatch({
112 change: change({ path: "logo.png", oldPath: "logo.png" }),
113 texts: texts("PNG\u0000old", "PNG\u0000new"),
114 });
115
116 expect(patch.truncated).toBe(true);
117 expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ");
118 });
119
120 it("shows an overlong file as changed without its hunks", () => {
121 const patch = azureDevOpsFilePatch({
122 change: change({ path: "bundle.js", oldPath: "bundle.js" }),
123 texts: texts("a\n".repeat(400_000), "b\n".repeat(400_000)),
124 });
125
126 expect(patch.truncated).toBe(true);
127 expect(patch.section).toBe(
128 ["diff --git a/bundle.js b/bundle.js", "--- a/bundle.js", "+++ b/bundle.js", ""].join("\n"),
129 );
130 });
131
132 it("takes the host's word that a file is binary, whatever its bytes look like", () => {
133 // Azure hands such a file over base64-encoded, so nothing in the text it sent gives it away.
134 const patch = azureDevOpsFilePatch({
135 change: change({ path: "logo.png", oldPath: "logo.png" }),
136 texts: texts("b2xk", "bmV3", true),
137 });
138
139 expect(patch.truncated).toBe(true);
140 expect(patch.section).toContain("Binary files a/logo.png and b/logo.png differ");
141 });
142
143 it("counts an overlong file in bytes rather than in characters", () => {
144 // Three bytes each, so a ceiling counted in code units would let three times the size through.
145 const patch = azureDevOpsFilePatch({
146 change: change({ path: "notes.md", oldPath: "notes.md" }),
147 texts: texts("\u4e00".repeat(200_000), "\u4e8c".repeat(200_000)),
148 });
149
150 expect(patch.truncated).toBe(true);
151 expect(patch.section).toBe(
152 ["diff --git a/notes.md b/notes.md", "--- a/notes.md", "+++ b/notes.md", ""].join("\n"),
153 );
154 });
155
156 const lineRange = (count: number, prefix: string) =>
157 Array.from({ length: count }, (_, line) => `${prefix} ${line}`).join("\n");
158
159 it("lists a file too far apart to diff without its hunks", () => {
160 // Sharing no line at all costs one edit per line on each side, so this pair is twice the
161 // ceiling apart. Left to itself the search costs about the square of that and would hold the
162 // whole server, every websocket client with it, while it worked out a patch nobody reads.
163 // Writing both sides out instead would read as a genuine rewrite: the ceiling is a distance
164 // rather than a proportion, so a long file reaches it having changed in one corner, and that
165 // corner would be buried in a wall of red and green.
166 const lines = (prefix: string) =>
167 Array.from({ length: MAX_FILE_DIFF_EDITS }, (_, line) => `${prefix} ${line}`).join("\n");
168 const patch = azureDevOpsFilePatch({
169 change: change({ path: "generated.ts", oldPath: "generated.ts" }),
170 texts: texts(`${lines("old")}\n`, `${lines("new")}\n`),
171 });
172
173 expect(patch.truncated).toBe(true);
174 // And it says the search was given up on, because the reader of a run of files is meant to
175 // stop rather than spend that work again on each of the ones behind it.
176 expect(patch.abandoned).toBe(true);
177 expect(patch.section).toBe(
178 [
179 "diff --git a/generated.ts b/generated.ts",
180 "--- a/generated.ts",
181 "+++ b/generated.ts",
182 "",
183 ].join("\n"),
184 );
185 });
186
187 it("writes out a wholly new file however many lines it has", () => {
188 // Nothing on the old side means there was no edit distance to search out, so this is the
189 // minimal patch and not a stand-in for one. Fifteen thousand lines of thirty bytes is the
190 // shape this used to lose: inside the byte ceiling that gates every file, and well past every
191 // bound a two-sided file answers to, none of which is protecting against anything here.
192 const contents = `${Array.from({ length: 15_000 }, () => "x".repeat(29)).join("\n")}\n`;
193 const patch = azureDevOpsFilePatch({
194 change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }),
195 texts: texts("", contents),
196 });
197
198 expect(byteLength(contents)).toBeLessThan(512 * 1024);
199 expect(patch.truncated).toBe(false);
200 expect(patch.abandoned).toBe(false);
201 expect(patch.edits).toBe(15_000);
202 expect(patch.section).toContain("@@ -0,0 +1,15000 @@");
203 // Only the `+++` of the header on top of the file's own lines, so nothing was dropped out of
204 // the middle.
205 expect(patch.section.match(/^\+/gmu)).toHaveLength(15_001);
206 });
207
208 it("keeps a wholly new file too heavy to write out listed without its hunks", () => {
209 // Every line carries a marker, so a side of very short lines answers with up to twice its own
210 // bytes: these 200,000 one-character lines fit the ceiling each side is read under and weigh
211 // about 600KB written out, more than twice what a whole slice may carry. There is no smaller
212 // true patch for a creation to fall back to, so it is listed without its hunks, the same as a
213 // side too big to read at all, rather than sent at a size the slice budget exists to prevent.
214 const contents = `${Array.from({ length: 200_000 }, () => "x").join("\n")}\n`;
215 const patch = azureDevOpsFilePatch({
216 change: change({ path: "bundle.min.js", oldPath: "bundle.min.js", changeKind: "new" }),
217 texts: texts("", contents),
218 });
219
220 expect(byteLength(contents)).toBeLessThan(512 * 1024);
221 expect(patch.truncated).toBe(true);
222 expect(patch.abandoned).toBe(false);
223 // It still cost the walk over its lines, which is what the slice is charged for.
224 expect(patch.edits).toBe(200_000);
225 expect(patch.section).toBe(
226 [
227 "diff --git a/bundle.min.js b/bundle.min.js",
228 "new file mode 100644",
229 "--- /dev/null",
230 "+++ b/bundle.min.js",
231 "",
232 ].join("\n"),
233 );
234 });
235
236 it("writes out a wholly deleted file however many lines it had", () => {
237 const contents = `${lineRange(20_000, "line")}\n`;
238 const patch = azureDevOpsFilePatch({
239 change: change({ path: "OLD.md", oldPath: "OLD.md", changeKind: "deleted" }),
240 texts: texts(contents, ""),
241 });
242
243 expect(patch.truncated).toBe(false);
244 expect(patch.abandoned).toBe(false);
245 expect(patch.edits).toBe(20_000);
246 expect(patch.section).toContain("@@ -1,20000 +0,0 @@");
247 expect(patch.section.match(/^-line /gmu)).toHaveLength(20_000);
248 expect(patch.section.match(/^-/gmu)).toHaveLength(20_001);
249 });
250
251 it("gives an empty new file no hunk to read", () => {
252 // A file with nothing on either side has no lines to claim were replaced, and git writes it
253 // as a header alone.
254 const patch = azureDevOpsFilePatch({
255 change: change({ path: "EMPTY.md", oldPath: "EMPTY.md", changeKind: "new" }),
256 texts: texts("", ""),
257 });
258
259 expect(patch.edits).toBe(0);
260 expect(patch.section).not.toContain("@@");
261 });
262
263 it("marks a wholly new file whose last line has no newline after it", () => {
264 const patch = azureDevOpsFilePatch({
265 change: change({ path: "NOTES.md", oldPath: "NOTES.md", changeKind: "new" }),
266 texts: texts("", "one\ntwo"),
267 });
268
269 expect(patch.section).toBe(
270 [
271 "diff --git a/NOTES.md b/NOTES.md",
272 "new file mode 100644",
273 "--- /dev/null",
274 "+++ b/NOTES.md",
275 "@@ -0,0 +1,2 @@",
276 "+one",
277 "+two",
278 "\\ No newline at end of file",
279 "",
280 ].join("\n"),
281 );
282 });
283
284 it("keeps a file it did diff out of the giving up", () => {
285 const patch = azureDevOpsFilePatch({
286 change: change(),
287 texts: texts("one\ntwo\n", "one\ntwo again\n"),
288 });
289
290 expect(patch.abandoned).toBe(false);
291 });
292
293 it("counts what the diff worked out, which is what the file cost to diff", () => {
294 // The caller spends a budget of these across a slice, so they have to be the edits the search
295 // actually made: one line replaced is a removal and an addition, and the three lines of
296 // context around them cost nothing.
297 const patch = azureDevOpsFilePatch({
298 change: change(),
299 texts: texts("one\ntwo\nthree\nfour\n", "one\ntwo again\nthree\nfour\n"),
300 });
301
302 expect(patch.edits).toBe(2);
303 });
304
305 it("counts nothing for a file it never diffed", () => {
306 const patch = azureDevOpsFilePatch({
307 change: change({ path: "logo.png", oldPath: "logo.png" }),
308 texts: texts("PNG\u0000old", "PNG\u0000new"),
309 });
310
311 expect(patch.edits).toBe(0);
312 });
313
314 it("lists a file whose hunks outweigh its sides without them", () => {
315 // A handful of very long lines is a few edits and nowhere near the edit ceiling, and the
316 // patch carries both sides in full with three lines of context around each hunk, so the
317 // section comes out heavier than either side was. What one file weighs is what a slice's
318 // budget is spent in, so the edit ceiling alone does not bound this.
319 const line = `${"a".repeat(400 * 1024)}\n`;
320 const patch = azureDevOpsFilePatch({
321 change: change({ path: "min.js", oldPath: "min.js" }),
322 texts: texts(line, `${"b".repeat(400 * 1024)}\n`),
323 });
324
325 expect(patch.edits).toBeLessThan(MAX_FILE_DIFF_EDITS);
326 expect(patch.truncated).toBe(true);
327 expect(patch.section).toBe(
328 ["diff --git a/min.js b/min.js", "--- a/min.js", "+++ b/min.js", ""].join("\n"),
329 );
330 expect(byteLength(patch.section)).toBeLessThan(byteLength(line));
331 });
332
333 it("marks a file that does not end in a newline, as git does", () => {
334 const patch = azureDevOpsFilePatch({
335 change: change(),
336 texts: texts("one\n", "two"),
337 });
338
339 expect(patch.section).toContain("\\ No newline at end of file");
340 });
341});
342
343describe("azureDevOpsUnreadableFilePatch", () => {
344 it("keeps a file the host would not hand over, listed without its hunks", () => {
345 const patch = azureDevOpsUnreadableFilePatch(change({ path: "huge.bin", oldPath: "huge.bin" }));
346
347 expect(patch.truncated).toBe(true);
348 expect(patch.section).toBe(
349 ["diff --git a/huge.bin b/huge.bin", "--- a/huge.bin", "+++ b/huge.bin", ""].join("\n"),
350 );
351 });
352});
353
354describe("a file Azure names something a patch header cannot carry plainly", () => {
355 it("writes each side as git's quoted form, the side letter inside the quotes", () => {
356 const patch = azureDevOpsFilePatch({
357 change: change({ path: "notes\treadme.md", oldPath: "notes\treadme.md" }),
358 texts: texts("one\n", "two\n"),
359 });
360
361 expect(patch.section).toBe(
362 [
363 'diff --git "a/notes\\treadme.md" "b/notes\\treadme.md"',
364 '--- "a/notes\\treadme.md"',
365 '+++ "b/notes\\treadme.md"',
366 "@@ -1 +1 @@",
367 "-one",
368 "+two",
369 "",
370 ].join("\n"),
371 );
372 });
373
374 it("keeps a name holding a newline on the one header line it belongs to", () => {
375 const patch = azureDevOpsFilePatch({
376 change: change({ path: "line\nfile.txt", oldPath: "line\nfile.txt" }),
377 texts: texts("one\n", "two\n"),
378 });
379
380 // Written as itself the name would start a line of its own, and a reader would take what
381 // followed for a header the patch never had.
382 expect(patch.section.split("\n").slice(0, 3)).toEqual([
383 'diff --git "a/line\\nfile.txt" "b/line\\nfile.txt"',
384 '--- "a/line\\nfile.txt"',
385 '+++ "b/line\\nfile.txt"',
386 ]);
387 });
388
389 it("quotes the names a rename states, which carry no side letter", () => {
390 const patch = azureDevOpsFilePatch({
391 change: change({
392 path: "docs/new\tname.md",
393 oldPath: "docs/old\tname.md",
394 changeKind: "rename-pure",
395 }),
396 texts: texts("same\n", "same\n"),
397 });
398
399 expect(patch.section).toBe(
400 [
401 'diff --git "a/docs/old\\tname.md" "b/docs/new\\tname.md"',
402 'rename from "docs/old\\tname.md"',
403 'rename to "docs/new\\tname.md"',
404 '--- "a/docs/old\\tname.md"',
405 '+++ "b/docs/new\\tname.md"',
406 "",
407 ].join("\n"),
408 );
409 });
410
411 it("quotes the sides of the one line a binary file gets", () => {
412 const patch = azureDevOpsFilePatch({
413 change: change({ path: "logo\tmark.png", oldPath: "logo\tmark.png" }),
414 texts: texts("PNG\u0000old", "PNG\u0000new"),
415 });
416
417 expect(patch.section).toContain(
418 'Binary files "a/logo\\tmark.png" and "b/logo\\tmark.png" differ',
419 );
420 });
421
422 it("hands a reader of the header back the name Azure gave", () => {
423 const path = 'every\t\n"kind"\\of.md';
424 const patch = azureDevOpsFilePatch({
425 change: change({ path, oldPath: path }),
426 texts: texts("one\n", "two\n"),
427 });
428 const [header, oldLine, newLine] = patch.section.split("\n");
429
430 expect(header).not.toContain("\t");
431 expect(unquoteGitPatchPath(oldLine?.slice(4) ?? "")).toBe(`a/${path}`);
432 expect(unquoteGitPatchPath(newLine?.slice(4) ?? "")).toBe(`b/${path}`);
433 });
434});
435
436describe("a diff cursor", () => {
437 it("carries the push it was taken against back to the next slice", () => {
438 const cursor = formatAzureDevOpsDiffCursor({ iterationId: 3, fileIndex: 12 });
439
440 expect(parseAzureDevOpsDiffCursor(cursor)).toEqual({ iterationId: 3, fileIndex: 12 });
441 });
442
443 it("reads anything it did not write as no position at all", () => {
444 // Which starts the read from the top rather than failing it: a cursor is the client's to
445 // hand back, and nothing downstream is worth refusing a whole diff over.
446 for (const raw of [undefined, null, "", "abc", "1", "0:4", "1:-2", "1:2:3"]) {
447 expect(parseAzureDevOpsDiffCursor(raw)).toBeNull();
448 }
449 });
450
451 it("refuses a half it did not write rather than reading it as the first file", () => {
452 // `Number` is wider than the cursor: an empty, padded or hex half would otherwise pass as a
453 // position, and the read would resume against an iteration the client never saw instead of
454 // starting again from the latest one.
455 for (const raw of ["1:", ":4", "1: ", " 1:4", "1:0x2", "0x1:2", "1e2:0", "1:4.0"]) {
456 expect(parseAzureDevOpsDiffCursor(raw)).toBeNull();
457 }
458 });
459});