libretto 0.6.11 → 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
@@ -5,10 +5,6 @@ import { resolveLibrettoRepoRoot } from "./repo-root.js";
5
5
  const LIBRETTO_DIRNAME = ".libretto";
6
6
  const LIBRETTO_SESSIONS_DIRNAME = "sessions";
7
7
  const SESSION_STATE_FILENAME = "state.json";
8
- const RUNNER_LOG_DIRNAME = "logs";
9
- const RUNNER_LOG_FILENAME = "logs.jsonl";
10
- const PAUSED_SIGNAL_SUFFIX = "paused";
11
- const RESUME_SIGNAL_SUFFIX = "resume";
12
8
 
13
9
  function getLibrettoRoot(cwd: string = process.cwd()): string {
14
10
  return join(resolveLibrettoRepoRoot(cwd), LIBRETTO_DIRNAME);
@@ -32,56 +28,6 @@ function getLibrettoSessionStatePath(
32
28
  return join(getLibrettoSessionDir(sessionName, cwd), SESSION_STATE_FILENAME);
33
29
  }
34
30
 
35
- export function getLibrettoPauseSignalDir(
36
- sessionName: string,
37
- cwd: string = process.cwd(),
38
- ): string {
39
- return getLibrettoSessionDir(sessionName, cwd);
40
- }
41
-
42
- function getLibrettoRunnerLogDir(
43
- sessionName: string,
44
- cwd: string = process.cwd(),
45
- ): string {
46
- return join(getLibrettoSessionDir(sessionName, cwd), RUNNER_LOG_DIRNAME);
47
- }
48
-
49
- export function getRunnerLogPathForDir(logDir: string): string {
50
- return join(logDir, RUNNER_LOG_FILENAME);
51
- }
52
-
53
- export function getPauseSignalPathForDir(
54
- signalDir: string,
55
- sessionName: string,
56
- signal: "paused" | "resume",
57
- ): string {
58
- const suffix =
59
- signal === "paused" ? PAUSED_SIGNAL_SUFFIX : RESUME_SIGNAL_SUFFIX;
60
- return join(signalDir, `${sessionName}.${suffix}`);
61
- }
62
-
63
- export function getLibrettoPausedSignalPath(
64
- sessionName: string,
65
- cwd: string = process.cwd(),
66
- ): string {
67
- return getPauseSignalPathForDir(
68
- getLibrettoPauseSignalDir(sessionName, cwd),
69
- sessionName,
70
- "paused",
71
- );
72
- }
73
-
74
- export function getLibrettoResumeSignalPath(
75
- sessionName: string,
76
- cwd: string = process.cwd(),
77
- ): string {
78
- return getPauseSignalPathForDir(
79
- getLibrettoPauseSignalDir(sessionName, cwd),
80
- sessionName,
81
- "resume",
82
- );
83
- }
84
-
85
31
  export function ensureLibrettoSessionStatePath(
86
32
  sessionName: string,
87
33
  cwd: string = process.cwd(),
@@ -90,21 +36,3 @@ export function ensureLibrettoSessionStatePath(
90
36
  mkdirSync(dirname(filePath), { recursive: true });
91
37
  return filePath;
92
38
  }
93
-
94
- export function ensureLibrettoPauseSignalDir(
95
- sessionName: string,
96
- cwd: string = process.cwd(),
97
- ): string {
98
- const dir = getLibrettoPauseSignalDir(sessionName, cwd);
99
- mkdirSync(dir, { recursive: true });
100
- return dir;
101
- }
102
-
103
- export function ensureLibrettoRunnerLogDir(
104
- sessionName: string,
105
- cwd: string = process.cwd(),
106
- ): string {
107
- const dir = getLibrettoRunnerLogDir(sessionName, cwd);
108
- mkdirSync(dir, { recursive: true });
109
- return dir;
110
- }
@@ -0,0 +1,615 @@
1
+ import type { CDPSession, Page } from "playwright";
2
+ import type { Snapshot, SnapshotNode, SnapshotPrimitive } from "./types.js";
3
+
4
+ export type {
5
+ Snapshot,
6
+ SnapshotAvailableFrame,
7
+ SnapshotFrame,
8
+ SnapshotNode,
9
+ SnapshotPrimitive,
10
+ SnapshotUnavailableFrame,
11
+ } from "./types.js";
12
+
13
+ const MAX_ATTRIBUTE_NODE_LOOKUPS = 300;
14
+
15
+ const REFS_BY_ROLE = new Set([
16
+ "RootWebArea",
17
+ "main",
18
+ "navigation",
19
+ "banner",
20
+ "contentinfo",
21
+ "form",
22
+ "search",
23
+ "article",
24
+ "section",
25
+ "region",
26
+ "heading",
27
+ "button",
28
+ "link",
29
+ "textbox",
30
+ "textField",
31
+ "checkbox",
32
+ "radio",
33
+ "switch",
34
+ "combobox",
35
+ "listbox",
36
+ "menuitem",
37
+ "tab",
38
+ "slider",
39
+ ]);
40
+
41
+ const INTERESTING_ATTRIBUTES = new Set([
42
+ "data-testid",
43
+ "data-test",
44
+ "data-qa",
45
+ "data-cy",
46
+ "id",
47
+ "name",
48
+ "type",
49
+ "placeholder",
50
+ "href",
51
+ "src",
52
+ "aria-label",
53
+ "aria-expanded",
54
+ "aria-pressed",
55
+ "aria-selected",
56
+ "aria-checked",
57
+ "role",
58
+ "title",
59
+ "alt",
60
+ "onclick",
61
+ "tabindex",
62
+ ]);
63
+
64
+ const STATE_PROPERTY_NAMES = [
65
+ "level",
66
+ "disabled",
67
+ "checked",
68
+ "expanded",
69
+ "selected",
70
+ "pressed",
71
+ "focused",
72
+ "required",
73
+ "invalid",
74
+ "readonly",
75
+ "multiline",
76
+ "autocomplete",
77
+ "haspopup",
78
+ "value",
79
+ ];
80
+
81
+ type RawAxProperty = {
82
+ name: string;
83
+ value: SnapshotPrimitive;
84
+ };
85
+
86
+ type RawAxNode = {
87
+ nodeId: string;
88
+ parentId: string | null;
89
+ ignored: boolean;
90
+ role: string;
91
+ name: string | null;
92
+ value: SnapshotPrimitive;
93
+ description: string | null;
94
+ properties: RawAxProperty[];
95
+ childIds: string[];
96
+ backendDOMNodeId: number | null;
97
+ };
98
+
99
+ type MutableSnapshotNode = Omit<SnapshotNode, "children"> & {
100
+ childIds: string[];
101
+ children: MutableSnapshotNode[];
102
+ parent: MutableSnapshotNode | null;
103
+ };
104
+
105
+ type FrameInfo = {
106
+ id: string;
107
+ url: string;
108
+ name: string | null;
109
+ parentId: string | null;
110
+ };
111
+
112
+ export async function snapshot(page: Page): Promise<Snapshot> {
113
+ const cdp = await page.context().newCDPSession(page);
114
+
115
+ try {
116
+ await enableIfSupported(cdp, "DOM.enable");
117
+ await enableIfSupported(cdp, "Accessibility.enable");
118
+ await enableIfSupported(cdp, "Runtime.enable");
119
+
120
+ const [title, frames] = await Promise.all([
121
+ page.title().catch(() => ""),
122
+ getFrameInfos(cdp),
123
+ ]);
124
+
125
+ const snapshotFrames: Snapshot["frames"] = [];
126
+ let nextRef = 1;
127
+ for (const [index, frame] of frames.entries()) {
128
+ const frameSnapshot = await captureFrameSnapshot(cdp, frame, index);
129
+ if (frameSnapshot.ok) {
130
+ nextRef = assignRefs(frameSnapshot.roots, nextRef);
131
+ snapshotFrames.push({
132
+ status: "ok",
133
+ id: frame.id,
134
+ index,
135
+ url: frame.url,
136
+ name: frame.name,
137
+ parentId: frame.parentId,
138
+ roots: frameSnapshot.roots.map(toSnapshotNode),
139
+ });
140
+ } else {
141
+ snapshotFrames.push({
142
+ status: "unavailable",
143
+ id: frame.id,
144
+ index,
145
+ url: frame.url,
146
+ name: frame.name,
147
+ parentId: frame.parentId,
148
+ error: frameSnapshot.error,
149
+ });
150
+ }
151
+ }
152
+
153
+ return { title, url: page.url(), frames: snapshotFrames };
154
+ } finally {
155
+ await cdp.detach().catch(() => {});
156
+ }
157
+ }
158
+
159
+ export function findSnapshotNodeByRef(
160
+ snapshotTree: Snapshot,
161
+ ref: string,
162
+ ): SnapshotNode {
163
+ const normalizedRef = normalizeRequestedRef(ref);
164
+ const matchingNode = findNodeByRef(snapshotTree, normalizedRef);
165
+ if (!matchingNode) {
166
+ throw new Error(`Snapshot ref "${ref}" was not found.`);
167
+ }
168
+ return matchingNode;
169
+ }
170
+
171
+ export function scopeSnapshotToRef(
172
+ snapshotTree: Snapshot,
173
+ ref: string,
174
+ ): Snapshot {
175
+ const matchingNode = findSnapshotNodeByRef(snapshotTree, ref);
176
+
177
+ return {
178
+ ...snapshotTree,
179
+ frames: snapshotTree.frames.flatMap((frame) => {
180
+ if (frame.status !== "ok") return [];
181
+ if (!frameContainsNode(frame.roots, matchingNode)) return [];
182
+ return [{ ...frame, roots: [matchingNode] }];
183
+ }),
184
+ };
185
+ }
186
+
187
+ function normalizeRequestedRef(ref: string): string {
188
+ return ref.trim();
189
+ }
190
+
191
+ function findNodeByRef(
192
+ snapshotTree: Snapshot,
193
+ ref: string,
194
+ ): SnapshotNode | null {
195
+ const exact = findNode(snapshotTree, (node) => node.ref === ref);
196
+ if (exact) return exact;
197
+
198
+ const numericSuffix = ref.match(/^[a-zA-Z]+(\d+)$/)?.[1];
199
+ if (!numericSuffix) return null;
200
+ return findNode(
201
+ snapshotTree,
202
+ (node) => node.ref?.match(/^[a-zA-Z]+(\d+)$/)?.[1] === numericSuffix,
203
+ );
204
+ }
205
+
206
+ function findNode(
207
+ snapshotTree: Snapshot,
208
+ predicate: (node: SnapshotNode) => boolean,
209
+ ): SnapshotNode | null {
210
+ for (const frame of snapshotTree.frames) {
211
+ if (frame.status !== "ok") continue;
212
+ for (const root of frame.roots) {
213
+ const node = findNodeInTree(root, predicate);
214
+ if (node) return node;
215
+ }
216
+ }
217
+ return null;
218
+ }
219
+
220
+ function findNodeInTree(
221
+ node: SnapshotNode,
222
+ predicate: (node: SnapshotNode) => boolean,
223
+ ): SnapshotNode | null {
224
+ if (predicate(node)) return node;
225
+ for (const child of node.children) {
226
+ const match = findNodeInTree(child, predicate);
227
+ if (match) return match;
228
+ }
229
+ return null;
230
+ }
231
+
232
+ function frameContainsNode(
233
+ roots: SnapshotNode[],
234
+ target: SnapshotNode,
235
+ ): boolean {
236
+ return roots.some((root) => findNodeInTree(root, (node) => node === target));
237
+ }
238
+
239
+ async function enableIfSupported(
240
+ cdp: CDPSession,
241
+ method: "DOM.enable" | "Accessibility.enable" | "Runtime.enable",
242
+ ): Promise<void> {
243
+ try {
244
+ await cdp.send(method);
245
+ } catch {
246
+ // Some Chromium targets do not support every domain for every frame target.
247
+ }
248
+ }
249
+
250
+ async function captureFrameSnapshot(
251
+ cdp: CDPSession,
252
+ frame: FrameInfo,
253
+ frameIndex: number,
254
+ ): Promise<
255
+ { ok: true; roots: MutableSnapshotNode[] } | { ok: false; error: string }
256
+ > {
257
+ try {
258
+ const response = await cdp.send("Accessibility.getFullAXTree", {
259
+ frameId: frame.id,
260
+ });
261
+ const rawNodes = parseAxNodes(response as unknown);
262
+ const attributeMap = await readAttributesByBackendNodeId(cdp, rawNodes);
263
+ return { ok: true, roots: buildSnapshotTree(rawNodes, attributeMap) };
264
+ } catch (error) {
265
+ if (frameIndex !== 0) {
266
+ return {
267
+ ok: false,
268
+ error: error instanceof Error ? error.message : String(error),
269
+ };
270
+ }
271
+
272
+ try {
273
+ const response = await cdp.send("Accessibility.getFullAXTree");
274
+ const rawNodes = parseAxNodes(response as unknown);
275
+ const attributeMap = await readAttributesByBackendNodeId(cdp, rawNodes);
276
+ return { ok: true, roots: buildSnapshotTree(rawNodes, attributeMap) };
277
+ } catch (fallbackError) {
278
+ return {
279
+ ok: false,
280
+ error:
281
+ fallbackError instanceof Error
282
+ ? fallbackError.message
283
+ : String(fallbackError),
284
+ };
285
+ }
286
+ }
287
+ }
288
+
289
+ async function getFrameInfos(cdp: CDPSession): Promise<FrameInfo[]> {
290
+ try {
291
+ const response = (await cdp.send("Page.getFrameTree")) as unknown;
292
+ const frames = parseFrameTree(response);
293
+ return frames.length > 0
294
+ ? frames
295
+ : [{ id: "main", url: "", name: null, parentId: null }];
296
+ } catch {
297
+ return [{ id: "main", url: "", name: null, parentId: null }];
298
+ }
299
+ }
300
+
301
+ function parseFrameTree(response: unknown): FrameInfo[] {
302
+ const root = readRecord(response).frameTree;
303
+ const frames: FrameInfo[] = [];
304
+
305
+ function visit(value: unknown, inheritedParentId: string | null): void {
306
+ const tree = readRecord(value);
307
+ const frame = readRecord(tree.frame);
308
+ const id = readString(frame.id);
309
+ if (!id) return;
310
+
311
+ frames.push({
312
+ id,
313
+ url: readString(frame.url) ?? "",
314
+ name: readString(frame.name),
315
+ parentId: readString(frame.parentId) ?? inheritedParentId,
316
+ });
317
+
318
+ const childFrames = Array.isArray(tree.childFrames) ? tree.childFrames : [];
319
+ for (const child of childFrames) visit(child, id);
320
+ }
321
+
322
+ visit(root, null);
323
+ return frames;
324
+ }
325
+
326
+ function parseAxNodes(response: unknown): RawAxNode[] {
327
+ const nodes = readRecord(response).nodes;
328
+ if (!Array.isArray(nodes)) return [];
329
+ return nodes.map(parseAxNode);
330
+ }
331
+
332
+ function parseAxNode(value: unknown): RawAxNode {
333
+ const record = readRecord(value);
334
+ const nodeId = readString(record.nodeId) ?? "";
335
+ const childIds = Array.isArray(record.childIds)
336
+ ? record.childIds.map(readString).filter((id): id is string => id !== null)
337
+ : [];
338
+
339
+ return {
340
+ nodeId,
341
+ parentId: readString(record.parentId),
342
+ ignored: readBoolean(record.ignored) ?? false,
343
+ role: readAxValueString(record.role) ?? "unknown",
344
+ name: readAxValueString(record.name),
345
+ value: readAxPrimitive(record.value),
346
+ description: readAxValueString(record.description),
347
+ properties: parseAxProperties(record.properties),
348
+ childIds,
349
+ backendDOMNodeId: readNumber(record.backendDOMNodeId),
350
+ };
351
+ }
352
+
353
+ function parseAxProperties(value: unknown): RawAxProperty[] {
354
+ if (!Array.isArray(value)) return [];
355
+ const properties: RawAxProperty[] = [];
356
+ for (const item of value) {
357
+ const record = readRecord(item);
358
+ const name = readString(record.name);
359
+ if (!name) continue;
360
+ properties.push({ name, value: readAxPrimitive(record.value) });
361
+ }
362
+ return properties;
363
+ }
364
+
365
+ async function readAttributesByBackendNodeId(
366
+ cdp: CDPSession,
367
+ nodes: RawAxNode[],
368
+ ): Promise<Map<number, Record<string, string>>> {
369
+ const backendNodeIds = unique(
370
+ nodes
371
+ .filter(shouldReadAttributes)
372
+ .sort((a, b) => attributeLookupPriority(a) - attributeLookupPriority(b))
373
+ .map((node) => node.backendDOMNodeId)
374
+ .filter((id): id is number => id !== null),
375
+ ).slice(0, MAX_ATTRIBUTE_NODE_LOOKUPS);
376
+
377
+ const result = new Map<number, Record<string, string>>();
378
+ await Promise.all(
379
+ backendNodeIds.map(async (backendNodeId) => {
380
+ const attributes = await readAttributesForBackendNodeId(
381
+ cdp,
382
+ backendNodeId,
383
+ );
384
+ if (Object.keys(attributes).length > 0) {
385
+ result.set(backendNodeId, attributes);
386
+ }
387
+ }),
388
+ );
389
+ return result;
390
+ }
391
+
392
+ function shouldReadAttributes(node: RawAxNode): boolean {
393
+ if (node.ignored) return false;
394
+ if (node.backendDOMNodeId === null) return false;
395
+ if (REFS_BY_ROLE.has(node.role)) return true;
396
+ if (node.role === "generic" || node.role === "group") return true;
397
+ if (node.name && node.name.trim().length > 0) return true;
398
+ return node.properties.some((property) =>
399
+ STATE_PROPERTY_NAMES.includes(property.name),
400
+ );
401
+ }
402
+
403
+ function attributeLookupPriority(node: RawAxNode): number {
404
+ if (REFS_BY_ROLE.has(node.role)) return 0;
405
+ if (node.name && node.name.trim().length > 0) return 1;
406
+ if (
407
+ node.properties.some((property) =>
408
+ STATE_PROPERTY_NAMES.includes(property.name),
409
+ )
410
+ ) {
411
+ return 1;
412
+ }
413
+ return 2;
414
+ }
415
+
416
+ async function readAttributesForBackendNodeId(
417
+ cdp: CDPSession,
418
+ backendNodeId: number,
419
+ ): Promise<Record<string, string>> {
420
+ try {
421
+ const response = (await cdp.send("DOM.describeNode", {
422
+ backendNodeId,
423
+ depth: 0,
424
+ pierce: false,
425
+ })) as unknown;
426
+ const node = readRecord(readRecord(response).node);
427
+ const rawAttributes = Array.isArray(node.attributes) ? node.attributes : [];
428
+ const attributes: Record<string, string> = {};
429
+ for (let i = 0; i < rawAttributes.length - 1; i += 2) {
430
+ const name = readString(rawAttributes[i]);
431
+ const value = readString(rawAttributes[i + 1]);
432
+ if (name && value !== null && INTERESTING_ATTRIBUTES.has(name)) {
433
+ if (name === "tabindex" && Number(value) < 0) continue;
434
+ attributes[name] = value;
435
+ }
436
+ }
437
+ const cursor = await readComputedCursorForBackendNodeId(cdp, backendNodeId);
438
+ if (cursor === "pointer") attributes.cursor = cursor;
439
+ return attributes;
440
+ } catch {
441
+ return {};
442
+ }
443
+ }
444
+
445
+ async function readComputedCursorForBackendNodeId(
446
+ cdp: CDPSession,
447
+ backendDOMNodeId: number,
448
+ ): Promise<string | null> {
449
+ let objectId: string | null = null;
450
+ try {
451
+ const resolved = (await cdp.send("DOM.resolveNode", {
452
+ backendNodeId: backendDOMNodeId,
453
+ })) as unknown;
454
+ objectId = readString(readRecord(readRecord(resolved).object).objectId);
455
+ if (!objectId) return null;
456
+
457
+ const response = (await cdp.send("Runtime.callFunctionOn", {
458
+ objectId,
459
+ functionDeclaration:
460
+ "function() { return getComputedStyle(this).cursor; }",
461
+ returnByValue: true,
462
+ silent: true,
463
+ })) as unknown;
464
+ return readString(readRecord(readRecord(response).result).value);
465
+ } catch {
466
+ return null;
467
+ } finally {
468
+ if (objectId) {
469
+ await cdp.send("Runtime.releaseObject", { objectId }).catch(() => {});
470
+ }
471
+ }
472
+ }
473
+
474
+ function buildSnapshotTree(
475
+ rawNodes: RawAxNode[],
476
+ attributesByBackendNodeId: Map<number, Record<string, string>>,
477
+ ): MutableSnapshotNode[] {
478
+ const byId = new Map<string, MutableSnapshotNode>();
479
+ const childIds = new Set<string>();
480
+
481
+ for (const rawNode of rawNodes) {
482
+ if (!rawNode.nodeId) continue;
483
+ byId.set(rawNode.nodeId, {
484
+ nodeId: rawNode.nodeId,
485
+ ignored: rawNode.ignored,
486
+ role: rawNode.role,
487
+ name: rawNode.name,
488
+ value: rawNode.value,
489
+ description: rawNode.description,
490
+ properties: Object.fromEntries(
491
+ rawNode.properties.map((property) => [property.name, property.value]),
492
+ ),
493
+ attributes:
494
+ rawNode.backendDOMNodeId !== null
495
+ ? (attributesByBackendNodeId.get(rawNode.backendDOMNodeId) ?? {})
496
+ : {},
497
+ childIds: rawNode.childIds,
498
+ children: [],
499
+ parent: null,
500
+ ref: null,
501
+ subtreeSize: 1,
502
+ });
503
+ }
504
+
505
+ for (const node of byId.values()) {
506
+ for (const childId of node.childIds) {
507
+ const child = byId.get(childId);
508
+ if (!child) continue;
509
+ child.parent = node;
510
+ node.children.push(child);
511
+ childIds.add(childId);
512
+ }
513
+ }
514
+
515
+ for (const rawNode of rawNodes) {
516
+ if (!rawNode.parentId || childIds.has(rawNode.nodeId)) continue;
517
+ const node = byId.get(rawNode.nodeId);
518
+ const parent = byId.get(rawNode.parentId);
519
+ if (!node || !parent) continue;
520
+ node.parent = parent;
521
+ parent.children.push(node);
522
+ childIds.add(rawNode.nodeId);
523
+ }
524
+
525
+ const roots = [...byId.values()].filter((node) => !childIds.has(node.nodeId));
526
+ for (const root of roots) annotateSubtreeSize(root);
527
+ return roots;
528
+ }
529
+
530
+ function annotateSubtreeSize(node: MutableSnapshotNode): number {
531
+ node.subtreeSize = 1;
532
+ for (const child of node.children) {
533
+ node.subtreeSize += annotateSubtreeSize(child);
534
+ }
535
+ return node.subtreeSize;
536
+ }
537
+
538
+ function assignRefs(nodes: MutableSnapshotNode[], nextRef: number): number {
539
+ for (const node of nodes) {
540
+ if (shouldAssignRef(node)) {
541
+ node.ref = `l${nextRef}`;
542
+ nextRef += 1;
543
+ }
544
+ nextRef = assignRefs(node.children, nextRef);
545
+ }
546
+ return nextRef;
547
+ }
548
+
549
+ function toSnapshotNode(node: MutableSnapshotNode): SnapshotNode {
550
+ return {
551
+ nodeId: node.nodeId,
552
+ ignored: node.ignored,
553
+ role: node.role,
554
+ name: node.name,
555
+ value: node.value,
556
+ description: node.description,
557
+ properties: node.properties,
558
+ attributes: node.attributes,
559
+ children: node.children.map(toSnapshotNode),
560
+ ref: node.ref,
561
+ subtreeSize: node.subtreeSize,
562
+ };
563
+ }
564
+
565
+ function shouldAssignRef(node: MutableSnapshotNode): boolean {
566
+ if (node.ignored) return false;
567
+ if (node.role === "StaticText" || node.role === "InlineTextBox") return false;
568
+ if (node.role === "none" || node.role === "presentation") return false;
569
+ if (REFS_BY_ROLE.has(node.role)) return true;
570
+ if (Object.keys(node.attributes).length > 0) return true;
571
+ return Boolean(node.name);
572
+ }
573
+
574
+ function readRecord(value: unknown): Record<string, unknown> {
575
+ return typeof value === "object" && value !== null
576
+ ? (value as Record<string, unknown>)
577
+ : {};
578
+ }
579
+
580
+ function readString(value: unknown): string | null {
581
+ return typeof value === "string" ? value : null;
582
+ }
583
+
584
+ function readNumber(value: unknown): number | null {
585
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
586
+ }
587
+
588
+ function readBoolean(value: unknown): boolean | null {
589
+ return typeof value === "boolean" ? value : null;
590
+ }
591
+
592
+ function readAxValueString(value: unknown): string | null {
593
+ const rawValue = readRecord(value).value;
594
+ if (typeof rawValue === "string") return rawValue;
595
+ if (typeof rawValue === "number" || typeof rawValue === "boolean") {
596
+ return String(rawValue);
597
+ }
598
+ return null;
599
+ }
600
+
601
+ function readAxPrimitive(value: unknown): SnapshotPrimitive {
602
+ const rawValue = readRecord(value).value;
603
+ if (
604
+ typeof rawValue === "string" ||
605
+ typeof rawValue === "number" ||
606
+ typeof rawValue === "boolean"
607
+ ) {
608
+ return rawValue;
609
+ }
610
+ return null;
611
+ }
612
+
613
+ function unique(values: number[]): number[] {
614
+ return [...new Set(values)];
615
+ }