Public: anyone can read this; the crew can change it.
test/diff-edge-cases.test.ts runs in under 10 s, every test under 1 s, proving what it proves today #10256
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10241-test-diff-edge-cases-test-ts-runs-in-und → main 2 commits fork
1 file
changed +173
−215
test/diff-edge-cases.test.ts +173 −215
| @@ -83,12 +83,110 @@ describe("diff engine + resource edge cases", () => { | ||
| 83 | 83 | const remote = `${base.replace("http://", `http://ada:${token}@`)}/ada/engine.git`; |
| 84 | 84 | |
| 85 | 85 | await shOk(["git", "clone", remote, "engine"], work); |
| 86 | await Bun.write(join(dir(), "README.md"), "# init\n"); | |
| 87 | await shOk(["git", "add", "."], dir()); | |
| 88 | await shOk(["git", "commit", "-m", "init"], dir()); | |
| 86 | await buildHistory(); | |
| 87 | // One push carries every scenario's commits: the server sees the same | |
| 88 | // objects it would from one push per test, at a fraction of the cost. | |
| 89 | 89 | await shOk(["git", "push", "origin", "main"], dir()); |
| 90 | 90 | }); |
| 91 | 91 | |
| 92 | // Each scenario's commit, built once locally in beforeAll and pushed in one | |
| 93 | // go; tests only read the server's rendering of them. | |
| 94 | const oid: Record<string, string> = {}; | |
| 95 | ||
| 96 | async function commit(key: string, message: string): Promise<void> { | |
| 97 | await shOk(["git", "add", "-A"], dir()); | |
| 98 | await shOk(["git", "commit", "-m", message], dir()); | |
| 99 | oid[key] = await shOk(["git", "rev-parse", "HEAD"], dir()); | |
| 100 | } | |
| 101 | ||
| 102 | // Sized just past the caps in src/web/diff-view.ts (MAX_FILE_LINES 5000, | |
| 103 | // MAX_FILE_BYTES 100_000): far enough over to trip each cap, small enough | |
| 104 | // that git and the server move them in milliseconds. | |
| 105 | const HUGE_LINES = 6_000; | |
| 106 | const GIANT_LINE = 60_000; | |
| 107 | ||
| 108 | async function buildHistory(): Promise<void> { | |
| 109 | await Bun.write(join(dir(), "README.md"), "# init\n"); | |
| 110 | await commit("root", "init"); | |
| 111 | ||
| 112 | const bytes = new Uint8Array(2000); | |
| 113 | for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 37 + 5) % 256; | |
| 114 | await Bun.write(join(dir(), "blob.bin"), bytes); | |
| 115 | await Bun.write(join(dir(), "text.txt"), "hello\nworld\n"); | |
| 116 | await commit("binary", "add binary + text"); | |
| 117 | bytes.reverse(); | |
| 118 | await Bun.write(join(dir(), "blob.bin"), bytes); | |
| 119 | await Bun.write(join(dir(), "text.txt"), "hello\nworld\nmodified\n"); | |
| 120 | await commit("binaryModify", "modify binary + text"); | |
| 121 | await shOk(["git", "rm", "-q", "blob.bin"], dir()); | |
| 122 | await Bun.write(join(dir(), "text.txt"), "hello\nworld\nmodified\ndeleted\n"); | |
| 123 | await commit("binaryDelete", "delete binary, modify text"); | |
| 124 | ||
| 125 | await Bun.write( | |
| 126 | join(dir(), "renme-src.txt"), | |
| 127 | Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n") + "\n", | |
| 128 | ); | |
| 129 | await commit("renameBase", "add renme-src"); | |
| 130 | await shOk(["git", "mv", "renme-src.txt", "renme-dst.txt"], dir()); | |
| 131 | const renamed = Array.from({ length: 20 }, (_, i) => | |
| 132 | i % 2 === 0 ? `line ${i}` : `CHANGED ${i}`, | |
| 133 | ); | |
| 134 | await Bun.write(join(dir(), "renme-dst.txt"), renamed.join("\n") + "\n"); | |
| 135 | await commit("renamePartial", "rename+modify renme"); | |
| 136 | await shOk(["git", "mv", "renme-dst.txt", "renme-final.txt"], dir()); | |
| 137 | await commit("renamePure", "pure rename"); | |
| 138 | ||
| 139 | await Bun.write(join(dir(), "文件名.txt"), "chinese filename content\n"); | |
| 140 | await commit("unicode", "unicode filename"); | |
| 141 | ||
| 142 | await Bun.write(join(dir(), "nonl.txt"), "line1\nline2\nline3"); | |
| 143 | await commit("nonlAdd", "no trailing newline"); | |
| 144 | await Bun.write(join(dir(), "nonl.txt"), "line1\nline2\nline3\nline4"); | |
| 145 | await commit("nonlModify", "modify no trailing newline"); | |
| 146 | ||
| 147 | await shOk(["git", "checkout", "-b", "feature"], dir()); | |
| 148 | await Bun.write(join(dir(), "feature.txt"), "feature content\n"); | |
| 149 | await commit("feature", "feature commit"); | |
| 150 | await shOk(["git", "checkout", "main"], dir()); | |
| 151 | await Bun.write(join(dir(), "mainline.txt"), "mainline content\n"); | |
| 152 | await commit("mainline", "mainline commit"); | |
| 153 | await shOk(["git", "merge", "--no-ff", "feature", "-m", "merge feature"], dir()); | |
| 154 | oid.merge = await shOk(["git", "rev-parse", "HEAD"], dir()); | |
| 155 | ||
| 156 | await Bun.write(join(dir(), "chmod-me.sh"), "#!/bin/sh\necho hi\n"); | |
| 157 | await commit("chmodBase", "add chmod-me"); | |
| 158 | await shOk(["chmod", "+x", join(dir(), "chmod-me.sh")], dir()); | |
| 159 | await commit("chmod", "chmod only"); | |
| 160 | ||
| 161 | const lines: string[] = []; | |
| 162 | for (let i = 1; i <= 100; i++) lines.push(`orig line ${i}`); | |
| 163 | await Bun.write(join(dir(), "multihunk.txt"), lines.join("\n") + "\n"); | |
| 164 | await commit("multihunkBase", "add multihunk base"); | |
| 165 | const lines2 = [...lines]; | |
| 166 | lines2[4] = "CHANGED near top"; // old/new line 5 | |
| 167 | lines2[94] = "CHANGED near bottom"; // old/new line 95 | |
| 168 | await Bun.write(join(dir(), "multihunk.txt"), lines2.join("\n") + "\n"); | |
| 169 | await commit("multihunk", "modify multihunk two spots"); | |
| 170 | ||
| 171 | await Promise.all( | |
| 172 | Array.from({ length: 300 }, (_, i) => | |
| 173 | Bun.write(join(dir(), `many/file-${i}.txt`), `content ${i}\n`), | |
| 174 | ), | |
| 175 | ); | |
| 176 | await commit("many", "touch 300 files"); | |
| 177 | ||
| 178 | // Short lines, so only the line-count cap can fire: HUGE_LINES lines of | |
| 179 | // at most 5 characters stay well under MAX_FILE_BYTES and MAX_LINE_WIDTH. | |
| 180 | const big = Array.from({ length: HUGE_LINES }, (_, i) => `L${i}`); | |
| 181 | await Bun.write(join(dir(), "huge.txt"), big.join("\n") + "\n"); | |
| 182 | await commit("huge", `add huge file ${HUGE_LINES} lines`); | |
| 183 | ||
| 184 | await Bun.write(join(dir(), "hugeline.txt"), "x".repeat(GIANT_LINE) + "\n"); | |
| 185 | await commit("hugelineBase", "add file with one giant line"); | |
| 186 | await Bun.write(join(dir(), "hugeline.txt"), "y".repeat(GIANT_LINE) + "\n"); | |
| 187 | await commit("hugeline", "modify the giant line"); | |
| 188 | } | |
| 189 | ||
| 92 | 190 | afterAll(async () => { |
| 93 | 191 | servers.stop(); |
| 94 | 192 | app.close(); |
| @@ -96,56 +194,39 @@ describe("diff engine + resource edge cases", () => { | ||
| 96 | 194 | await rm(work, { recursive: true, force: true }); |
| 97 | 195 | }); |
| 98 | 196 | |
| 99 | test("binary add/modify/delete does not corrupt subsequent file parsing", async () => { | |
| 100 | const bytes = new Uint8Array(2000); | |
| 101 | for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 37 + 5) % 256; | |
| 102 | await Bun.write(join(dir(), "blob.bin"), bytes); | |
| 103 | await Bun.write(join(dir(), "text.txt"), "hello\nworld\n"); | |
| 104 | await shOk(["git", "add", "."], dir()); | |
| 105 | await shOk(["git", "commit", "-m", "add binary + text"], dir()); | |
| 106 | await shOk(["git", "push", "origin", "main"], dir()); | |
| 107 | const oid = await shOk(["git", "rev-parse", "HEAD"], dir()); | |
| 197 | const page = async (key: string): Promise<Response> => | |
| 198 | fetch(`${base}/ada/engine/commit/${oid[key]}`); | |
| 108 | 199 | |
| 109 | const page = await (await fetch(`${base}/ada/engine/commit/${oid}`)).text(); | |
| 110 | expect(page).toContain("Binary file not shown"); | |
| 111 | expect(page).toContain("blob.bin"); | |
| 112 | // The text file after the binary section in the same diff must still parse. | |
| 113 | expect(page).toContain("hello"); | |
| 200 | test("binary add/modify/delete does not corrupt subsequent file parsing", async () => { | |
| 201 | // Invariant: a binary section in a diff is stubbed and the text file after it still parses. | |
| 202 | // blob.bin sorts before text.txt, so the text section follows the binary one. | |
| 203 | const cases: Array<[string, string]> = [ | |
| 204 | ["binary", "hello"], | |
| 205 | ["binaryModify", "modified"], | |
| 206 | ["binaryDelete", "deleted"], | |
| 207 | ]; | |
| 208 | for (const [key, textLine] of cases) { | |
| 209 | const text = await (await page(key)).text(); | |
| 210 | expect(text).toContain("Binary file not shown"); | |
| 211 | expect(text).toContain("blob.bin"); | |
| 212 | expect(text).toContain("text.txt"); | |
| 213 | expect(text).toContain(textLine); | |
| 214 | } | |
| 114 | 215 | }); |
| 115 | 216 | |
| 116 | 217 | test("rename with partial similarity keeps old/new path and hunk content", async () => { |
| 117 | await Bun.write( | |
| 118 | join(dir(), "renme-src.txt"), | |
| 119 | Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n") + "\n", | |
| 120 | ); | |
| 121 | await shOk(["git", "add", "."], dir()); | |
| 122 | await shOk(["git", "commit", "-m", "add renme-src"], dir()); | |
| 123 | await shOk(["git", "push", "origin", "main"], dir()); | |
| 124 | ||
| 125 | await shOk(["git", "mv", "renme-src.txt", "renme-dst.txt"], dir()); | |
| 126 | const lines = Array.from({ length: 20 }, (_, i) => | |
| 127 | i % 2 === 0 ? `line ${i}` : `CHANGED ${i}`, | |
| 128 | ); | |
| 129 | await Bun.write(join(dir(), "renme-dst.txt"), lines.join("\n") + "\n"); | |
| 130 | await shOk(["git", "add", "."], dir()); | |
| 131 | await shOk(["git", "commit", "-m", "rename+modify renme"], dir()); | |
| 132 | await shOk(["git", "push", "origin", "main"], dir()); | |
| 133 | const oid = await shOk(["git", "rev-parse", "HEAD"], dir()); | |
| 134 | ||
| 135 | const page = await (await fetch(`${base}/ada/engine/commit/${oid}`)).text(); | |
| 136 | expect(page).toContain("renme-src.txt"); | |
| 137 | expect(page).toContain("renme-dst.txt"); | |
| 138 | expect(page).toContain("CHANGED"); | |
| 218 | // Invariant: a rename-with-edits shows both paths and its hunk. | |
| 219 | const text = await (await page("renamePartial")).text(); | |
| 220 | expect(text).toContain("renme-src.txt"); | |
| 221 | expect(text).toContain("renme-dst.txt"); | |
| 222 | expect(text).toContain("CHANGED"); | |
| 139 | 223 | }); |
| 140 | 224 | |
| 141 | 225 | test("pure rename (100% similarity) produces no hunks but shows both paths", async () => { |
| 142 | await shOk(["git", "mv", "renme-dst.txt", "renme-final.txt"], dir()); | |
| 143 | await shOk(["git", "commit", "-m", "pure rename"], dir()); | |
| 144 | await shOk(["git", "push", "origin", "main"], dir()); | |
| 145 | const oid = await shOk(["git", "rev-parse", "HEAD"], dir()); | |
| 146 | const page = await (await fetch(`${base}/ada/engine/commit/${oid}`)).text(); | |
| 147 | expect(page).toContain("renme-dst.txt"); | |
| 148 | expect(page).toContain("renme-final.txt"); | |
| 226 | // Invariant: a hunkless pure rename still names old and new path. | |
| 227 | const text = await (await page("renamePure")).text(); | |
| 228 | expect(text).toContain("renme-dst.txt"); | |
| 229 | expect(text).toContain("renme-final.txt"); | |
| 149 | 230 | }); |
| 150 | 231 | |
| 151 | 232 | // git quotes non-ASCII paths with C-style octal byte escapes |
| @@ -153,227 +234,104 @@ describe("diff engine + resource edge cases", () => { | ||
| 153 | 234 | // decodes them as UTF-8, so the real name survives — in the displayed path |
| 154 | 235 | // and in the oldPath/newPath the highlighter keys blob lookups on. |
| 155 | 236 | test("unicode filenames survive as real UTF-8", async () => { |
| 156 | await Bun.write(join(dir(), "文件名.txt"), "chinese filename content\n"); | |
| 157 | await shOk(["git", "add", "."], dir()); | |
| 158 | await shOk(["git", "commit", "-m", "unicode filename"], dir()); | |
| 159 | await shOk(["git", "push", "origin", "main"], dir()); | |
| 160 | const oid = await shOk(["git", "rev-parse", "HEAD"], dir()); | |
| 161 | ||
| 162 | const page = await (await fetch(`${base}/ada/engine/commit/${oid}`)).text(); | |
| 163 | expect(page).toContain("chinese filename content"); | |
| 164 | expect(page).toContain("文件名.txt"); // the real name, not mojibake | |
| 237 | // Invariant: git's octal-quoted path decodes to the real UTF-8 name. | |
| 238 | const text = await (await page("unicode")).text(); | |
| 239 | expect(text).toContain("chinese filename content"); | |
| 240 | expect(text).toContain("文件名.txt"); // the real name, not mojibake | |
| 165 | 241 | }); |
| 166 | 242 | |
| 167 | 243 | test("no-newline-at-eof marker on add and modify", async () => { |
| 168 | await Bun.write(join(dir(), "nonl.txt"), "line1\nline2\nline3"); | |
| 169 | await shOk(["git", "add", "."], dir()); | |
| 170 | await shOk(["git", "commit", "-m", "no trailing newline"], dir()); | |
| 171 | await shOk(["git", "push", "origin", "main"], dir()); | |
| 172 | const oid1 = await shOk(["git", "rev-parse", "HEAD"], dir()); | |
| 173 | const page1 = await ( | |
| 174 | await fetch(`${base}/ada/engine/commit/${oid1}`) | |
| 175 | ).text(); | |
| 176 | expect(page1).toContain("nonl"); | |
| 244 | // Invariant: "\ No newline at end of file" neither crashes nor swallows the next line. | |
| 245 | const page1 = await (await page("nonlAdd")).text(); | |
| 177 | 246 | expect(page1).toContain("nonl"); // renders without crashing |
| 178 | ||
| 179 | await Bun.write(join(dir(), "nonl.txt"), "line1\nline2\nline3\nline4"); | |
| 180 | await shOk(["git", "add", "."], dir()); | |
| 181 | await shOk(["git", "commit", "-m", "modify no trailing newline"], dir()); | |
| 182 | await shOk(["git", "push", "origin", "main"], dir()); | |
| 183 | const oid2 = await shOk(["git", "rev-parse", "HEAD"], dir()); | |
| 184 | const resp2 = await fetch(`${base}/ada/engine/commit/${oid2}`); | |
| 247 | const resp2 = await page("nonlModify"); | |
| 185 | 248 | expect(resp2.status).toBe(200); |
| 186 | const page2 = await resp2.text(); | |
| 187 | expect(page2).toContain("line4"); | |
| 249 | expect(await resp2.text()).toContain("line4"); | |
| 188 | 250 | }); |
| 189 | 251 | |
| 190 | 252 | test("root commit (no parent) diffs cleanly against the empty tree", async () => { |
| 191 | const rootOid = ( | |
| 192 | await shOk(["git", "rev-list", "--max-parents=0", "HEAD"], dir()) | |
| 193 | ).split("\n")[0]!; | |
| 194 | const resp = await fetch(`${base}/ada/engine/commit/${rootOid}`); | |
| 253 | // Invariant: a parentless commit diffs against the empty tree. | |
| 254 | const resp = await page("root"); | |
| 195 | 255 | expect(resp.status).toBe(200); |
| 196 | const page = await resp.text(); | |
| 197 | expect(page).toContain("init"); | |
| 256 | expect(await resp.text()).toContain("init"); | |
| 198 | 257 | }); |
| 199 | 258 | |
| 200 | 259 | test("merge commit does not crash and returns 200", async () => { |
| 201 | await shOk(["git", "checkout", "-b", "feature"], dir()); | |
| 202 | await Bun.write(join(dir(), "feature.txt"), "feature content\n"); | |
| 203 | await shOk(["git", "add", "."], dir()); | |
| 204 | await shOk(["git", "commit", "-m", "feature commit"], dir()); | |
| 205 | await shOk(["git", "checkout", "main"], dir()); | |
| 206 | await Bun.write(join(dir(), "mainline.txt"), "mainline content\n"); | |
| 207 | await shOk(["git", "add", "."], dir()); | |
| 208 | await shOk(["git", "commit", "-m", "mainline commit"], dir()); | |
| 209 | await shOk( | |
| 210 | ["git", "merge", "--no-ff", "feature", "-m", "merge feature"], | |
| 211 | dir(), | |
| 212 | ); | |
| 213 | await shOk(["git", "push", "origin", "main"], dir()); | |
| 214 | const oid = await shOk(["git", "rev-parse", "HEAD"], dir()); | |
| 215 | const resp = await fetch(`${base}/ada/engine/commit/${oid}`); | |
| 260 | // Invariant: a two-parent commit renders. | |
| 261 | const resp = await page("merge"); | |
| 216 | 262 | expect(resp.status).toBe(200); |
| 217 | 263 | }); |
| 218 | 264 | |
| 219 | 265 | test("mode-only change (chmod +x) with no content diff renders cleanly", async () => { |
| 220 | await Bun.write(join(dir(), "chmod-me.sh"), "#!/bin/sh\necho hi\n"); | |
| 221 | await shOk(["git", "add", "."], dir()); | |
| 222 | await shOk(["git", "commit", "-m", "add chmod-me"], dir()); | |
| 223 | await shOk(["git", "push", "origin", "main"], dir()); | |
| 224 | await shOk(["chmod", "+x", join(dir(), "chmod-me.sh")], dir()); | |
| 225 | await shOk(["git", "add", "."], dir()); | |
| 226 | await shOk(["git", "commit", "-m", "chmod only"], dir()); | |
| 227 | await shOk(["git", "push", "origin", "main"], dir()); | |
| 228 | const oid = await shOk(["git", "rev-parse", "HEAD"], dir()); | |
| 229 | const resp = await fetch(`${base}/ada/engine/commit/${oid}`); | |
| 266 | // Invariant: a mode change with no hunks says so instead of rendering an empty table. | |
| 267 | const resp = await page("chmod"); | |
| 230 | 268 | expect(resp.status).toBe(200); |
| 231 | const page = await resp.text(); | |
| 232 | expect(page).toContain("No content changes"); | |
| 269 | expect(await resp.text()).toContain("No content changes"); | |
| 233 | 270 | }); |
| 234 | 271 | |
| 235 | 272 | test("multi-hunk file: second hunk's line numbers are exactly correct (no off-by-one)", async () => { |
| 236 | const lines: string[] = []; | |
| 237 | for (let i = 1; i <= 100; i++) lines.push(`orig line ${i}`); | |
| 238 | await Bun.write(join(dir(), "multihunk.txt"), lines.join("\n") + "\n"); | |
| 239 | await shOk(["git", "add", "."], dir()); | |
| 240 | await shOk(["git", "commit", "-m", "add multihunk base"], dir()); | |
| 241 | await shOk(["git", "push", "origin", "main"], dir()); | |
| 242 | ||
| 243 | const lines2 = [...lines]; | |
| 244 | lines2[4] = "CHANGED near top"; // old/new line 5 | |
| 245 | lines2[94] = "CHANGED near bottom"; // old/new line 95 | |
| 246 | await Bun.write(join(dir(), "multihunk.txt"), lines2.join("\n") + "\n"); | |
| 247 | await shOk(["git", "add", "."], dir()); | |
| 248 | await shOk(["git", "commit", "-m", "modify multihunk two spots"], dir()); | |
| 249 | await shOk(["git", "push", "origin", "main"], dir()); | |
| 250 | const oid = await shOk(["git", "rev-parse", "HEAD"], dir()); | |
| 251 | ||
| 252 | const page = await (await fetch(`${base}/ada/engine/commit/${oid}`)).text(); | |
| 273 | // Invariant: each hunk's rows carry the line numbers from its own header. | |
| 274 | const text = await (await page("multihunk")).text(); | |
| 253 | 275 | // Word-diff highlighting splits row text into per-token <span>s, so we |
| 254 | 276 | // can't search for the literal phrase; instead find each token and |
| 255 | 277 | // check the nearest preceding `<td class="dn">N</td>` line-number cell. |
| 256 | 278 | function lineNumberBefore(token: string): string { |
| 257 | const idx = page.indexOf(token); | |
| 279 | const idx = text.indexOf(token); | |
| 258 | 280 | expect(idx).toBeGreaterThan(-1); |
| 259 | const before = page.slice(Math.max(0, idx - 600), idx); | |
| 281 | const before = text.slice(Math.max(0, idx - 600), idx); | |
| 260 | 282 | const matches = [...before.matchAll(/<td class="dn">(\d+)<\/td>/g)]; |
| 261 | 283 | return matches[matches.length - 1]![1]!; |
| 262 | 284 | } |
| 263 | 285 | expect(lineNumberBefore(">CHANGED<")).toBeTruthy(); |
| 264 | 286 | // First hunk: the add row for "CHANGED near top" is new-line 5, in the |
| 265 | 287 | // second <td class="dn"> column (old is empty for an add row). |
| 266 | const topIdx = page.indexOf("CHANGED"); | |
| 267 | const topSnippet = page.slice(Math.max(0, topIdx - 500), topIdx); | |
| 288 | const topIdx = text.indexOf("CHANGED"); | |
| 289 | const topSnippet = text.slice(Math.max(0, topIdx - 500), topIdx); | |
| 268 | 290 | expect(topSnippet).toContain('<td class="dn">5</td>'); |
| 269 | const bottomIdx = page.lastIndexOf("CHANGED"); | |
| 270 | const bottomSnippet = page.slice(Math.max(0, bottomIdx - 500), bottomIdx); | |
| 291 | const bottomIdx = text.lastIndexOf("CHANGED"); | |
| 292 | const bottomSnippet = text.slice(Math.max(0, bottomIdx - 500), bottomIdx); | |
| 271 | 293 | expect(bottomSnippet).toContain('<td class="dn">95</td>'); |
| 272 | 294 | }); |
| 273 | 295 | |
| 274 | 296 | // ---- Resource / DoS ---- |
| 275 | 297 | |
| 276 | // BUG (confirmed, HIGH severity): diffSyntax() in src/web/diff-syntax.ts | |
| 277 | // loops over every file in the diff sequentially (`for (const file of | |
| 278 | // diff.files)`) and, per file, calls blobForRender() which spawns a fresh | |
| 279 | // `git cat-file` subprocess (up to 3 spawns per side: -t, -s, blob) rather | |
| 280 | // than using the batched RepoReader. MAX_CHANGED (5000) bounds total | |
| 281 | // changed *lines* but not the *number of files* in a commit. A commit | |
| 282 | // touching many trivial files is therefore linear in file count at | |
| 283 | // roughly 17-18ms/file with no concurrency, no cap, and no timeout | |
| 284 | // shorter than the diff's own 60s spawn timeout. | |
| 285 | // | |
| 286 | // Measured on this machine (outside the regression run, see report): | |
| 287 | // 200 files -> 3.47s | |
| 288 | // 1000 files -> 18.2s | |
| 289 | // 5200 files -> 95.2s (commit page request) | |
| 290 | // A forge that should feel interactive (per docs/ARCHITECTURE.md) turns a | |
| 291 | // single push into a 95-second synchronous request. | |
| 292 | test("DOS: file count is NOT capped independent of changed-line count (perf regression sentinel)", async () => { | |
| 293 | const nFiles = 300; | |
| 294 | for (let i = 0; i < nFiles; i++) { | |
| 295 | await Bun.write(join(dir(), `many/file-${i}.txt`), `content ${i}\n`); | |
| 296 | } | |
| 297 | await shOk(["git", "add", "."], dir()); | |
| 298 | await shOk(["git", "commit", "-m", `touch ${nFiles} files`], dir()); | |
| 299 | await shOk(["git", "push", "origin", "main"], dir()); | |
| 300 | const oid = await shOk(["git", "rev-parse", "HEAD"], dir()); | |
| 301 | ||
| 302 | const t0 = performance.now(); | |
| 303 | const resp = await fetch(`${base}/ada/engine/commit/${oid}`); | |
| 298 | // A commit touching many trivial files once cost a `git cat-file` spawn per | |
| 299 | // file in diffSyntax(); MAX_DIFF_FILES (100) in diff-view.ts now bounds the | |
| 300 | // files rendered and highlighted, and says how many it left out. | |
| 301 | test("MAX_DIFF_FILES: a 300-file commit page answers 200 rendering 100 files and naming the rest", async () => { | |
| 302 | // Invariant: past MAX_DIFF_FILES the page renders exactly that many files and counts the hidden ones. | |
| 303 | const resp = await page("many"); | |
| 304 | 304 | const text = await resp.text(); |
| 305 | const elapsed = performance.now() - t0; | |
| 306 | console.log( | |
| 307 | `[perf] ${nFiles}-file commit diff took ${elapsed.toFixed(0)}ms, ${text.length} bytes`, | |
| 308 | ); | |
| 309 | 305 | expect(resp.status).toBe(200); |
| 310 | // No clock is asserted: a bound in milliseconds measures the machine | |
| 311 | // (currently ~17ms/file with no cap). The sentinel is this test's own | |
| 312 | // timeout, far above what an interactive forge should take, and it is | |
| 313 | // expected to keep passing until the file count is capped like the line | |
| 314 | // count already is. | |
| 315 | }, 90_000); | |
| 306 | const shown = new Set(text.match(/many\/file-\d+\.txt/g) ?? []); | |
| 307 | expect(shown.size).toBe(100); | |
| 308 | expect(text).toContain("200 more files not"); | |
| 309 | }); | |
| 316 | 310 | |
| 317 | test("MAX_FILE_LINES caps a single huge file (200k+ changed lines) instead of rendering it", async () => { | |
| 318 | const bigLines: string[] = []; | |
| 319 | for (let i = 0; i < 210_000; i++) | |
| 320 | bigLines.push(`line number ${i} of a huge file`); | |
| 321 | await Bun.write(join(dir(), "huge.txt"), bigLines.join("\n") + "\n"); | |
| 322 | await shOk(["git", "add", "."], dir()); | |
| 323 | await shOk(["git", "commit", "-m", "add huge file 210k lines"], dir()); | |
| 324 | await shOk(["git", "push", "origin", "main"], dir()); | |
| 325 | const oid = await shOk(["git", "rev-parse", "HEAD"], dir()); | |
| 326 | ||
| 327 | const t0 = performance.now(); | |
| 328 | const resp = await fetch(`${base}/ada/engine/commit/${oid}`); | |
| 311 | test("MAX_FILE_LINES caps a single huge file instead of rendering it", async () => { | |
| 312 | // Invariant: a file over MAX_FILE_LINES changed lines is a stub naming its count, none of its lines on the wire. | |
| 313 | const resp = await page("huge"); | |
| 329 | 314 | const text = await resp.text(); |
| 330 | const elapsed = performance.now() - t0; | |
| 331 | console.log( | |
| 332 | `[perf] 210k-line file diff took ${elapsed.toFixed(0)}ms, ${text.length} bytes`, | |
| 333 | ); | |
| 334 | 315 | expect(resp.status).toBe(200); |
| 335 | 316 | expect(text).toContain("huge.txt"); |
| 336 | 317 | expect(text).toContain("Diff not shown"); |
| 337 | expect(text).toContain("210,000 changed lines"); | |
| 338 | // The cap is what keeps this from being a resource sink, and the cap is | |
| 339 | // what is checked: none of the file's lines are on the wire, and the | |
| 340 | // page is small. How long it took is logged, not asserted. | |
| 341 | expect(text).not.toContain("line number 209999 of a huge file"); | |
| 318 | expect(text).toContain(`${HUGE_LINES.toLocaleString()} changed lines`); | |
| 319 | expect(text).not.toContain(`>L${HUGE_LINES - 1}<`); | |
| 342 | 320 | expect(text.length).toBeLessThan(500_000); |
| 343 | }, 60_000); | |
| 321 | }); | |
| 344 | 322 | |
| 345 | test("CAP_BYTES bounds a file that is few lines but huge in bytes (single giant line)", async () => { | |
| 346 | const hugeLine1 = "x".repeat(2_000_000); | |
| 347 | await Bun.write(join(dir(), "hugeline.txt"), hugeLine1 + "\n"); | |
| 348 | await shOk(["git", "add", "."], dir()); | |
| 349 | await shOk(["git", "commit", "-m", "add file with one 2MB line"], dir()); | |
| 350 | await shOk(["git", "push", "origin", "main"], dir()); | |
| 351 | ||
| 352 | const hugeLine2 = "y".repeat(2_000_000); | |
| 353 | await Bun.write(join(dir(), "hugeline.txt"), hugeLine2 + "\n"); | |
| 354 | await shOk(["git", "add", "."], dir()); | |
| 355 | await shOk(["git", "commit", "-m", "modify the 2MB line"], dir()); | |
| 356 | await shOk(["git", "push", "origin", "main"], dir()); | |
| 357 | const oid = await shOk(["git", "rev-parse", "HEAD"], dir()); | |
| 358 | ||
| 359 | const t0 = performance.now(); | |
| 360 | const resp = await fetch(`${base}/ada/engine/commit/${oid}`); | |
| 323 | test("MAX_FILE_BYTES bounds a file that is few lines but huge in bytes (single giant line)", async () => { | |
| 324 | // Invariant: a one-line change over MAX_FILE_BYTES of diff is a stub, and neither giant line reaches the page. | |
| 325 | const resp = await page("hugeline"); | |
| 361 | 326 | const text = await resp.text(); |
| 362 | const elapsed = performance.now() - t0; | |
| 363 | console.log( | |
| 364 | `[perf] 2MB-single-line modify diff took ${elapsed.toFixed(0)}ms, ${text.length} bytes`, | |
| 365 | ); | |
| 366 | 327 | expect(resp.status).toBe(200); |
| 367 | 328 | expect(text).toContain("hugeline.txt"); |
| 368 | 329 | expect(text).toContain("Diff not shown"); |
| 369 | 330 | expect(text).toContain("bytes of diff"); |
| 370 | // The byte ceiling this issue asks for: a 4MB-of-diff single line must | |
| 371 | // not put anywhere near that on the wire, and must not creep back up. | |
| 372 | 331 | expect(text.length).toBeLessThan(500_000); |
| 373 | // MAX_FILE_BYTES/MAX_LINE_WIDTH in diff-view.ts stub the file before it | |
| 374 | // is ever tokenized or rendered as rows, though the changed-line count | |
| 375 | // (1) is far under MAX_FILE_LINES: neither giant line reached the page. | |
| 332 | // The changed-line count (1) is far under MAX_FILE_LINES: only the byte | |
| 333 | // bound keeps either giant line off the page. | |
| 376 | 334 | expect(text).not.toContain("x".repeat(1_000)); |
| 377 | 335 | expect(text).not.toContain("y".repeat(1_000)); |
| 378 | }, 60_000); | |
| 336 | }); | |
| 379 | 337 | }); |