libretto 0.6.10 → 0.6.12

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 (119) hide show
  1. package/README.md +4 -0
  2. package/README.template.md +4 -0
  3. package/dist/cli/cli.js +4 -3
  4. package/dist/cli/commands/ai.js +3 -2
  5. package/dist/cli/commands/browser.js +17 -17
  6. package/dist/cli/commands/execution.js +254 -234
  7. package/dist/cli/commands/experiments.js +100 -0
  8. package/dist/cli/commands/setup.js +20 -34
  9. package/dist/cli/commands/shared.js +10 -0
  10. package/dist/cli/commands/snapshot.js +81 -9
  11. package/dist/cli/commands/status.js +5 -4
  12. package/dist/cli/core/ai-model.js +6 -3
  13. package/dist/cli/core/browser.js +300 -121
  14. package/dist/cli/core/config.js +4 -2
  15. package/dist/cli/core/context.js +4 -0
  16. package/dist/cli/core/daemon/config.js +0 -6
  17. package/dist/cli/core/daemon/daemon.js +535 -89
  18. package/dist/cli/core/daemon/ipc.js +170 -129
  19. package/dist/cli/core/daemon/snapshot.js +72 -6
  20. package/dist/cli/core/experiments.js +66 -0
  21. package/dist/cli/core/session.js +5 -4
  22. package/dist/cli/core/skill-version.js +2 -1
  23. package/dist/cli/core/snapshot-analyzer.js +4 -3
  24. package/dist/cli/core/workflow-runner/runner.js +147 -0
  25. package/dist/cli/core/workflow-runtime.js +60 -0
  26. package/dist/cli/router.js +4 -1
  27. package/dist/shared/debug/pause-handler.d.ts +9 -0
  28. package/dist/shared/debug/pause-handler.js +15 -0
  29. package/dist/shared/debug/pause.d.ts +1 -2
  30. package/dist/shared/debug/pause.js +13 -36
  31. package/dist/shared/ipc/child-process-transport.d.ts +7 -0
  32. package/dist/shared/ipc/child-process-transport.js +60 -0
  33. package/dist/shared/ipc/child-process-transport.spec.d.ts +2 -0
  34. package/dist/shared/ipc/child-process-transport.spec.js +68 -0
  35. package/dist/shared/ipc/ipc.d.ts +46 -0
  36. package/dist/shared/ipc/ipc.js +165 -0
  37. package/dist/shared/ipc/ipc.spec.d.ts +2 -0
  38. package/dist/shared/ipc/ipc.spec.js +114 -0
  39. package/dist/shared/ipc/socket-transport.d.ts +9 -0
  40. package/dist/shared/ipc/socket-transport.js +143 -0
  41. package/dist/shared/ipc/socket-transport.spec.d.ts +2 -0
  42. package/dist/shared/ipc/socket-transport.spec.js +117 -0
  43. package/dist/shared/package-manager.d.ts +7 -0
  44. package/dist/shared/package-manager.js +60 -0
  45. package/dist/shared/paths/paths.d.ts +1 -8
  46. package/dist/shared/paths/paths.js +1 -49
  47. package/dist/shared/snapshot/capture-snapshot.d.ts +9 -0
  48. package/dist/shared/snapshot/capture-snapshot.js +463 -0
  49. package/dist/shared/snapshot/diff-snapshots.d.ts +72 -0
  50. package/dist/shared/snapshot/diff-snapshots.js +358 -0
  51. package/dist/shared/snapshot/render-snapshot.d.ts +39 -0
  52. package/dist/shared/snapshot/render-snapshot.js +651 -0
  53. package/dist/shared/snapshot/snapshot.spec.d.ts +2 -0
  54. package/dist/shared/snapshot/snapshot.spec.js +333 -0
  55. package/dist/shared/snapshot/types.d.ts +40 -0
  56. package/dist/shared/snapshot/types.js +0 -0
  57. package/dist/shared/snapshot/wait-for-page-stable.d.ts +17 -0
  58. package/dist/shared/snapshot/wait-for-page-stable.js +281 -0
  59. package/dist/shared/state/session-state.d.ts +1 -0
  60. package/dist/shared/state/session-state.js +1 -0
  61. package/docs/experiments.md +67 -0
  62. package/package.json +4 -2
  63. package/skills/libretto/SKILL.md +3 -1
  64. package/skills/libretto-readonly/SKILL.md +1 -1
  65. package/src/cli/AGENTS.md +7 -0
  66. package/src/cli/cli.ts +4 -3
  67. package/src/cli/commands/ai.ts +3 -2
  68. package/src/cli/commands/browser.ts +13 -11
  69. package/src/cli/commands/execution.ts +303 -271
  70. package/src/cli/commands/experiments.ts +120 -0
  71. package/src/cli/commands/setup.ts +18 -36
  72. package/src/cli/commands/shared.ts +20 -0
  73. package/src/cli/commands/snapshot.ts +99 -11
  74. package/src/cli/commands/status.ts +5 -4
  75. package/src/cli/core/ai-model.ts +6 -3
  76. package/src/cli/core/browser.ts +369 -147
  77. package/src/cli/core/config.ts +3 -1
  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 +686 -106
  81. package/src/cli/core/daemon/ipc.ts +330 -214
  82. package/src/cli/core/daemon/snapshot.ts +106 -8
  83. package/src/cli/core/experiments.ts +85 -0
  84. package/src/cli/core/session.ts +5 -4
  85. package/src/cli/core/skill-version.ts +2 -1
  86. package/src/cli/core/snapshot-analyzer.ts +4 -3
  87. package/src/cli/core/workflow-runner/runner.ts +237 -0
  88. package/src/cli/core/workflow-runtime.ts +85 -0
  89. package/src/cli/router.ts +4 -1
  90. package/src/shared/debug/pause-handler.ts +20 -0
  91. package/src/shared/debug/pause.ts +14 -48
  92. package/src/shared/ipc/AGENTS.md +24 -0
  93. package/src/shared/ipc/child-process-transport.spec.ts +86 -0
  94. package/src/shared/ipc/child-process-transport.ts +96 -0
  95. package/src/shared/ipc/ipc.spec.ts +161 -0
  96. package/src/shared/ipc/ipc.ts +288 -0
  97. package/src/shared/ipc/socket-transport.spec.ts +141 -0
  98. package/src/shared/ipc/socket-transport.ts +189 -0
  99. package/src/shared/package-manager.ts +76 -0
  100. package/src/shared/paths/paths.ts +0 -72
  101. package/src/shared/snapshot/capture-snapshot.ts +615 -0
  102. package/src/shared/snapshot/diff-snapshots.ts +579 -0
  103. package/src/shared/snapshot/render-snapshot.ts +962 -0
  104. package/src/shared/snapshot/snapshot.spec.ts +388 -0
  105. package/src/shared/snapshot/types.ts +43 -0
  106. package/src/shared/snapshot/wait-for-page-stable.ts +425 -0
  107. package/src/shared/state/session-state.ts +1 -0
  108. package/dist/cli/core/daemon/index.js +0 -16
  109. package/dist/cli/core/daemon/spawn.js +0 -90
  110. package/dist/cli/core/pause-signals.js +0 -29
  111. package/dist/cli/workers/run-integration-runtime.js +0 -235
  112. package/dist/cli/workers/run-integration-worker-protocol.js +0 -17
  113. package/dist/cli/workers/run-integration-worker.js +0 -64
  114. package/src/cli/core/daemon/index.ts +0 -24
  115. package/src/cli/core/daemon/spawn.ts +0 -171
  116. package/src/cli/core/pause-signals.ts +0 -35
  117. package/src/cli/workers/run-integration-runtime.ts +0 -326
  118. package/src/cli/workers/run-integration-worker-protocol.ts +0 -19
  119. package/src/cli/workers/run-integration-worker.ts +0 -72
@@ -0,0 +1,962 @@
1
+ import { scopeSnapshotToRef } from "./capture-snapshot.js";
2
+ import type {
3
+ Snapshot,
4
+ SnapshotFrame,
5
+ SnapshotNode,
6
+ SnapshotPrimitive,
7
+ } from "./types.js";
8
+
9
+ const MAX_CHILDREN_PER_PARENT = 4;
10
+ const MAX_LABEL_CHARS = 140;
11
+ const MAX_SUMMARY_TEXT_CHARS = 80;
12
+ const MAX_HREF_CHARS = 96;
13
+ const MAX_ACTIONS_IN_SUMMARY = 3;
14
+ const MAX_ACTION_LABEL_CHARS = 80;
15
+
16
+ const PRESERVE_CHILDREN_BY_ROLE = new Set([
17
+ "document",
18
+ "main",
19
+ "navigation",
20
+ "banner",
21
+ "contentinfo",
22
+ "form",
23
+ "search",
24
+ "list",
25
+ "table",
26
+ "tabpanel",
27
+ ]);
28
+
29
+ const FLATTEN_ROLES = new Set([
30
+ "none",
31
+ "presentation",
32
+ "LayoutTable",
33
+ "LayoutTableRow",
34
+ "LayoutTableCell",
35
+ ]);
36
+
37
+ const SKIP_ROLES = new Set(["InlineTextBox", "ListMarker"]);
38
+
39
+ const ACTION_ROLES = new Set([
40
+ "button",
41
+ "link",
42
+ "textbox",
43
+ "checkbox",
44
+ "radio",
45
+ "switch",
46
+ "combobox",
47
+ "listbox",
48
+ "menuitem",
49
+ "tab",
50
+ "slider",
51
+ ]);
52
+
53
+ const ACTION_STATE_ATTRS = new Set([
54
+ "checked",
55
+ "disabled",
56
+ "expanded",
57
+ "pressed",
58
+ "selected",
59
+ "value",
60
+ "placeholder",
61
+ ]);
62
+
63
+ const TEXT_ACTION_ROLES = new Set(["button", "link", "menuitem", "tab"]);
64
+
65
+ const KEEP_ROLES = new Set([
66
+ "document",
67
+ "main",
68
+ "navigation",
69
+ "banner",
70
+ "contentinfo",
71
+ "form",
72
+ "search",
73
+ "list",
74
+ "listitem",
75
+ "button",
76
+ "link",
77
+ "image",
78
+ "textbox",
79
+ "checkbox",
80
+ "radio",
81
+ "switch",
82
+ "combobox",
83
+ "listbox",
84
+ "menu",
85
+ "menuitem",
86
+ "option",
87
+ "tab",
88
+ "slider",
89
+ ]);
90
+
91
+ const BLOCK_FLATTEN_ROLES = new Set([
92
+ "paragraph",
93
+ "section",
94
+ "article",
95
+ "region",
96
+ "group",
97
+ "figure",
98
+ ]);
99
+
100
+ const RENDERED_STATE_PROPERTIES = [
101
+ "disabled",
102
+ "checked",
103
+ "expanded",
104
+ "selected",
105
+ "pressed",
106
+ "required",
107
+ "invalid",
108
+ "readonly",
109
+ "multiline",
110
+ "autocomplete",
111
+ "haspopup",
112
+ "value",
113
+ ];
114
+
115
+ type SnapshotTextNode = {
116
+ kind: "text";
117
+ text: string;
118
+ block?: boolean;
119
+ };
120
+
121
+ export type RenderedSnapshotNode = {
122
+ kind: "node";
123
+ key: string;
124
+ role: string;
125
+ attrs: Array<[string, string]>;
126
+ children: RenderedSnapshotChild[];
127
+ };
128
+
129
+ export type RenderedSnapshotChild = RenderedSnapshotNode | SnapshotTextNode;
130
+
131
+ export type RenderedSnapshotFrame =
132
+ | {
133
+ status: "ok";
134
+ id: string;
135
+ index: number;
136
+ url: string;
137
+ name: string | null;
138
+ parentId: string | null;
139
+ roots: RenderedSnapshotNode[];
140
+ }
141
+ | {
142
+ status: "unavailable";
143
+ id: string;
144
+ index: number;
145
+ url: string;
146
+ name: string | null;
147
+ parentId: string | null;
148
+ error: string;
149
+ };
150
+
151
+ export function renderSnapshot(snapshot: Snapshot, refId?: string): string {
152
+ const snapshotTree = refId ? scopeSnapshotToRef(snapshot, refId) : snapshot;
153
+ const lines = [renderPageOpen(snapshotTree, "")];
154
+ for (const frame of renderSnapshotFrames(snapshotTree)) {
155
+ renderFrame(frame, 1, lines);
156
+ }
157
+ lines.push("</page>");
158
+ return lines.join("\n");
159
+ }
160
+
161
+ export function renderSnapshotFrames(snapshot: Snapshot): RenderedSnapshotFrame[] {
162
+ return snapshot.frames.map(toRenderedFrame).filter(hasRenderedFrameContent);
163
+ }
164
+
165
+ function renderPageOpen(
166
+ snapshot: Pick<Snapshot, "title" | "url">,
167
+ prefix: string,
168
+ selfClosing = false,
169
+ ): string {
170
+ return `${prefix}${formatTag(
171
+ "page",
172
+ [
173
+ ["title", firstNonEmpty(snapshot.title, snapshot.url) ?? ""],
174
+ ["url", snapshot.url],
175
+ ],
176
+ !selfClosing,
177
+ )}`;
178
+ }
179
+
180
+ function renderFrameLine(
181
+ frame: RenderedSnapshotFrame,
182
+ depth: number,
183
+ prefix: string,
184
+ selfClosing: boolean,
185
+ ): string {
186
+ const attrs: Array<[string, string]> = [
187
+ ["index", String(frame.index)],
188
+ ["url", normalizeText(frame.url, MAX_LABEL_CHARS)],
189
+ ];
190
+ if (frame.name)
191
+ attrs.push(["name", normalizeText(frame.name, MAX_LABEL_CHARS)]);
192
+ if (frame.parentId) attrs.push(["parent", frame.parentId]);
193
+ if (frame.status === "unavailable") {
194
+ attrs.push(["error", normalizeText(frame.error, 180)]);
195
+ }
196
+ return `${prefix}${indent(depth)}${formatTag("frame", attrs, !selfClosing)}`;
197
+ }
198
+
199
+ function renderTextNode(
200
+ node: SnapshotTextNode,
201
+ depth: number,
202
+ prefix: string,
203
+ ): string {
204
+ return `${prefix}${indent(depth)}${escapeText(node.text)}`;
205
+ }
206
+
207
+ function indent(depth: number): string {
208
+ return "\t".repeat(depth);
209
+ }
210
+
211
+ function formatTag(
212
+ tagName: string,
213
+ attributes: Array<[string, string]>,
214
+ hasChildren: boolean,
215
+ ): string {
216
+ const attrs = attributes
217
+ .filter(([, value]) => value !== "")
218
+ .map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)
219
+ .join("");
220
+ return hasChildren ? `<${tagName}${attrs}>` : `<${tagName}${attrs} />`;
221
+ }
222
+
223
+ export function renderChildrenTruncationNotice(
224
+ children: RenderedSnapshotChild[],
225
+ ): string {
226
+ const count = children.length;
227
+ const summaryActions = actionSummariesForChildren(children);
228
+ const textSnippet = previewForChildren(children, summaryActions.labels);
229
+ const elementLabel = count === 1 ? "element" : "elements";
230
+ const textSnippetPart = textSnippet
231
+ ? `. Text snippet: ${JSON.stringify(textSnippet)}`
232
+ : "";
233
+ const interactiveText = summaryActions.actions.length
234
+ ? `. Interactive elements: ${summaryActions.actions
235
+ .map((action) => action.markup)
236
+ .join(", ")}${summaryActions.hasMore ? ", ..." : ""}`
237
+ : "";
238
+ return `[Truncated ${count} more ${elementLabel}${textSnippetPart}${interactiveText}]`;
239
+ }
240
+
241
+ function toRenderedFrame(frame: SnapshotFrame): RenderedSnapshotFrame {
242
+ if (frame.status === "unavailable") return frame;
243
+ return {
244
+ status: "ok",
245
+ id: frame.id,
246
+ index: frame.index,
247
+ url: frame.url,
248
+ name: frame.name,
249
+ parentId: frame.parentId,
250
+ roots: frame.roots.flatMap((root) => toRenderedNodes(root, null)),
251
+ };
252
+ }
253
+
254
+ function hasRenderedFrameContent(frame: RenderedSnapshotFrame): boolean {
255
+ if (frame.status === "unavailable") return true;
256
+ return frame.roots.length > 0;
257
+ }
258
+
259
+ function toRenderedNodes(
260
+ node: SnapshotNode,
261
+ parent: SnapshotNode | null,
262
+ ): RenderedSnapshotNode[] {
263
+ return toRenderedChildren(node, parent).filter(isRenderedNode);
264
+ }
265
+
266
+ function toRenderedChildren(
267
+ node: SnapshotNode,
268
+ parent: SnapshotNode | null,
269
+ ): RenderedSnapshotChild[] {
270
+ if (shouldSkipNode(node, parent)) return [];
271
+ if (isTextRole(node.role)) {
272
+ const text = firstNonEmpty(
273
+ node.name,
274
+ node.description,
275
+ primitiveToString(node.value),
276
+ );
277
+ if (text && text !== parent?.name && text !== nodeTextValue(parent)) {
278
+ return [{ kind: "text", text }];
279
+ }
280
+ return [];
281
+ }
282
+
283
+ const children = renderableChildren(node);
284
+ const role = tagNameForRole(node.role);
285
+ if (role === "heading") return renderHeading(node, children);
286
+
287
+ const compactRole = roleForNode(node, role, children);
288
+ if (compactRole === "image" && !hasNonEmptyAttribute(node, "src"))
289
+ return [];
290
+ if (compactRole === "link" && !hasNonEmptyAttribute(node, "href")) {
291
+ return flattenedChildren(node, children).filter(
292
+ hasVisibleTextOrInteractive,
293
+ );
294
+ }
295
+ if (
296
+ node.ignored ||
297
+ FLATTEN_ROLES.has(node.role) ||
298
+ !KEEP_ROLES.has(compactRole)
299
+ ) {
300
+ return flattenedChildren(node, children).filter(
301
+ hasVisibleTextOrInteractive,
302
+ );
303
+ }
304
+
305
+ const text = normalizedText(children);
306
+ const suppressName = text.includes(normalizeRawText(node.name ?? ""))
307
+ ? node.name
308
+ : null;
309
+ const attrs = nodeAttributes(node, suppressName);
310
+ const content = nameAttributeAsContent(attrs, children);
311
+ const renderedChildren = removeDuplicateNestedActions(
312
+ compactRole,
313
+ content.attrs,
314
+ content.children,
315
+ ).filter(hasVisibleTextOrInteractive);
316
+
317
+ if (
318
+ !ACTION_ROLES.has(compactRole) &&
319
+ !renderedChildren.some(hasVisibleTextOrInteractive)
320
+ ) {
321
+ return [];
322
+ }
323
+
324
+ const rendered: RenderedSnapshotNode = {
325
+ kind: "node",
326
+ key:
327
+ node.nodeId ||
328
+ node.ref ||
329
+ `${compactRole}:${content.attrs.map(([name, value]) => `${name}=${value}`).join(";")}`,
330
+ role: compactRole,
331
+ attrs: content.attrs,
332
+ children: renderedChildren,
333
+ };
334
+ return [rendered];
335
+ }
336
+
337
+ function renderableChildren(node: SnapshotNode): RenderedSnapshotChild[] {
338
+ const children: RenderedSnapshotChild[] = [];
339
+ for (const child of node.children)
340
+ children.push(...toRenderedChildren(child, node));
341
+ return mergeAdjacentText(children).filter(hasVisibleTextOrInteractive);
342
+ }
343
+
344
+ function renderHeading(
345
+ node: SnapshotNode,
346
+ children: RenderedSnapshotChild[],
347
+ ): RenderedSnapshotChild[] {
348
+ const text = firstNonEmpty(node.name, normalizedText(children));
349
+ if (!text) return [];
350
+ return [
351
+ {
352
+ kind: "text",
353
+ text: `${"#".repeat(headingLevel(node))} ${text}`,
354
+ block: true,
355
+ },
356
+ ];
357
+ }
358
+
359
+ function headingLevel(node: SnapshotNode): number {
360
+ const rawLevel = node.properties.level;
361
+ const level = typeof rawLevel === "number" ? rawLevel : Number(rawLevel);
362
+ if (!Number.isFinite(level)) return 2;
363
+ return Math.min(6, Math.max(1, Math.round(level)));
364
+ }
365
+
366
+ function roleForNode(
367
+ node: SnapshotNode,
368
+ role: string,
369
+ children: RenderedSnapshotChild[],
370
+ ): string {
371
+ if (isPointerButtonCandidate(node, role, children)) return "button";
372
+ return role;
373
+ }
374
+
375
+ function isPointerButtonCandidate(
376
+ node: SnapshotNode,
377
+ role: string,
378
+ children: RenderedSnapshotChild[],
379
+ ): boolean {
380
+ if (KEEP_ROLES.has(role) && role !== "document") return false;
381
+ if (children.some(hasInteractiveNode)) return false;
382
+ if (!hasClickableHint(node)) return false;
383
+ return Boolean(firstNonEmpty(node.name, normalizedText(children)));
384
+ }
385
+
386
+ function hasClickableHint(node: SnapshotNode): boolean {
387
+ if (node.attributes.cursor === "pointer") return true;
388
+ if (Object.hasOwn(node.attributes, "onclick")) return true;
389
+ const tabindex = node.attributes.tabindex;
390
+ return tabindex !== undefined && Number(tabindex) >= 0;
391
+ }
392
+
393
+ function hasInteractiveNode(child: RenderedSnapshotChild): boolean {
394
+ if (child.kind === "text") return false;
395
+ if (ACTION_ROLES.has(child.role)) return true;
396
+ return child.children.some(hasInteractiveNode);
397
+ }
398
+
399
+ function flattenedChildren(
400
+ node: SnapshotNode,
401
+ children: RenderedSnapshotChild[],
402
+ ): RenderedSnapshotChild[] {
403
+ const fallbackText = fallbackTextForFlattenedNode(node);
404
+ const flattened =
405
+ children.length > 0 || !fallbackText
406
+ ? children
407
+ : [{ kind: "text" as const, text: fallbackText }];
408
+
409
+ if (!BLOCK_FLATTEN_ROLES.has(tagNameForRole(node.role))) return flattened;
410
+ return flattened.map((child) =>
411
+ child.kind === "text" ? { ...child, block: true } : child,
412
+ );
413
+ }
414
+
415
+ function fallbackTextForFlattenedNode(node: SnapshotNode): string | null {
416
+ const name = firstNonEmpty(node.name, primitiveToString(node.value));
417
+ if (!name) return null;
418
+ if (attributeMatchesName(node, "aria-label", name)) return null;
419
+ if (attributeMatchesName(node, "title", name)) return null;
420
+ if (attributeMatchesName(node, "alt", name)) return null;
421
+ return name;
422
+ }
423
+
424
+ function attributeMatchesName(
425
+ node: SnapshotNode,
426
+ attributeName: string,
427
+ name: string,
428
+ ): boolean {
429
+ return normalizeRawText(node.attributes[attributeName] ?? "") === name;
430
+ }
431
+
432
+ function hasNonEmptyAttribute(
433
+ node: SnapshotNode,
434
+ attributeName: string,
435
+ ): boolean {
436
+ return normalizeRawText(node.attributes[attributeName] ?? "") !== "";
437
+ }
438
+
439
+ function removeDuplicateNestedActions(
440
+ role: string,
441
+ attrs: Array<[string, string]>,
442
+ children: RenderedSnapshotChild[],
443
+ ): RenderedSnapshotChild[] {
444
+ if (!ACTION_ROLES.has(role)) return children;
445
+ const label = firstNonEmpty(
446
+ attrFromAttrs(attrs, "name"),
447
+ normalizedText(children),
448
+ );
449
+ if (!label) return children;
450
+
451
+ return children.flatMap((child) => {
452
+ if (child.kind === "text") return [child];
453
+ if (!ACTION_ROLES.has(child.role)) return [child];
454
+ const childLabel = firstNonEmpty(
455
+ attrValue(child, "name"),
456
+ singleTextChild(child),
457
+ normalizedText(child.children),
458
+ );
459
+ return childLabel === label ? child.children : [child];
460
+ });
461
+ }
462
+
463
+ function nameAttributeAsContent(
464
+ attrs: Array<[string, string]>,
465
+ children: RenderedSnapshotChild[],
466
+ ): { attrs: Array<[string, string]>; children: RenderedSnapshotChild[] } {
467
+ const name = attrFromAttrs(attrs, "name");
468
+ if (!name) return { attrs, children };
469
+
470
+ const attrsWithoutName = attrs.filter(([attr]) => attr !== "name");
471
+ if (normalizedText(children).includes(normalizeRawText(name))) {
472
+ return { attrs: attrsWithoutName, children };
473
+ }
474
+
475
+ return {
476
+ attrs: attrsWithoutName,
477
+ children: [{ kind: "text", text: name }, ...children],
478
+ };
479
+ }
480
+
481
+ export function renderFrame(
482
+ frame: RenderedSnapshotFrame,
483
+ depth: number,
484
+ lines: string[],
485
+ prefix = "",
486
+ ): void {
487
+ if (frame.status === "unavailable") {
488
+ lines.push(renderFrameLine(frame, depth, prefix, true));
489
+ return;
490
+ }
491
+
492
+ lines.push(renderFrameLine(frame, depth, prefix, false));
493
+ for (const root of frame.roots) renderNode(root, depth + 1, lines, prefix);
494
+ lines.push(`${prefix}${indent(depth)}</frame>`);
495
+ }
496
+
497
+ export function renderNode(
498
+ node: RenderedSnapshotNode,
499
+ depth: number,
500
+ lines: string[],
501
+ prefix = "",
502
+ ): void {
503
+ if (renderFoldedSingleChildChain(node, depth, lines, prefix)) return;
504
+
505
+ if (node.children.length === 0) {
506
+ lines.push(
507
+ `${prefix}${indent(depth)}${formatTag(node.role, node.attrs, false)}`,
508
+ );
509
+ return;
510
+ }
511
+
512
+ const singleText = singleTextChild(node);
513
+ if (singleText !== null) {
514
+ if (shouldRenderBareText(node)) {
515
+ lines.push(`${prefix}${indent(depth)}${escapeText(singleText)}`);
516
+ return;
517
+ }
518
+
519
+ lines.push(
520
+ `${prefix}${indent(depth)}${formatTag(node.role, node.attrs, true)}${escapeText(singleText)}</${node.role}>`,
521
+ );
522
+ return;
523
+ }
524
+
525
+ lines.push(
526
+ `${prefix}${indent(depth)}${formatTag(node.role, node.attrs, true)}`,
527
+ );
528
+ renderChildren(node.children, depth + 1, lines, prefix);
529
+ lines.push(`${prefix}${indent(depth)}</${node.role}>`);
530
+ }
531
+
532
+ function renderChildren(
533
+ children: RenderedSnapshotChild[],
534
+ depth: number,
535
+ lines: string[],
536
+ prefix: string,
537
+ ): void {
538
+ const renderedChildren = children.slice(0, MAX_CHILDREN_PER_PARENT);
539
+ for (const child of renderedChildren) {
540
+ if (child.kind === "text") {
541
+ lines.push(`${prefix}${indent(depth)}${escapeText(child.text)}`);
542
+ continue;
543
+ }
544
+ renderNode(child, depth, lines, prefix);
545
+ }
546
+
547
+ if (children.length > MAX_CHILDREN_PER_PARENT) {
548
+ const truncated = children.slice(MAX_CHILDREN_PER_PARENT);
549
+ lines.push(
550
+ `${prefix}${indent(depth)}${renderChildrenTruncationNotice(
551
+ truncated,
552
+ )}`,
553
+ );
554
+ }
555
+ }
556
+
557
+ function renderFoldedSingleChildChain(
558
+ node: RenderedSnapshotNode,
559
+ depth: number,
560
+ lines: string[],
561
+ prefix: string,
562
+ ): boolean {
563
+ const chain = singleChildChain(node);
564
+ if (chain.length <= 1) return false;
565
+
566
+ const keptIndexes = chain
567
+ .map((chainNode, index) => ({ chainNode, index }))
568
+ .filter(({ chainNode, index }) =>
569
+ shouldKeepFoldedChainNode(chainNode, index),
570
+ )
571
+ .map(({ index }) => index);
572
+ if (keptIndexes.length === chain.length) return false;
573
+
574
+ renderFoldedChainNode(chain, keptIndexes, 0, depth, lines, prefix);
575
+ return true;
576
+ }
577
+
578
+ function shouldKeepFoldedChainNode(
579
+ node: RenderedSnapshotNode,
580
+ index: number,
581
+ ): boolean {
582
+ return index === 0 || node.role === "list";
583
+ }
584
+
585
+ function renderFoldedChainNode(
586
+ chain: RenderedSnapshotNode[],
587
+ keptIndexes: number[],
588
+ keptIndexPosition: number,
589
+ depth: number,
590
+ lines: string[],
591
+ prefix: string,
592
+ ): void {
593
+ const currentIndex = keptIndexes[keptIndexPosition]!;
594
+ const current = chain[currentIndex]!;
595
+ lines.push(
596
+ `${prefix}${indent(depth)}${formatTag(current.role, current.attrs, true)}`,
597
+ );
598
+ renderFoldedChainNodeOwnContent(current, depth + 1, lines, prefix);
599
+
600
+ const nextKeptIndex = keptIndexes[keptIndexPosition + 1];
601
+ if (nextKeptIndex !== undefined) {
602
+ if (nextKeptIndex > currentIndex + 1)
603
+ lines.push(`${prefix}${indent(depth + 1)}...`);
604
+ renderFoldedChainNode(
605
+ chain,
606
+ keptIndexes,
607
+ keptIndexPosition + 1,
608
+ depth + 1,
609
+ lines,
610
+ prefix,
611
+ );
612
+ } else {
613
+ const terminal = chain[chain.length - 1]!;
614
+ if (chain.length - 1 > currentIndex)
615
+ lines.push(`${prefix}${indent(depth + 1)}...`);
616
+ renderChildren(terminal.children, depth + 1, lines, prefix);
617
+ }
618
+
619
+ lines.push(`${prefix}${indent(depth)}</${current.role}>`);
620
+ }
621
+
622
+ function renderFoldedChainNodeOwnContent(
623
+ node: RenderedSnapshotNode,
624
+ depth: number,
625
+ lines: string[],
626
+ prefix: string,
627
+ ): void {
628
+ for (const child of node.children) {
629
+ if (child.kind === "text")
630
+ lines.push(`${prefix}${indent(depth)}${escapeText(child.text)}`);
631
+ }
632
+ }
633
+
634
+ function singleChildChain(node: RenderedSnapshotNode): RenderedSnapshotNode[] {
635
+ const chain = [node];
636
+ let current = node;
637
+
638
+ while (isDeprioritizedSingleChildParent(current)) {
639
+ const child = singleElementChild(current);
640
+ if (!child) break;
641
+ if (ACTION_ROLES.has(child.role)) break;
642
+ chain.push(child);
643
+ current = child;
644
+ }
645
+
646
+ return chain;
647
+ }
648
+
649
+ function isDeprioritizedSingleChildParent(node: RenderedSnapshotNode): boolean {
650
+ if (node.role === "document") return false;
651
+ if (ACTION_ROLES.has(node.role)) return false;
652
+ return singleElementChild(node) !== null;
653
+ }
654
+
655
+ function singleElementChild(
656
+ node: RenderedSnapshotNode,
657
+ ): RenderedSnapshotNode | null {
658
+ let result: RenderedSnapshotNode | null = null;
659
+ for (const child of node.children) {
660
+ if (child.kind === "text") continue;
661
+ if (result) return null;
662
+ result = child;
663
+ }
664
+ return result;
665
+ }
666
+
667
+ function shouldRenderBareText(node: RenderedSnapshotNode): boolean {
668
+ if (ACTION_ROLES.has(node.role)) return false;
669
+ if (attrValue(node, "ref")) return false;
670
+ if (PRESERVE_CHILDREN_BY_ROLE.has(node.role)) return false;
671
+ return true;
672
+ }
673
+
674
+ function nodeAttributes(
675
+ node: SnapshotNode,
676
+ suppressName: string | null,
677
+ ): Array<[string, string]> {
678
+ const attributes: Array<[string, string]> = [];
679
+ const usedNames = new Set<string>();
680
+ const push = (name: string, value: SnapshotPrimitive | undefined): void => {
681
+ if (value === undefined || value === null || value === "") return;
682
+ if (value === false || value === "false") return;
683
+ const normalizedName = uniqueAttributeName(
684
+ sanitizeAttributeName(name),
685
+ usedNames,
686
+ );
687
+ attributes.push([
688
+ normalizedName,
689
+ normalizeAttributeValue(normalizedName, value),
690
+ ]);
691
+ usedNames.add(normalizedName);
692
+ };
693
+
694
+ push("ref", node.ref);
695
+ if (node.name !== suppressName) push("name", node.name);
696
+
697
+ const hasStateValue =
698
+ node.properties.value !== undefined &&
699
+ node.properties.value !== null &&
700
+ node.properties.value !== "";
701
+ for (const name of RENDERED_STATE_PROPERTIES) {
702
+ const value = node.properties[name];
703
+ if (value === true) push(name, "true");
704
+ else push(name, value);
705
+ }
706
+
707
+ if (!hasStateValue) push("value", node.value);
708
+ push("href", node.attributes.href);
709
+ push("placeholder", node.attributes.placeholder);
710
+ return attributes;
711
+ }
712
+
713
+ function normalizeAttributeValue(
714
+ name: string,
715
+ value: SnapshotPrimitive,
716
+ ): string {
717
+ const normalized = normalizeRawText(String(value));
718
+ return name === "href" ? truncate(normalized, MAX_HREF_CHARS) : normalized;
719
+ }
720
+
721
+ function singleTextChild(node: RenderedSnapshotNode): string | null {
722
+ if (node.children.length !== 1) return null;
723
+ const child = node.children[0]!;
724
+ return child.kind === "text" && !child.block ? child.text : null;
725
+ }
726
+
727
+ function mergeAdjacentText(
728
+ children: RenderedSnapshotChild[],
729
+ ): RenderedSnapshotChild[] {
730
+ const result: RenderedSnapshotChild[] = [];
731
+ for (const child of children) {
732
+ const previous = result[result.length - 1];
733
+ if (
734
+ child.kind === "text" &&
735
+ previous?.kind === "text" &&
736
+ !child.block &&
737
+ !previous.block
738
+ ) {
739
+ previous.text = normalizeRawText(`${previous.text} ${child.text}`);
740
+ } else {
741
+ result.push(child);
742
+ }
743
+ }
744
+ return result;
745
+ }
746
+
747
+ function normalizedText(children: RenderedSnapshotChild[]): string {
748
+ return children
749
+ .map((child) =>
750
+ child.kind === "text" ? child.text : normalizedText(child.children),
751
+ )
752
+ .join(" ");
753
+ }
754
+
755
+ function previewForChildren(
756
+ children: RenderedSnapshotChild[],
757
+ excludedText: Set<string>,
758
+ ): string {
759
+ const labels: string[] = [];
760
+ const seen = new Set<string>();
761
+
762
+ function visit(
763
+ child: RenderedSnapshotChild,
764
+ insideInteractive: boolean,
765
+ ): void {
766
+ if (child.kind === "text") {
767
+ if (!insideInteractive) pushLabel(child.text);
768
+ return;
769
+ }
770
+
771
+ const nextInsideInteractive =
772
+ insideInteractive || ACTION_ROLES.has(child.role);
773
+ if (labels.join(" · ").length > MAX_SUMMARY_TEXT_CHARS) return;
774
+ for (const grandchild of child.children)
775
+ visit(grandchild, nextInsideInteractive);
776
+ }
777
+
778
+ function pushLabel(value: string | null): void {
779
+ const normalized = normalizeRawText(value ?? "");
780
+ if (
781
+ !normalized ||
782
+ normalized === "no visible text" ||
783
+ seen.has(normalized) ||
784
+ excludedText.has(normalized)
785
+ ) {
786
+ return;
787
+ }
788
+ seen.add(normalized);
789
+ labels.push(normalized);
790
+ }
791
+
792
+ for (const child of children) visit(child, false);
793
+ const preview = labels.join(" · ");
794
+ return preview ? truncate(preview, MAX_SUMMARY_TEXT_CHARS) : "";
795
+ }
796
+
797
+ function actionSummariesForChildren(children: RenderedSnapshotChild[]): {
798
+ actions: Array<{ markup: string; label: string | null }>;
799
+ labels: Set<string>;
800
+ hasMore: boolean;
801
+ } {
802
+ const actions: Array<{ markup: string; label: string | null }> = [];
803
+ const labels = new Set<string>();
804
+ const seenRefs = new Set<string>();
805
+ let hasMore = false;
806
+
807
+ function visit(child: RenderedSnapshotChild): void {
808
+ if (child.kind === "text") return;
809
+
810
+ const ref = attrValue(child, "ref");
811
+ if (ref && ACTION_ROLES.has(child.role) && !seenRefs.has(ref)) {
812
+ seenRefs.add(ref);
813
+ const label = actionLabel(child);
814
+ if (label) labels.add(label);
815
+ if (actions.length < MAX_ACTIONS_IN_SUMMARY) {
816
+ actions.push({ markup: renderActionSummary(child, ref), label });
817
+ } else {
818
+ hasMore = true;
819
+ }
820
+ }
821
+
822
+ for (const grandchild of child.children) visit(grandchild);
823
+ }
824
+
825
+ for (const child of children) visit(child);
826
+ return { actions, labels, hasMore };
827
+ }
828
+
829
+ function renderActionSummary(node: RenderedSnapshotNode, ref: string): string {
830
+ const label = actionLabel(node);
831
+ const attrs: Array<[string, string]> = [["ref", ref]];
832
+
833
+ for (const [name, value] of node.attrs) {
834
+ if (name === "ref" || !ACTION_STATE_ATTRS.has(name)) continue;
835
+ attrs.push([name, normalizeText(value, MAX_ACTION_LABEL_CHARS)]);
836
+ }
837
+
838
+ if (!label || !TEXT_ACTION_ROLES.has(node.role)) {
839
+ const name = attrValue(node, "name");
840
+ if (name) attrs.push(["name", normalizeText(name, MAX_ACTION_LABEL_CHARS)]);
841
+ return formatTag(node.role, attrs, false);
842
+ }
843
+
844
+ return `${formatTag(node.role, attrs, true)}${escapeText(
845
+ normalizeText(label, MAX_ACTION_LABEL_CHARS),
846
+ )}</${node.role}>`;
847
+ }
848
+
849
+ function actionLabel(node: RenderedSnapshotNode): string | null {
850
+ return firstNonEmpty(
851
+ singleTextChild(node),
852
+ attrValue(node, "name"),
853
+ attrValue(node, "value"),
854
+ attrValue(node, "placeholder"),
855
+ );
856
+ }
857
+
858
+ function attrValue(node: RenderedSnapshotNode, name: string): string | null {
859
+ return node.attrs.find(([attr]) => attr === name)?.[1] ?? null;
860
+ }
861
+
862
+ function attrFromAttrs(
863
+ attrs: Array<[string, string]>,
864
+ name: string,
865
+ ): string | null {
866
+ return attrs.find(([attr]) => attr === name)?.[1] ?? null;
867
+ }
868
+
869
+ function shouldSkipNode(
870
+ node: SnapshotNode,
871
+ parent: SnapshotNode | null,
872
+ ): boolean {
873
+ if (SKIP_ROLES.has(node.role)) return true;
874
+ if (node.role !== "StaticText") return false;
875
+ return Boolean(parent?.name && node.name && parent.name === node.name);
876
+ }
877
+
878
+ function isTextRole(role: string): boolean {
879
+ return role === "StaticText" || role === "InlineTextBox";
880
+ }
881
+
882
+ function isRenderedNode(
883
+ child: RenderedSnapshotChild,
884
+ ): child is RenderedSnapshotNode {
885
+ return child.kind === "node";
886
+ }
887
+
888
+ function hasVisibleTextOrInteractive(child: RenderedSnapshotChild): boolean {
889
+ if (child.kind === "text") return normalizeRawText(child.text) !== "";
890
+ if (ACTION_ROLES.has(child.role)) return true;
891
+ return child.children.some(hasVisibleTextOrInteractive);
892
+ }
893
+
894
+ function tagNameForRole(role: string): string {
895
+ const normalized = normalizeRole(role).replace(/[^a-zA-Z0-9_.:-]/g, "-");
896
+ return /^[a-zA-Z_:]/.test(normalized) ? normalized : "node";
897
+ }
898
+
899
+ function normalizeRole(role: string): string {
900
+ if (role === "RootWebArea") return "document";
901
+ if (role === "textField") return "textbox";
902
+ return role || "node";
903
+ }
904
+
905
+ function primitiveToString(value: SnapshotPrimitive): string | null {
906
+ return value === null ? null : String(value);
907
+ }
908
+
909
+ function nodeTextValue(node: SnapshotNode | null): string | null {
910
+ if (!node) return null;
911
+ const value = primitiveToString(node.properties.value ?? node.value);
912
+ return value ? normalizeRawText(value) : null;
913
+ }
914
+
915
+ function firstNonEmpty(
916
+ ...values: Array<string | null | undefined>
917
+ ): string | null {
918
+ for (const value of values) {
919
+ const normalized = normalizeRawText(value ?? "");
920
+ if (normalized) return truncate(normalized, MAX_LABEL_CHARS);
921
+ }
922
+ return null;
923
+ }
924
+
925
+ function normalizeText(value: string, maxChars: number): string {
926
+ return truncate(value.replace(/\s+/g, " ").trim(), maxChars);
927
+ }
928
+
929
+ function normalizeRawText(value: string): string {
930
+ return value.replace(/\s+/g, " ").trim();
931
+ }
932
+
933
+ function truncate(value: string, maxChars: number): string {
934
+ return value.length > maxChars ? `${value.slice(0, maxChars - 1)}…` : value;
935
+ }
936
+
937
+ function uniqueAttributeName(name: string, usedNames: Set<string>): string {
938
+ if (!usedNames.has(name)) return name;
939
+ let index = 2;
940
+ while (usedNames.has(`${name}-${index}`)) index += 1;
941
+ return `${name}-${index}`;
942
+ }
943
+
944
+ function sanitizeAttributeName(name: string): string {
945
+ const sanitized = name.replace(/[^a-zA-Z0-9_.:-]/g, "-");
946
+ return /^[a-zA-Z_:]/.test(sanitized) ? sanitized : `attr-${sanitized}`;
947
+ }
948
+
949
+ function escapeText(value: string): string {
950
+ return value
951
+ .replace(/&/g, "&amp;")
952
+ .replace(/</g, "&lt;")
953
+ .replace(/>/g, "&gt;");
954
+ }
955
+
956
+ function escapeAttribute(value: string): string {
957
+ return value
958
+ .replace(/&/g, "&amp;")
959
+ .replace(/"/g, "&quot;")
960
+ .replace(/</g, "&lt;")
961
+ .replace(/>/g, "&gt;");
962
+ }