| 1 | // Run: tsx src/__tests__/diff-rendering.test.ts |
| 2 | |
| 3 | import { readFileSync } from "node:fs"; |
| 4 | import { dirname, resolve } from "node:path"; |
| 5 | import { fileURLToPath } from "node:url"; |
| 6 | import { diffLines, diffRowsFromUnifiedDiff } from "../lib/diff"; |
| 7 | import { summarize } from "../lib/tools"; |
| 8 | import { initialState, reducer } from "../lib/useController"; |
| 9 | import type { Item } from "../lib/useController"; |
| 10 | |
| 11 | const testDir = dirname(fileURLToPath(import.meta.url)); |
| 12 | const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); |
| 13 | |
| 14 | let passed = 0; |
| 15 | let failed = 0; |
| 16 | |
| 17 | function ok(value: unknown, label: string) { |
| 18 | if (value) { |
| 19 | process.stdout.write(` PASS ${label}\n`); |
| 20 | passed += 1; |
| 21 | } else { |
| 22 | process.stdout.write(` FAIL ${label}\n`); |
| 23 | failed += 1; |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | function eq(a: unknown, b: unknown, label: string) { |
| 28 | if (a === b) { |
| 29 | process.stdout.write(` PASS ${label}\n`); |
| 30 | passed += 1; |
| 31 | } else { |
| 32 | process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); |
| 33 | failed += 1; |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | function matchingBlocks(selector: string): string[] { |
| 38 | const blocks: string[] = []; |
| 39 | const rule = /([^{}]+)\{([^{}]*)\}/g; |
| 40 | let match: RegExpExecArray | null; |
| 41 | while ((match = rule.exec(styles)) !== null) { |
| 42 | const selectors = match[1].split(",").map((part) => part.trim()); |
| 43 | if (selectors.includes(selector)) blocks.push(match[2]); |
| 44 | } |
| 45 | return blocks; |
| 46 | } |
| 47 | |
| 48 | function finalDeclaration(selector: string, property: string): string | undefined { |
| 49 | let value: string | undefined; |
| 50 | for (const block of matchingBlocks(selector)) { |
| 51 | const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g"); |
| 52 | let match: RegExpExecArray | null; |
| 53 | while ((match = declaration.exec(block)) !== null) { |
| 54 | value = match[1].trim(); |
| 55 | } |
| 56 | } |
| 57 | return value; |
| 58 | } |
| 59 | |
| 60 | type ToolItem = Extract<Item, { kind: "tool" }>; |
| 61 | |
| 62 | function toolItems(s: typeof initialState): ToolItem[] { |
| 63 | return s.items.filter((it): it is ToolItem => it.kind === "tool"); |
| 64 | } |
| 65 | |
| 66 | console.log("\ndiff rendering contract"); |
| 67 | |
| 68 | { |
| 69 | const rows = diffLines("one\ntwo\nthree", "one\nTWO\nthree\nfour"); |
| 70 | eq(JSON.stringify(rows.map((r) => [r.type, r.oldLine ?? "", r.newLine ?? "", r.text])), JSON.stringify([ |
| 71 | ["ctx", 1, 1, "one"], |
| 72 | ["del", 2, "", "two"], |
| 73 | ["add", "", 2, "TWO"], |
| 74 | ["ctx", 3, 3, "three"], |
| 75 | ["add", "", 4, "four"], |
| 76 | ]), "diff rows carry old/new line numbers"); |
| 77 | } |
| 78 | |
| 79 | { |
| 80 | const rows = diffRowsFromUnifiedDiff([ |
| 81 | "--- a/settings/settings_IO.gd", |
| 82 | "+++ b/settings/settings_IO.gd", |
| 83 | "@@ -27,2 +27,3 @@", |
| 84 | " func keep():", |
| 85 | "-func save():", |
| 86 | "+func save_file():", |
| 87 | "+func save_backup():", |
| 88 | "", |
| 89 | ].join("\n")); |
| 90 | eq(JSON.stringify(rows.map((r) => [r.type, r.oldLine ?? "", r.newLine ?? "", r.text])), JSON.stringify([ |
| 91 | ["ctx", 27, 27, "func keep():"], |
| 92 | ["del", 28, "", "func save():"], |
| 93 | ["add", "", 28, "func save_file():"], |
| 94 | ["add", "", 29, "func save_backup():"], |
| 95 | ]), "unified diff rows preserve hunk line numbers"); |
| 96 | } |
| 97 | |
| 98 | { |
| 99 | eq(summarize("write_file", ""), "", "archived write_file without args does not synthesize 0 lines"); |
| 100 | eq(summarize("write_file", JSON.stringify({ content: "" })), "0 lines", "explicit empty write_file content still summarizes as 0 lines"); |
| 101 | } |
| 102 | |
| 103 | for (const prefix of ["diff", "inline-diff"]) { |
| 104 | eq(finalDeclaration(`.${prefix}__table`, "min-width"), "max-content", `${prefix} rows share the longest scroll width`); |
| 105 | eq(finalDeclaration(`.${prefix}__table`, "width"), "100%", `${prefix} table fills the visible viewport`); |
| 106 | eq(finalDeclaration(`.${prefix}__row`, "width"), "100%", `${prefix} row background fills table width`); |
| 107 | eq(finalDeclaration(`.${prefix}__gutter`, "position"), "sticky", `${prefix} gutter remains visible while horizontally scrolled`); |
| 108 | eq(finalDeclaration(`.${prefix}__gutter`, "left"), "0", `${prefix} sticky gutter anchors at left`); |
| 109 | } |
| 110 | |
| 111 | { |
| 112 | let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } }); |
| 113 | const fileDiff = [ |
| 114 | "--- a/settings/pages/video_settings.tscn", |
| 115 | "+++ b/settings/pages/video_settings.tscn", |
| 116 | "@@ -42,2 +42,2 @@", |
| 117 | "-layout_mode = 3", |
| 118 | "+layout_mode = 1", |
| 119 | ].join("\n"); |
| 120 | s = reducer(s, { |
| 121 | type: "event", |
| 122 | e: { |
| 123 | kind: "tool_dispatch", |
| 124 | tool: { |
| 125 | id: "write-existing", |
| 126 | name: "write_file", |
| 127 | args: JSON.stringify({ path: "settings/pages/video_settings.tscn", content: "full\nreplacement\nfile\n" }), |
| 128 | readOnly: false, |
| 129 | diff: fileDiff, |
| 130 | added: 1, |
| 131 | removed: 1, |
| 132 | } as any, |
| 133 | }, |
| 134 | }); |
| 135 | let [tool] = toolItems(s); |
| 136 | eq(tool?.summary, "+1 -1", "writer dispatch uses preview file diff summary instead of content line count"); |
| 137 | eq((tool as any)?.fileDiff?.diff, fileDiff, "writer dispatch keeps preview file diff for rendering"); |
| 138 | s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "write-existing", name: "write_file", readOnly: false, output: "wrote 22 bytes", durationMs: 12 } } }); |
| 139 | [tool] = toolItems(s); |
| 140 | eq(tool?.summary, "+1 -1", "completed writer archives with preview file diff summary"); |
| 141 | ok(tool?.dataArchived === true, "completed writer with preview diff is still archived"); |
| 142 | } |
| 143 | |
| 144 | { |
| 145 | const fileDiff = [ |
| 146 | "--- a/settings/settings_IO.gd", |
| 147 | "+++ b/settings/settings_IO.gd", |
| 148 | "@@ -27 +27 @@", |
| 149 | "-func save():", |
| 150 | "+func save_file():", |
| 151 | ].join("\n"); |
| 152 | const s = reducer(initialState, { |
| 153 | type: "history", |
| 154 | messages: [ |
| 155 | { |
| 156 | role: "assistant", |
| 157 | content: "", |
| 158 | toolCalls: [{ |
| 159 | id: "hist-edit", |
| 160 | name: "edit_file", |
| 161 | arguments: "", |
| 162 | argumentsArchived: true, |
| 163 | subject: "settings/settings_IO.gd", |
| 164 | diff: fileDiff, |
| 165 | added: 1, |
| 166 | removed: 1, |
| 167 | }], |
| 168 | }, |
| 169 | { |
| 170 | role: "tool", |
| 171 | content: "", |
| 172 | toolCallId: "hist-edit", |
| 173 | toolName: "edit_file", |
| 174 | toolResultArchived: true, |
| 175 | }, |
| 176 | ] as any, |
| 177 | }); |
| 178 | const [tool] = toolItems(s); |
| 179 | eq(tool?.summary, "+1 -1", "history writer restores preview file diff summary"); |
| 180 | eq((tool as any)?.fileDiff?.diff, fileDiff, "history writer restores preview file diff body"); |
| 181 | } |
| 182 | |
| 183 | { |
| 184 | let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } }); |
| 185 | s = reducer(s, { |
| 186 | type: "event", |
| 187 | e: { |
| 188 | kind: "tool_dispatch", |
| 189 | tool: { |
| 190 | id: "edit-1", |
| 191 | name: "edit_file", |
| 192 | args: JSON.stringify({ path: "settings/settings.gd", old_string: "old\nsame", new_string: "new\nsame\nextra" }), |
| 193 | readOnly: false, |
| 194 | }, |
| 195 | }, |
| 196 | }); |
| 197 | s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "edit-1", name: "edit_file", readOnly: false, output: "edited settings/settings.gd", durationMs: 12 } } }); |
| 198 | const [tool] = toolItems(s); |
| 199 | eq(tool?.summary, "+2 -1", "completed writer keeps +N -M summary after archiving"); |
| 200 | ok(tool?.dataArchived === true, "completed writer data is still archived"); |
| 201 | eq(tool?.output, undefined, "summary does not require keeping tool output"); |
| 202 | } |
| 203 | |
| 204 | { |
| 205 | let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } }); |
| 206 | s = reducer(s, { |
| 207 | type: "event", |
| 208 | e: { |
| 209 | kind: "tool_dispatch", |
| 210 | tool: { |
| 211 | id: "edit-error", |
| 212 | name: "edit_file", |
| 213 | args: JSON.stringify({ path: "settings/settings.gd", old_string: "old", new_string: "new" }), |
| 214 | readOnly: false, |
| 215 | }, |
| 216 | }, |
| 217 | }); |
| 218 | s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "edit-error", name: "edit_file", readOnly: false, err: "old_string not found", durationMs: 12 } } }); |
| 219 | const [tool] = toolItems(s); |
| 220 | eq(tool?.status, "error", "failed writer is marked as error"); |
| 221 | eq(tool?.summary, undefined, "failed writer clears cached +N -M summary"); |
| 222 | } |
| 223 | |
| 224 | { |
| 225 | let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } }); |
| 226 | const args = JSON.stringify({ |
| 227 | path: "settings/settings.gd", |
| 228 | edits: [ |
| 229 | { old_string: "old", new_string: "new", replace_all: true }, |
| 230 | { old_string: "same", new_string: "same2" }, |
| 231 | ], |
| 232 | }); |
| 233 | s = reducer(s, { |
| 234 | type: "event", |
| 235 | e: { |
| 236 | kind: "tool_dispatch", |
| 237 | tool: { |
| 238 | id: "multi-replace-all", |
| 239 | name: "multi_edit", |
| 240 | args, |
| 241 | readOnly: false, |
| 242 | }, |
| 243 | }, |
| 244 | }); |
| 245 | let [tool] = toolItems(s); |
| 246 | eq(tool?.summary, "", "replace_all multi_edit defers summary until result"); |
| 247 | s = reducer(s, { |
| 248 | type: "event", |
| 249 | e: { |
| 250 | kind: "tool_result", |
| 251 | tool: { |
| 252 | id: "multi-replace-all", |
| 253 | name: "multi_edit", |
| 254 | readOnly: false, |
| 255 | output: "multi_edit settings/settings.gd: 2 edits applied (5 total replacements)", |
| 256 | durationMs: 12, |
| 257 | }, |
| 258 | }, |
| 259 | }); |
| 260 | [tool] = toolItems(s); |
| 261 | eq(tool?.summary, "2 edits · 5 replacements", "replace_all multi_edit uses applied replacement count"); |
| 262 | } |
| 263 | |
| 264 | { |
| 265 | let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } }); |
| 266 | s = reducer(s, { |
| 267 | type: "event", |
| 268 | e: { |
| 269 | kind: "tool_dispatch", |
| 270 | tool: { |
| 271 | id: "tool-1", |
| 272 | name: "edit_file", |
| 273 | args: JSON.stringify({ path: "settings/settings.gd", old_string: "old", new_string: "new" }), |
| 274 | readOnly: false, |
| 275 | }, |
| 276 | }, |
| 277 | }); |
| 278 | s = reducer(s, { |
| 279 | type: "event", |
| 280 | e: { |
| 281 | kind: "tool_dispatch", |
| 282 | tool: { |
| 283 | id: "tool-1", |
| 284 | name: "read_file", |
| 285 | args: JSON.stringify({ path: "settings/settings.gd" }), |
| 286 | readOnly: true, |
| 287 | }, |
| 288 | }, |
| 289 | }); |
| 290 | const [tool] = toolItems(s); |
| 291 | eq(tool?.summary, undefined, "dispatch updates clear stale writer summary"); |
| 292 | } |
| 293 | |
| 294 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 295 | if (failed > 0) process.exit(1); |
| 296 |