libretto 0.6.11 → 0.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +7 -8
  2. package/README.template.md +7 -8
  3. package/dist/cli/cli.js +0 -22
  4. package/dist/cli/commands/browser.js +18 -24
  5. package/dist/cli/commands/execution.js +254 -234
  6. package/dist/cli/commands/experiments.js +100 -0
  7. package/dist/cli/commands/setup.js +3 -310
  8. package/dist/cli/commands/shared.js +10 -0
  9. package/dist/cli/commands/snapshot.js +46 -64
  10. package/dist/cli/commands/status.js +1 -40
  11. package/dist/cli/core/browser.js +303 -124
  12. package/dist/cli/core/config.js +5 -6
  13. package/dist/cli/core/context.js +4 -0
  14. package/dist/cli/core/daemon/config.js +0 -6
  15. package/dist/cli/core/daemon/daemon.js +497 -90
  16. package/dist/cli/core/daemon/ipc.js +170 -129
  17. package/dist/cli/core/daemon/snapshot.js +48 -9
  18. package/dist/cli/core/experiments.js +39 -0
  19. package/dist/cli/core/session.js +5 -4
  20. package/dist/cli/core/skill-version.js +2 -1
  21. package/dist/cli/core/workflow-runner/runner.js +147 -0
  22. package/dist/cli/core/workflow-runtime.js +60 -0
  23. package/dist/cli/index.js +0 -2
  24. package/dist/cli/router.js +4 -3
  25. package/dist/shared/debug/pause-handler.d.ts +9 -0
  26. package/dist/shared/debug/pause-handler.js +15 -0
  27. package/dist/shared/debug/pause.d.ts +1 -2
  28. package/dist/shared/debug/pause.js +13 -36
  29. package/dist/shared/instrumentation/instrument.js +4 -4
  30. package/dist/shared/ipc/child-process-transport.d.ts +7 -0
  31. package/dist/shared/ipc/child-process-transport.js +60 -0
  32. package/dist/shared/ipc/child-process-transport.spec.d.ts +2 -0
  33. package/dist/shared/ipc/child-process-transport.spec.js +68 -0
  34. package/dist/shared/ipc/ipc.d.ts +46 -0
  35. package/dist/shared/ipc/ipc.js +165 -0
  36. package/dist/shared/ipc/ipc.spec.d.ts +2 -0
  37. package/dist/shared/ipc/ipc.spec.js +114 -0
  38. package/dist/shared/ipc/socket-transport.d.ts +9 -0
  39. package/dist/shared/ipc/socket-transport.js +143 -0
  40. package/dist/shared/ipc/socket-transport.spec.d.ts +2 -0
  41. package/dist/shared/ipc/socket-transport.spec.js +117 -0
  42. package/dist/shared/package-manager.d.ts +7 -0
  43. package/dist/shared/package-manager.js +60 -0
  44. package/dist/shared/paths/paths.d.ts +1 -8
  45. package/dist/shared/paths/paths.js +1 -49
  46. package/dist/shared/snapshot/capture-snapshot.d.ts +9 -0
  47. package/dist/shared/snapshot/capture-snapshot.js +463 -0
  48. package/dist/shared/snapshot/diff-snapshots.d.ts +72 -0
  49. package/dist/shared/snapshot/diff-snapshots.js +358 -0
  50. package/dist/shared/snapshot/render-snapshot.d.ts +39 -0
  51. package/dist/shared/snapshot/render-snapshot.js +651 -0
  52. package/dist/shared/snapshot/snapshot.spec.d.ts +2 -0
  53. package/dist/shared/snapshot/snapshot.spec.js +333 -0
  54. package/dist/shared/snapshot/types.d.ts +40 -0
  55. package/dist/shared/snapshot/types.js +0 -0
  56. package/dist/shared/snapshot/wait-for-page-stable.d.ts +17 -0
  57. package/dist/shared/snapshot/wait-for-page-stable.js +281 -0
  58. package/dist/shared/state/session-state.d.ts +1 -0
  59. package/dist/shared/state/session-state.js +1 -0
  60. package/docs/experiments.md +67 -0
  61. package/docs/releasing.md +8 -6
  62. package/package.json +5 -2
  63. package/skills/libretto/SKILL.md +19 -19
  64. package/skills/libretto/references/configuration-file-reference.md +6 -12
  65. package/skills/libretto/references/pages-and-page-targeting.md +1 -1
  66. package/skills/libretto-readonly/SKILL.md +2 -9
  67. package/src/cli/AGENTS.md +7 -0
  68. package/src/cli/cli.ts +0 -23
  69. package/src/cli/commands/browser.ts +14 -18
  70. package/src/cli/commands/execution.ts +303 -271
  71. package/src/cli/commands/experiments.ts +120 -0
  72. package/src/cli/commands/setup.ts +3 -400
  73. package/src/cli/commands/shared.ts +20 -0
  74. package/src/cli/commands/snapshot.ts +54 -94
  75. package/src/cli/commands/status.ts +1 -48
  76. package/src/cli/core/browser.ts +372 -150
  77. package/src/cli/core/config.ts +4 -5
  78. package/src/cli/core/context.ts +4 -0
  79. package/src/cli/core/daemon/config.ts +35 -19
  80. package/src/cli/core/daemon/daemon.ts +645 -107
  81. package/src/cli/core/daemon/ipc.ts +319 -214
  82. package/src/cli/core/daemon/snapshot.ts +71 -15
  83. package/src/cli/core/experiments.ts +56 -0
  84. package/src/cli/core/resolve-model.ts +5 -0
  85. package/src/cli/core/session.ts +5 -4
  86. package/src/cli/core/skill-version.ts +2 -1
  87. package/src/cli/core/workflow-runner/runner.ts +237 -0
  88. package/src/cli/core/workflow-runtime.ts +86 -0
  89. package/src/cli/index.ts +0 -1
  90. package/src/cli/router.ts +4 -3
  91. package/src/shared/debug/pause-handler.ts +20 -0
  92. package/src/shared/debug/pause.ts +14 -48
  93. package/src/shared/instrumentation/instrument.ts +4 -4
  94. package/src/shared/ipc/AGENTS.md +24 -0
  95. package/src/shared/ipc/child-process-transport.spec.ts +86 -0
  96. package/src/shared/ipc/child-process-transport.ts +96 -0
  97. package/src/shared/ipc/ipc.spec.ts +161 -0
  98. package/src/shared/ipc/ipc.ts +288 -0
  99. package/src/shared/ipc/socket-transport.spec.ts +141 -0
  100. package/src/shared/ipc/socket-transport.ts +189 -0
  101. package/src/shared/package-manager.ts +76 -0
  102. package/src/shared/paths/paths.ts +0 -72
  103. package/src/shared/snapshot/capture-snapshot.ts +615 -0
  104. package/src/shared/snapshot/diff-snapshots.ts +579 -0
  105. package/src/shared/snapshot/render-snapshot.ts +962 -0
  106. package/src/shared/snapshot/snapshot.spec.ts +388 -0
  107. package/src/shared/snapshot/types.ts +43 -0
  108. package/src/shared/snapshot/wait-for-page-stable.ts +425 -0
  109. package/src/shared/state/session-state.ts +1 -0
  110. package/dist/cli/commands/ai.js +0 -109
  111. package/dist/cli/core/ai-model.js +0 -192
  112. package/dist/cli/core/api-snapshot-analyzer.js +0 -86
  113. package/dist/cli/core/daemon/index.js +0 -16
  114. package/dist/cli/core/daemon/spawn.js +0 -90
  115. package/dist/cli/core/pause-signals.js +0 -29
  116. package/dist/cli/core/snapshot-analyzer.js +0 -666
  117. package/dist/cli/workers/run-integration-runtime.js +0 -235
  118. package/dist/cli/workers/run-integration-worker-protocol.js +0 -17
  119. package/dist/cli/workers/run-integration-worker.js +0 -64
  120. package/scripts/summarize-evals.mjs +0 -135
  121. package/src/cli/commands/ai.ts +0 -143
  122. package/src/cli/core/ai-model.ts +0 -298
  123. package/src/cli/core/api-snapshot-analyzer.ts +0 -110
  124. package/src/cli/core/daemon/index.ts +0 -24
  125. package/src/cli/core/daemon/spawn.ts +0 -171
  126. package/src/cli/core/pause-signals.ts +0 -35
  127. package/src/cli/core/snapshot-analyzer.ts +0 -855
  128. package/src/cli/workers/run-integration-runtime.ts +0 -326
  129. package/src/cli/workers/run-integration-worker-protocol.ts +0 -19
  130. package/src/cli/workers/run-integration-worker.ts +0 -72
@@ -0,0 +1,579 @@
1
+ import type { Snapshot } from "./types.js";
2
+ import {
3
+ renderChildrenTruncationNotice,
4
+ renderFrame,
5
+ renderNode,
6
+ renderSnapshotFrames,
7
+ type RenderedSnapshotChild,
8
+ type RenderedSnapshotFrame,
9
+ type RenderedSnapshotNode,
10
+ } from "./render-snapshot.js";
11
+
12
+ const MAX_DIFF_CHILDREN_PER_PARENT = 4;
13
+ const MAX_LABEL_CHARS = 140;
14
+
15
+ const LOW_SIGNAL_DIFF_ATTRS = new Set(["ref"]);
16
+
17
+ export type SnapshotDiff = {
18
+ before: Snapshot;
19
+ after: Snapshot;
20
+ changed: boolean;
21
+ pageChanged: boolean;
22
+ frames: SnapshotFrameDiff[];
23
+ };
24
+
25
+ export type SnapshotFrameDiff =
26
+ | {
27
+ type: "context";
28
+ frame: RenderedSnapshotFrame;
29
+ children: SnapshotDiffChild[];
30
+ }
31
+ | {
32
+ type: "modified";
33
+ before: RenderedSnapshotFrame;
34
+ after: RenderedSnapshotFrame;
35
+ }
36
+ | { type: "added"; frame: RenderedSnapshotFrame }
37
+ | { type: "removed"; frame: RenderedSnapshotFrame };
38
+
39
+ export type SnapshotDiffChild = SnapshotNodeDiff | SnapshotTextDiff;
40
+
41
+ export type SnapshotNodeDiff =
42
+ | {
43
+ kind: "node";
44
+ type: "context";
45
+ node: RenderedSnapshotNode;
46
+ children: SnapshotDiffChild[];
47
+ }
48
+ | {
49
+ kind: "node";
50
+ type: "modified";
51
+ before: RenderedSnapshotNode;
52
+ after: RenderedSnapshotNode;
53
+ children: SnapshotDiffChild[];
54
+ }
55
+ | { kind: "node"; type: "added"; node: RenderedSnapshotNode }
56
+ | { kind: "node"; type: "removed"; node: RenderedSnapshotNode };
57
+
58
+ export type SnapshotTextDiff =
59
+ | {
60
+ kind: "text";
61
+ type: "modified";
62
+ before: Extract<RenderedSnapshotChild, { kind: "text" }>;
63
+ after: Extract<RenderedSnapshotChild, { kind: "text" }>;
64
+ }
65
+ | {
66
+ kind: "text";
67
+ type: "added";
68
+ node: Extract<RenderedSnapshotChild, { kind: "text" }>;
69
+ }
70
+ | {
71
+ kind: "text";
72
+ type: "removed";
73
+ node: Extract<RenderedSnapshotChild, { kind: "text" }>;
74
+ };
75
+
76
+ export function diffSnapshots(before: Snapshot, after: Snapshot): SnapshotDiff {
77
+ const beforeFrames = renderSnapshotFrames(before);
78
+ const afterFrames = renderSnapshotFrames(after);
79
+ const pageChanged = before.title !== after.title || before.url !== after.url;
80
+ const frames = diffFrames(beforeFrames, afterFrames);
81
+ return {
82
+ before,
83
+ after,
84
+ pageChanged,
85
+ frames,
86
+ changed: pageChanged || frames.length > 0,
87
+ };
88
+ }
89
+
90
+ export function renderSnapshotDiff(diff: SnapshotDiff): string {
91
+ if (!diff.changed) return "";
92
+
93
+ if (diff.pageChanged && diff.frames.length === 0) {
94
+ return [
95
+ renderPageOpen(diff.before, "- ", true),
96
+ renderPageOpen(diff.after, "+ ", true),
97
+ ].join("\n");
98
+ }
99
+
100
+ const lines = [renderPageOpen(diff.after, "")];
101
+ for (const frameDiff of diff.frames) renderFrameDiff(frameDiff, 1, lines);
102
+ lines.push("</page>");
103
+ return lines.join("\n");
104
+ }
105
+
106
+ function renderPageOpen(
107
+ snapshot: Pick<Snapshot, "title" | "url">,
108
+ prefix: string,
109
+ selfClosing = false,
110
+ ): string {
111
+ return `${prefix}${formatTag(
112
+ "page",
113
+ [
114
+ ["title", firstNonEmpty(snapshot.title, snapshot.url) ?? ""],
115
+ ["url", snapshot.url],
116
+ ],
117
+ !selfClosing,
118
+ )}`;
119
+ }
120
+
121
+ function renderFrameLine(
122
+ frame: RenderedSnapshotFrame,
123
+ depth: number,
124
+ prefix: string,
125
+ selfClosing: boolean,
126
+ ): string {
127
+ const attrs: Array<[string, string]> = [
128
+ ["index", String(frame.index)],
129
+ ["url", normalizeText(frame.url, MAX_LABEL_CHARS)],
130
+ ];
131
+ if (frame.name)
132
+ attrs.push(["name", normalizeText(frame.name, MAX_LABEL_CHARS)]);
133
+ if (frame.parentId) attrs.push(["parent", frame.parentId]);
134
+ if (frame.status === "unavailable") {
135
+ attrs.push(["error", normalizeText(frame.error, 180)]);
136
+ }
137
+ return `${prefix}${indent(depth)}${formatTag("frame", attrs, !selfClosing)}`;
138
+ }
139
+
140
+ function renderTextNode(
141
+ node: Extract<RenderedSnapshotChild, { kind: "text" }>,
142
+ depth: number,
143
+ prefix: string,
144
+ ): string {
145
+ return `${prefix}${indent(depth)}${escapeText(node.text)}`;
146
+ }
147
+
148
+ function indent(depth: number): string {
149
+ return "\t".repeat(depth);
150
+ }
151
+
152
+ function formatTag(
153
+ tagName: string,
154
+ attributes: Array<[string, string]>,
155
+ hasChildren: boolean,
156
+ ): string {
157
+ const attrs = attributes
158
+ .filter(([, value]) => value !== "")
159
+ .map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)
160
+ .join("");
161
+ return hasChildren ? `<${tagName}${attrs}>` : `<${tagName}${attrs} />`;
162
+ }
163
+
164
+ function diffFrames(
165
+ beforeFrames: RenderedSnapshotFrame[],
166
+ afterFrames: RenderedSnapshotFrame[],
167
+ ): SnapshotFrameDiff[] {
168
+ const diffs: SnapshotFrameDiff[] = [];
169
+ const maxLength = Math.max(beforeFrames.length, afterFrames.length);
170
+
171
+ for (let index = 0; index < maxLength; index += 1) {
172
+ const before = beforeFrames[index];
173
+ const after = afterFrames[index];
174
+ if (before && !after) {
175
+ diffs.push({ type: "removed", frame: before });
176
+ } else if (!before && after) {
177
+ diffs.push({ type: "added", frame: after });
178
+ } else if (before && after) {
179
+ const diff = diffFrame(before, after);
180
+ if (diff) diffs.push(diff);
181
+ }
182
+ }
183
+
184
+ return diffs;
185
+ }
186
+
187
+ function diffFrame(
188
+ before: RenderedSnapshotFrame,
189
+ after: RenderedSnapshotFrame,
190
+ ): SnapshotFrameDiff | null {
191
+ if (before.status !== after.status)
192
+ return { type: "modified", before, after };
193
+ if (before.status === "unavailable" || after.status === "unavailable") {
194
+ return comparableFrame(before) === comparableFrame(after)
195
+ ? null
196
+ : { type: "modified", before, after };
197
+ }
198
+
199
+ const children = diffChildren(before.roots, after.roots);
200
+ const frameChanged = comparableFrame(before) !== comparableFrame(after);
201
+ if (!frameChanged && children.length === 0) return null;
202
+ if (frameChanged && children.length === 0)
203
+ return { type: "modified", before, after };
204
+ return { type: "context", frame: after, children };
205
+ }
206
+
207
+ function diffChildren(
208
+ beforeChildren: RenderedSnapshotChild[],
209
+ afterChildren: RenderedSnapshotChild[],
210
+ ): SnapshotDiffChild[] {
211
+ const diffs: SnapshotDiffChild[] = [];
212
+ const usedBefore = new Set<number>();
213
+
214
+ for (let afterIndex = 0; afterIndex < afterChildren.length; afterIndex += 1) {
215
+ const after = afterChildren[afterIndex]!;
216
+ const beforeIndex = findMatchingBeforeChild(
217
+ after,
218
+ afterIndex,
219
+ beforeChildren,
220
+ usedBefore,
221
+ );
222
+
223
+ if (beforeIndex === -1) {
224
+ diffs.push(addedChild(after));
225
+ continue;
226
+ }
227
+
228
+ usedBefore.add(beforeIndex);
229
+ const childDiff = diffChild(beforeChildren[beforeIndex]!, after);
230
+ if (childDiff) diffs.push(childDiff);
231
+ }
232
+
233
+ for (
234
+ let beforeIndex = 0;
235
+ beforeIndex < beforeChildren.length;
236
+ beforeIndex += 1
237
+ ) {
238
+ if (!usedBefore.has(beforeIndex))
239
+ diffs.push(removedChild(beforeChildren[beforeIndex]!));
240
+ }
241
+
242
+ return diffs;
243
+ }
244
+
245
+ function diffChild(
246
+ before: RenderedSnapshotChild,
247
+ after: RenderedSnapshotChild,
248
+ ): SnapshotDiffChild | null {
249
+ if (before.kind === "text" || after.kind === "text") {
250
+ if (before.kind === "text" && after.kind === "text") {
251
+ return before.text === after.text
252
+ ? null
253
+ : { kind: "text", type: "modified", before, after };
254
+ }
255
+ return after.kind === "text" ? addedChild(after) : removedChild(before);
256
+ }
257
+
258
+ const children = diffChildren(before.children, after.children);
259
+ const selfChanged = comparableNode(before) !== comparableNode(after);
260
+ const directTextChanged = children.some((child) => child.kind === "text");
261
+ if (!selfChanged && children.length === 0) return null;
262
+ if (selfChanged || (sameRef(before, after) && directTextChanged)) {
263
+ return { kind: "node", type: "modified", before, after, children };
264
+ }
265
+ return { kind: "node", type: "context", node: after, children };
266
+ }
267
+
268
+ function findMatchingBeforeChild(
269
+ after: RenderedSnapshotChild,
270
+ afterIndex: number,
271
+ beforeChildren: RenderedSnapshotChild[],
272
+ usedBefore: Set<number>,
273
+ ): number {
274
+ const beforeAtSameIndex = beforeChildren[afterIndex];
275
+ if (
276
+ beforeAtSameIndex &&
277
+ !usedBefore.has(afterIndex) &&
278
+ arePositionallySimilarChildren(beforeAtSameIndex, after)
279
+ ) {
280
+ return afterIndex;
281
+ }
282
+
283
+ if (after.kind === "node") {
284
+ const byKey = beforeChildren.findIndex(
285
+ (before, index) =>
286
+ !usedBefore.has(index) &&
287
+ before.kind === "node" &&
288
+ before.key === after.key,
289
+ );
290
+ if (byKey !== -1) return byKey;
291
+
292
+ const afterFingerprint = childFingerprint(after);
293
+ const byFingerprint = beforeChildren.findIndex(
294
+ (before, index) =>
295
+ !usedBefore.has(index) &&
296
+ before.kind === "node" &&
297
+ childFingerprint(before) === afterFingerprint,
298
+ );
299
+ if (byFingerprint !== -1) return byFingerprint;
300
+ }
301
+
302
+ return -1;
303
+ }
304
+
305
+ function addedChild(child: RenderedSnapshotChild): SnapshotDiffChild {
306
+ return child.kind === "text"
307
+ ? { kind: "text", type: "added", node: child }
308
+ : { kind: "node", type: "added", node: child };
309
+ }
310
+
311
+ function removedChild(child: RenderedSnapshotChild): SnapshotDiffChild {
312
+ return child.kind === "text"
313
+ ? { kind: "text", type: "removed", node: child }
314
+ : { kind: "node", type: "removed", node: child };
315
+ }
316
+
317
+ function arePositionallySimilarChildren(
318
+ before: RenderedSnapshotChild,
319
+ after: RenderedSnapshotChild,
320
+ ): boolean {
321
+ if (before.kind !== after.kind) return false;
322
+ if (before.kind === "text" && after.kind === "text") return true;
323
+ if (before.kind === "node" && after.kind === "node") {
324
+ return before.key === after.key || before.role === after.role;
325
+ }
326
+ return false;
327
+ }
328
+
329
+ function renderFrameDiff(
330
+ diff: SnapshotFrameDiff,
331
+ depth: number,
332
+ lines: string[],
333
+ ): void {
334
+ if (diff.type === "added") {
335
+ renderFrame(diff.frame, depth, lines, "+ ");
336
+ } else if (diff.type === "removed") {
337
+ renderFrame(diff.frame, depth, lines, "- ");
338
+ } else if (diff.type === "modified") {
339
+ renderFrame(diff.before, depth, lines, "- ");
340
+ renderFrame(diff.after, depth, lines, "+ ");
341
+ } else if (diff.frame.status === "ok") {
342
+ lines.push(renderFrameLine(diff.frame, depth, "", false));
343
+ if (
344
+ diff.frame.roots.length > diff.children.length &&
345
+ diff.children.length > 0
346
+ ) {
347
+ lines.push(`${indent(depth + 1)}...`);
348
+ }
349
+ renderChildDiffs(diff.children, depth + 1, lines);
350
+ lines.push(`${indent(depth)}</frame>`);
351
+ }
352
+ }
353
+
354
+ function renderChildDiffs(
355
+ diffs: SnapshotDiffChild[],
356
+ depth: number,
357
+ lines: string[],
358
+ ): void {
359
+ for (const diff of diffs.slice(0, MAX_DIFF_CHILDREN_PER_PARENT)) {
360
+ renderChildDiff(diff, depth, lines);
361
+ }
362
+
363
+ if (diffs.length > MAX_DIFF_CHILDREN_PER_PARENT) {
364
+ const truncated = diffs.slice(MAX_DIFF_CHILDREN_PER_PARENT);
365
+ const prefix = diffPrefixForSummary(truncated);
366
+ lines.push(
367
+ `${prefix}${indent(depth)}${renderChildrenTruncationNotice(
368
+ diffSummaryChildren(truncated),
369
+ )}`,
370
+ );
371
+ }
372
+ }
373
+
374
+ function renderChildDiff(
375
+ diff: SnapshotDiffChild,
376
+ depth: number,
377
+ lines: string[],
378
+ ): void {
379
+ if (diff.kind === "text") {
380
+ if (diff.type === "added")
381
+ lines.push(renderTextNode(diff.node, depth, "+ "));
382
+ else if (diff.type === "removed")
383
+ lines.push(renderTextNode(diff.node, depth, "- "));
384
+ else {
385
+ lines.push(renderTextNode(diff.before, depth, "- "));
386
+ lines.push(renderTextNode(diff.after, depth, "+ "));
387
+ }
388
+ return;
389
+ }
390
+
391
+ renderNodeDiff(diff, depth, lines);
392
+ }
393
+
394
+ function renderNodeDiff(
395
+ diff: SnapshotNodeDiff,
396
+ depth: number,
397
+ lines: string[],
398
+ ): void {
399
+ if (diff.type === "added") {
400
+ renderNode(diff.node, depth, lines, "+ ");
401
+ } else if (diff.type === "removed") {
402
+ renderRemovedNode(diff.node, depth, lines);
403
+ } else if (diff.type === "modified") {
404
+ if (sameRef(diff.before, diff.after)) {
405
+ renderModifiedSameRefNode(diff, depth, lines);
406
+ } else {
407
+ renderRemovedNode(diff.before, depth, lines);
408
+ renderNode(diff.after, depth, lines, "+ ");
409
+ }
410
+ } else {
411
+ if (diff.node.children.length === 0) {
412
+ lines.push(
413
+ `${indent(depth)}${formatTag(diff.node.role, diff.node.attrs, false)}`,
414
+ );
415
+ return;
416
+ }
417
+
418
+ lines.push(
419
+ `${indent(depth)}${formatTag(diff.node.role, diff.node.attrs, true)}`,
420
+ );
421
+ if (diff.node.children.length > diff.children.length)
422
+ lines.push(`${indent(depth + 1)}...`);
423
+ renderChildDiffs(diff.children, depth + 1, lines);
424
+ lines.push(`${indent(depth)}</${diff.node.role}>`);
425
+ }
426
+ }
427
+
428
+ function renderModifiedSameRefNode(
429
+ diff: Extract<SnapshotNodeDiff, { type: "modified" }>,
430
+ depth: number,
431
+ lines: string[],
432
+ ): void {
433
+ if (diff.children.length === 0 || singleTextChild(diff.after) !== null) {
434
+ renderNode(diff.after, depth, lines, "~ ");
435
+ return;
436
+ }
437
+
438
+ if (diff.after.children.length === 0) {
439
+ lines.push(
440
+ `~ ${indent(depth)}${formatTag(diff.after.role, diff.after.attrs, false)}`,
441
+ );
442
+ return;
443
+ }
444
+
445
+ lines.push(
446
+ `~ ${indent(depth)}${formatTag(diff.after.role, diff.after.attrs, true)}`,
447
+ );
448
+ if (diff.after.children.length > diff.children.length)
449
+ lines.push(`~ ${indent(depth + 1)}...`);
450
+ renderChildDiffs(diff.children, depth + 1, lines);
451
+ lines.push(`~ ${indent(depth)}</${diff.after.role}>`);
452
+ }
453
+
454
+ function renderRemovedNode(
455
+ node: RenderedSnapshotNode,
456
+ depth: number,
457
+ lines: string[],
458
+ ): void {
459
+ const attrs = node.attrs.filter(([name]) => name === "ref");
460
+ if (node.children.length === 0) {
461
+ lines.push(`- ${indent(depth)}${formatTag(node.role, attrs, false)}`);
462
+ return;
463
+ }
464
+
465
+ lines.push(
466
+ `- ${indent(depth)}${formatTag(node.role, attrs, true)}...</${node.role}>`,
467
+ );
468
+ }
469
+
470
+ function comparableFrame(frame: RenderedSnapshotFrame): string {
471
+ return JSON.stringify({
472
+ status: frame.status,
473
+ id: frame.id,
474
+ index: frame.index,
475
+ url: frame.url,
476
+ name: frame.name,
477
+ parentId: frame.parentId,
478
+ error: frame.status === "unavailable" ? frame.error : undefined,
479
+ });
480
+ }
481
+
482
+ function comparableNode(node: RenderedSnapshotNode): string {
483
+ return JSON.stringify({
484
+ role: node.role,
485
+ attrs: comparableAttrs(node.attrs),
486
+ });
487
+ }
488
+
489
+ function comparableAttrs(
490
+ attrs: Array<[string, string]>,
491
+ ): Array<[string, string]> {
492
+ return attrs.flatMap(([name, value]) => {
493
+ if (LOW_SIGNAL_DIFF_ATTRS.has(name)) return [];
494
+ if (name === "href") return [[name, normalizeComparableHref(value)]];
495
+ return [[name, value]];
496
+ });
497
+ }
498
+
499
+ function sameRef(
500
+ before: RenderedSnapshotNode,
501
+ after: RenderedSnapshotNode,
502
+ ): boolean {
503
+ const beforeRef = attrValue(before, "ref");
504
+ return beforeRef !== null && beforeRef === attrValue(after, "ref");
505
+ }
506
+
507
+ function attrValue(node: RenderedSnapshotNode, name: string): string | null {
508
+ return node.attrs.find(([attr]) => attr === name)?.[1] ?? null;
509
+ }
510
+
511
+ function singleTextChild(node: RenderedSnapshotNode): string | null {
512
+ if (node.children.length !== 1) return null;
513
+ const child = node.children[0]!;
514
+ return child.kind === "text" && !child.block ? child.text : null;
515
+ }
516
+
517
+ function childFingerprint(child: RenderedSnapshotChild): string {
518
+ if (child.kind === "text") return `text:${child.text}`;
519
+ return comparableNode(child);
520
+ }
521
+
522
+ function normalizeComparableHref(value: string): string {
523
+ const withoutEllipsis = value.endsWith("…") ? value.slice(0, -1) : value;
524
+ try {
525
+ const url = new URL(withoutEllipsis);
526
+ return `${url.protocol}//${url.host}${url.pathname}`;
527
+ } catch {
528
+ return withoutEllipsis.split(/[?#]/, 1)[0] ?? withoutEllipsis;
529
+ }
530
+ }
531
+
532
+ function firstNonEmpty(...values: Array<string | null | undefined>): string | null {
533
+ for (const value of values) {
534
+ const normalized = normalizeRawText(value ?? "");
535
+ if (normalized) return truncate(normalized, MAX_LABEL_CHARS);
536
+ }
537
+ return null;
538
+ }
539
+
540
+ function normalizeText(value: string, maxChars: number): string {
541
+ return truncate(value.replace(/\s+/g, " ").trim(), maxChars);
542
+ }
543
+
544
+ function normalizeRawText(value: string): string {
545
+ return value.replace(/\s+/g, " ").trim();
546
+ }
547
+
548
+ function truncate(value: string, maxChars: number): string {
549
+ return value.length > maxChars ? `${value.slice(0, maxChars - 1)}…` : value;
550
+ }
551
+
552
+ function escapeText(value: string): string {
553
+ return value
554
+ .replace(/&/g, "&amp;")
555
+ .replace(/</g, "&lt;")
556
+ .replace(/>/g, "&gt;");
557
+ }
558
+
559
+ function escapeAttribute(value: string): string {
560
+ return value
561
+ .replace(/&/g, "&amp;")
562
+ .replace(/"/g, "&quot;")
563
+ .replace(/</g, "&lt;")
564
+ .replace(/>/g, "&gt;");
565
+ }
566
+
567
+ function diffPrefixForSummary(diffs: SnapshotDiffChild[]): string {
568
+ if (diffs.every((diff) => diff.type === "added")) return "+ ";
569
+ if (diffs.every((diff) => diff.type === "removed")) return "- ";
570
+ return "";
571
+ }
572
+
573
+ function diffSummaryChildren(
574
+ diffs: SnapshotDiffChild[],
575
+ ): RenderedSnapshotChild[] {
576
+ return diffs.map((diff) =>
577
+ diff.type === "modified" ? diff.after : diff.node,
578
+ );
579
+ }