@crouton-kit/capture 1.3.7 → 1.3.9

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 (141) hide show
  1. package/bin/capture +32786 -9989
  2. package/package.json +9 -3
  3. package/src/capture.ts +86 -163
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -116
  6. package/src/cdp/bridge/protocol.ts +187 -1
  7. package/src/cdp/bridge/server.ts +211 -84
  8. package/src/cdp/bridge/spawn.ts +110 -1
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +35 -4
  11. package/src/cdp/commands/cdp.ts +266 -69
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +87 -0
  15. package/src/cdp/commands/measure/check.ts +109 -0
  16. package/src/cdp/commands/measure/diff.ts +120 -0
  17. package/src/cdp/commands/measure/explain.ts +133 -0
  18. package/src/cdp/commands/measure/index.ts +106 -0
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +52 -0
  21. package/src/cdp/commands/measure/map-layers.ts +59 -0
  22. package/src/cdp/commands/measure/map-scroll.ts +94 -0
  23. package/src/cdp/commands/measure/snap.ts +416 -0
  24. package/src/cdp/commands/measure/sweep.ts +227 -0
  25. package/src/cdp/commands/motion/index.ts +61 -0
  26. package/src/cdp/commands/motion/jank.ts +132 -0
  27. package/src/cdp/commands/motion/mask.ts +92 -0
  28. package/src/cdp/commands/motion/rec.ts +534 -0
  29. package/src/cdp/commands/motion/response.ts +76 -0
  30. package/src/cdp/commands/motion/timeline.ts +152 -0
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/coordinates.ts +158 -0
  46. package/src/cdp/detect.ts +42 -13
  47. package/src/cdp/dispatch.ts +76 -26
  48. package/src/cdp/domains.ts +75 -0
  49. package/src/cdp/har-recorder.ts +648 -115
  50. package/src/cdp/leaf-grammar.ts +99 -0
  51. package/src/cdp/measure/census.ts +205 -0
  52. package/src/cdp/measure/check.ts +318 -0
  53. package/src/cdp/measure/collectors/animation.ts +385 -0
  54. package/src/cdp/measure/collectors/ax.ts +278 -0
  55. package/src/cdp/measure/collectors/focus.ts +1041 -0
  56. package/src/cdp/measure/collectors/forms.ts +905 -0
  57. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  58. package/src/cdp/measure/collectors/hittest.ts +835 -0
  59. package/src/cdp/measure/collectors/layers.ts +697 -0
  60. package/src/cdp/measure/collectors/media.ts +522 -0
  61. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  62. package/src/cdp/measure/collectors/queries.ts +464 -0
  63. package/src/cdp/measure/collectors/scroll.ts +599 -0
  64. package/src/cdp/measure/collectors/states.ts +992 -0
  65. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  66. package/src/cdp/measure/collectors/styles.ts +355 -0
  67. package/src/cdp/measure/collectors/text.ts +713 -0
  68. package/src/cdp/measure/diff.ts +470 -0
  69. package/src/cdp/measure/explain.ts +498 -0
  70. package/src/cdp/measure/map-ax.ts +633 -0
  71. package/src/cdp/measure/map-focus.ts +188 -0
  72. package/src/cdp/measure/map-layers.ts +177 -0
  73. package/src/cdp/measure/map-scroll.ts +317 -0
  74. package/src/cdp/measure/redaction.ts +51 -0
  75. package/src/cdp/measure/settle.ts +1051 -0
  76. package/src/cdp/measure/snapshot.ts +390 -0
  77. package/src/cdp/measure/sweep.ts +330 -0
  78. package/src/cdp/measure/types.ts +220 -0
  79. package/src/cdp/measure/v2/collectors.ts +19 -0
  80. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  81. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  82. package/src/cdp/measure/v2/target-lock.ts +15 -0
  83. package/src/cdp/measure/v2/target.ts +16 -0
  84. package/src/cdp/motion/jank.ts +433 -0
  85. package/src/cdp/motion/mask.ts +421 -0
  86. package/src/cdp/motion/recorder.ts +764 -0
  87. package/src/cdp/motion/response.ts +356 -0
  88. package/src/cdp/motion/timeline.ts +215 -0
  89. package/src/cdp/record.ts +5 -212
  90. package/src/cdp/recorder-bridge.ts +1894 -0
  91. package/src/cdp/recorder-client.ts +326 -0
  92. package/src/cdp/scope-lock.ts +73 -0
  93. package/src/cdp/screenshot.ts +199 -66
  94. package/src/cdp/source-map.ts +309 -0
  95. package/src/cdp/targets.ts +21 -1
  96. package/src/cdp/timing.ts +87 -0
  97. package/src/cdp/types.ts +87 -6
  98. package/src/cdp/viewport.ts +21 -0
  99. package/src/cdp.ts +12 -58
  100. package/src/command-help.ts +29 -0
  101. package/src/command-resolution.ts +22 -0
  102. package/src/command-walker.ts +35 -0
  103. package/src/contracts/a11y.ts +33 -0
  104. package/src/contracts/cursor.ts +35 -0
  105. package/src/contracts/index.ts +15 -0
  106. package/src/contracts/motion.ts +19 -0
  107. package/src/contracts/neutral.ts +56 -0
  108. package/src/contracts/primitives.ts +205 -0
  109. package/src/contracts/release.ts +17 -0
  110. package/src/contracts/results.ts +208 -0
  111. package/src/contracts/routes.ts +140 -0
  112. package/src/contracts/snapshot.ts +92 -0
  113. package/src/cursor/index.ts +13 -0
  114. package/src/cursor/service.ts +310 -0
  115. package/src/errors.ts +71 -0
  116. package/src/har-manager.ts +992 -41
  117. package/src/interact.ts +410 -98
  118. package/src/output/artifact-lifecycle.ts +371 -0
  119. package/src/output/artifact.ts +526 -0
  120. package/src/output/diff.ts +111 -0
  121. package/src/output/exact-raw.ts +114 -0
  122. package/src/output/render.ts +987 -0
  123. package/src/output/selector.ts +185 -0
  124. package/src/output/structured.ts +211 -0
  125. package/src/registry.ts +208 -0
  126. package/src/session/artifacts.ts +481 -0
  127. package/src/session/commands.ts +1237 -368
  128. package/src/session/coordinator.ts +251 -0
  129. package/src/session/har-filter.ts +57 -0
  130. package/src/session/log-tailer.ts +653 -0
  131. package/src/session-context.ts +302 -34
  132. package/src/vault/bundle.ts +32 -17
  133. package/src/cdp/commands/exec.ts +0 -105
  134. package/src/cdp/commands/har.ts +0 -128
  135. package/src/cdp/commands/tabs.ts +0 -183
  136. package/src/cdp/commands/traffic.ts +0 -121
  137. package/src/cdp/commands/ui.ts +0 -172
  138. package/src/cdp/execute.ts +0 -113
  139. package/src/cdp/har-output.ts +0 -47
  140. package/src/cdp/usage.ts +0 -77
  141. package/src/tab-session.ts +0 -91
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.3.7",
3
+ "version": "1.3.9",
4
4
  "description": "Browser automation and UI validation via CDP — session-based screenshots, HAR, a11y, click/type, and JS exec",
5
5
  "bin": {
6
6
  "capture": "./bin/capture"
@@ -10,9 +10,12 @@
10
10
  "src"
11
11
  ],
12
12
  "scripts": {
13
- "build": "esbuild src/capture.ts --bundle --platform=node --format=cjs --outfile=bin/capture --external:esbuild '--banner:js=#!/usr/bin/env node' \"--define:globalThis.__CAPTURE_VERSION__=\\\"$npm_package_version\\\"\" && chmod +x bin/capture",
13
+ "build": "node scripts/build.mjs",
14
+ "check:bin": "node scripts/build.mjs --check",
14
15
  "dev": "tsx src/capture.ts",
15
- "test": "node --import tsx --test test/*.test.ts"
16
+ "test": "node scripts/test.mjs",
17
+ "test:live": "node scripts/test.mjs --live",
18
+ "test:install-chrome": "npx --yes @puppeteer/browsers install chrome-headless-shell@143.0.7499.40"
16
19
  },
17
20
  "repository": {
18
21
  "type": "git",
@@ -33,6 +36,7 @@
33
36
  "access": "public"
34
37
  },
35
38
  "devDependencies": {
39
+ "@types/pngjs": "^6.0.5",
36
40
  "@types/ws": "^8.18.1",
37
41
  "esbuild": "^0.24.0",
38
42
  "tsx": "^4",
@@ -40,6 +44,8 @@
40
44
  "zod": "^4.3.5"
41
45
  },
42
46
  "dependencies": {
47
+ "pixelmatch": "7.2.0",
48
+ "pngjs": "7.0.0",
43
49
  "ws": "^8.19.0"
44
50
  }
45
51
  }
package/src/capture.ts CHANGED
@@ -1,180 +1,103 @@
1
1
  /**
2
- * Capture — unified CLI for session management and CDP browser automation.
2
+ * capture — browser automation and UI measurement over CDP.
3
3
  *
4
- * Session commands (namespaced):
5
- * capture session start [--url <url>]
6
- * capture session stop <session-id>
7
- * capture session list
8
- * capture session view <session-id> [--filter screenshots|har|a11y]
9
- * capture log <path> [--name label]
10
- *
11
- * CDP commands (top-level):
12
- * capture detect Detect CDP port
13
- * capture exec <code> Execute JS in a tab
14
- * capture list List browser tabs
15
- * capture open <url> Open URL in browser
16
- * capture screenshot Capture screenshot
17
- * capture a11y Get accessibility tree
18
- * capture record Passive HAR recording
19
- * capture navigate <url> Navigate + record HAR
20
- * capture har create|read|delete Manage HAR recordings
4
+ * Root router: seven visible roots (session, page, tab, measure, motion,
5
+ * cdp, lib) plus the hidden `__bridge-serve` and `__log-tail-serve`
6
+ * internals, all dispatched below. Root help is assembled from each branch's exported
7
+ * `COMMAND_BLOCK` — the parent walks its children, it never hardcodes a
8
+ * child's description. An unknown first token is a structured
9
+ * `<error code="unknown_command">` (exit 1), never help text.
21
10
  */
22
11
 
23
- import * as fs from "fs";
24
- import * as path from "path";
25
- import { cdpMain } from "./cdp.js";
26
- import { sessionMain, logCommand } from "./session/commands.js";
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ import { cdpMain } from './cdp.js';
15
+ import { captureError, failureResult } from './errors.js';
16
+ import { emitResult } from './output/render.js';
17
+ import { COMMAND_BLOCK as SESSION_BLOCK } from './session/commands.js';
18
+ import { COMMAND_BLOCK as PAGE_BLOCK } from './cdp/commands/page/index.js';
19
+ import { COMMAND_BLOCK as TAB_BLOCK } from './cdp/commands/tab/index.js';
20
+ import { COMMAND_BLOCK as MEASURE_BLOCK } from './cdp/commands/measure/index.js';
21
+ import { COMMAND_BLOCK as MOTION_BLOCK } from './cdp/commands/motion/index.js';
22
+ import { COMMAND_BLOCK as CDP_BLOCK } from './cdp/commands/cdp.js';
23
+ import { COMMAND_BLOCK as LIB_BLOCK } from './cdp/commands/lib.js';
24
+
25
+ /** The seven visible root children, in help order. */
26
+ const ROOTS = ['session', 'page', 'tab', 'measure', 'motion', 'cdp', 'lib'] as const;
27
+
28
+ const ROOT_BLOCKS = [
29
+ SESSION_BLOCK,
30
+ PAGE_BLOCK,
31
+ TAB_BLOCK,
32
+ MEASURE_BLOCK,
33
+ MOTION_BLOCK,
34
+ CDP_BLOCK,
35
+ LIB_BLOCK,
36
+ ] as const;
37
+
38
+ function rootHelp(): string {
39
+ return `capture — browser automation and UI measurement over CDP.
40
+
41
+ ${ROOT_BLOCKS.join('\n\n')}
42
+
43
+ I/O contract: flags and positionals on input; one rendered prose block on
44
+ stdout. --json mirrors the same result as JSON, but the rendered block is
45
+ the contract. stderr carries in-flight diagnostics only.
46
+
47
+ A CDP-enabled browser must be running; \`capture tab list\` is the probe.
48
+ CDP_PORT / CDP_TARGET pin the browser + tab for orchestrators — precedence:
49
+ explicit flag > active session > env.`;
50
+ }
27
51
 
28
- // ============================================================================
29
- // CLI
30
- // ============================================================================
52
+ function printVersion(): void {
53
+ // Version is injected at build time via esbuild's --define flag.
54
+ // Falls back to reading package.json at runtime if not injected.
55
+ const declared = (globalThis as { __CAPTURE_VERSION__?: string }).__CAPTURE_VERSION__;
56
+ if (declared) {
57
+ console.log(declared);
58
+ return;
59
+ }
60
+ try {
61
+ const pkgPath = path.resolve(__dirname, '..', 'package.json');
62
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as { version: string };
63
+ console.log(pkg.version);
64
+ } catch {
65
+ console.log('unknown');
66
+ }
67
+ }
31
68
 
32
69
  async function main(): Promise<void> {
33
- const [command, ...args] = process.argv.slice(2);
70
+ const [command] = process.argv.slice(2);
34
71
 
35
- if (command === "--version" || command === "-v" || command === "version") {
36
- // Version is injected at build time via esbuild's --define flag.
37
- // Falls back to reading package.json at runtime if not injected.
38
- const declared = (globalThis as { __CAPTURE_VERSION__?: string }).__CAPTURE_VERSION__;
39
- if (declared) {
40
- console.log(declared);
41
- return;
42
- }
43
- try {
44
- const pkgPath = path.resolve(__dirname, "..", "package.json");
45
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version: string };
46
- console.log(pkg.version);
47
- } catch {
48
- console.log("unknown");
49
- }
72
+ if (command === '--version') {
73
+ printVersion();
50
74
  return;
51
75
  }
52
76
 
53
- switch (command) {
54
- case "session":
55
- return sessionMain(args);
56
-
57
- case "log":
58
- return logCommand(args);
59
-
60
- // CDP commands — delegate to cdp.ts
61
- case "detect":
62
- case "exec":
63
- case "open":
64
- case "reset-tab":
65
- case "screenshot":
66
- case "click":
67
- case "type":
68
- case "a11y":
69
- case "record":
70
- case "navigate":
71
- case "har":
72
- case "lib":
73
- case "network":
74
- case "list":
75
- case "cdp":
76
- case "__bridge-serve":
77
- return cdpMain();
78
-
79
- default:
80
- console.log(`Capture — browser automation over CDP for validating UI features.
81
-
82
- Most tasks start with a session. Once a session is active, every command
83
- auto-fills --target (the tab) and --har (the recording) — don't thread
84
- those flags yourself.
85
-
86
- TYPICAL WORKFLOW
87
-
88
- 1. Start a session (opens tab, starts HAR, marks it active):
89
- capture session start --url http://localhost:3000
90
-
91
- 2. Interact — no --target / --har needed:
92
- capture a11y --interactive See what's on the page
93
- capture click "Sign in" Click by accessible name (auto-screenshots)
94
- capture type "hi@me.com" --into "Email"
95
- capture screenshot Save current state
96
- capture navigate https://... Navigate within the session
97
- capture exec "document.title" Run JS (expressions, return, await)
98
- capture har read --filter-url /api Inspect recorded traffic
99
-
100
- 3. Bundle and inspect:
101
- capture session stop <session-id>
102
- capture session view <session-id>
103
-
104
- SESSION COMMANDS
105
-
106
- session start [--url <url>] Start a session
107
- session stop <session-id> Finalize and bundle artifacts
108
- session list List active and stopped sessions
109
- session view <id> [--filter section] section = screenshots|har|a11y|logs
110
- log <path> [--name label] Tail a log file into the active session
111
-
112
- INTERACTION COMMANDS (work inside or outside a session)
113
-
114
- a11y [--interactive] [--json] Accessibility tree (use this first to see elements)
115
- click "name" [--role <role>] Click by accessible name
116
- type "text" [--into "Field"] Type into focused element or named field
117
- screenshot [--out <path>] [--full-page] Screenshot (viewport: desktop|desktop-wide|tablet|mobile)
118
- exec <code> | exec --file <path> Evaluate JS; expressions, return, and await are supported
119
- navigate <url> [--settle <ms>] Navigate the current tab + record HAR
120
-
121
- DIAGNOSTICS & ONE-OFFS (no session needed)
122
-
123
- detect Find running CDP endpoints
124
- list List open tabs across endpoints
125
- open <url> [--new] Open a URL, return its tab id
126
- reset-tab <url> Abandon a stuck tab, open fresh (updates session)
127
- record [--duration <secs>] Passive HAR recording; parallel-safe capture
128
- network <offline|online> Toggle connectivity for a tab
129
- har create | read [id] | delete <id> Manage standalone HAR recordings
130
-
131
- RAW CDP ESCAPE HATCH
132
-
133
- cdp <Domain.method> [--params json] Send any CDP command capture doesn't wrap
134
- [--browser] Target the browser connection (Browser.*, ServiceWorker.*)
135
- [--wait-event name] Wait for a CDP event, e.g. after ServiceWorker.enable
136
-
137
- Browser-level state (permission grants, ServiceWorker enablement) reverts
138
- the instant its connection closes. session start --hold keeps ONE browser
139
- connection open across commands so that state survives between them:
140
-
141
- capture session start --url http://localhost:3000 --hold
142
- capture cdp Browser.grantPermissions --browser --params '{"origin":"...","permissions":["notifications"]}'
143
- capture cdp ServiceWorker.enable --browser --target <pageTabId>
144
- capture cdp ServiceWorker.deliverPushMessage --browser --target <pageTabId> --params '{...}'
145
- capture session stop <session-id>
146
-
147
- capture cdp --help Full reference
148
-
149
- LIBRARY (vault libs — dev checkout only)
150
-
151
- lib list List available libs
152
- lib search "<query>" [--limit N] Fuzzy-search functions across libs
153
- lib show <name> Lib + function summaries
154
- lib read <name> [fn…] Full input/output schemas (+ .ts source path)
155
- exec "import {fn} from 'libs/<name>'; return await fn({…})" Run a lib in the active tab
156
-
157
- TARGETING (only when NOT in a session, or picking a parallel tab)
158
-
159
- CDP_PORT / CDP_TARGET Orchestrators can pin the active browser + tab
160
- --target <tabId> Exact id; a prefix of 8 chars is enough (preferred, parallel-safe)
161
- --url <pattern> Fuzzy URL match against open tabs
162
- NOTE: on \`session start\`, --url is a URL to OPEN, not a pattern.
163
-
164
- HELP
77
+ if (command === undefined || command === '-h') {
78
+ console.log(rootHelp());
79
+ return;
80
+ }
165
81
 
166
- capture <command> --help Per-command usage, e.g. capture click --help, capture har --help
167
- capture --version Print version
82
+ if (command === '__log-tail-serve') {
83
+ // Internal self-spawn target for `session log`'s detached tailer worker.
84
+ // Not a visible root: absent from ROOTS, help, and every COMMAND_BLOCK.
85
+ const { runLogTailer } = await import('./session/log-tailer.js');
86
+ return runLogTailer(process.argv.slice(3));
87
+ }
168
88
 
169
- PREREQ — a browser with CDP must be running:
170
- Arc enabled by default
171
- Chrome / Chromium --remote-debugging-port=9222
172
- Electron apps CDP exposed automatically
173
- Verify with: capture detect`);
89
+ if ((ROOTS as readonly string[]).includes(command) || command === '__bridge-serve') {
90
+ return cdpMain();
174
91
  }
92
+
93
+ throw captureError(
94
+ 'invocation',
95
+ 'unknown_command',
96
+ `Unknown command ${command}; expected one of the seven roots: session, page, tab, measure, motion, cdp, lib.`,
97
+ );
175
98
  }
176
99
 
177
- main().catch((err) => {
178
- console.error(err.message);
179
- process.exit(1);
100
+ main().catch((error) => {
101
+ emitResult(failureResult(error), { json: process.argv.includes('--json') });
102
+ process.exitCode = 1;
180
103
  });
package/src/cdp/a11y.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { type CDPClient } from './client.js';
1
+ import { captureError } from '../errors.js';
2
+ import { withScopeSerialization } from './scope-lock.js';
2
3
 
3
4
  export const INTERACTIVE_ROLES = new Set([
4
5
  'button',
@@ -49,12 +50,18 @@ export function flattenA11yTree(
49
50
  return lines;
50
51
  }
51
52
 
52
- interface AXNode {
53
+ export interface AccessibilityClient {
54
+ send(method: string, params?: Record<string, unknown>): Promise<unknown>;
55
+ }
56
+
57
+ /** The fields consumed from a node returned by `Accessibility.getFullAXTree`. */
58
+ export interface FullAXNode {
53
59
  nodeId: string;
60
+ backendDOMNodeId?: number;
61
+ ignored?: boolean;
54
62
  role?: { value: string };
55
63
  name?: { value: string };
56
64
  childIds?: string[];
57
- properties?: Array<{ name: string; value: { value: unknown } }>;
58
65
  }
59
66
 
60
67
  export interface A11yNode {
@@ -63,23 +70,115 @@ export interface A11yNode {
63
70
  children: A11yNode[];
64
71
  }
65
72
 
73
+ function isRecord(value: unknown): value is Record<string, unknown> {
74
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
75
+ }
76
+
77
+ function isAxStringValue(value: unknown): value is { value: string } {
78
+ return isRecord(value) && typeof value.value === 'string';
79
+ }
80
+
81
+ function isFullAXNode(value: unknown): value is FullAXNode {
82
+ if (!isRecord(value) || typeof value.nodeId !== 'string') return false;
83
+ if (
84
+ value.backendDOMNodeId !== undefined &&
85
+ (!Number.isSafeInteger(value.backendDOMNodeId) || (value.backendDOMNodeId as number) < 0)
86
+ ) return false;
87
+ if (value.ignored !== undefined && typeof value.ignored !== 'boolean') return false;
88
+ if (value.role !== undefined && !isAxStringValue(value.role)) return false;
89
+ if (value.name !== undefined && !isAxStringValue(value.name)) return false;
90
+ if (
91
+ value.childIds !== undefined &&
92
+ (!Array.isArray(value.childIds) || !value.childIds.every((id) => typeof id === 'string'))
93
+ ) return false;
94
+ return true;
95
+ }
96
+
97
+ /**
98
+ * Reads one complete AX tree with call-scoped Accessibility ownership.
99
+ *
100
+ * The Accessibility domain's enabled state lives on the connection: a direct
101
+ * connection's state dies with its own websocket session, but the
102
+ * recorder-held connection is shared across commands (its bridge handles
103
+ * each CDP request independently — it serializes nothing), so the entire
104
+ * enable→read→disable scope serializes under the owning session's
105
+ * `.ax-scope.lock` (`withScopeSerialization`) when the client is the
106
+ * recorder-held adapter — otherwise a concurrent routed caller could
107
+ * disable this read's domain mid-flight.
108
+ */
109
+ export async function readFullAXTree(client: AccessibilityClient): Promise<FullAXNode[]> {
110
+ return withScopeSerialization(client, 'ax', 'full AX tree read', () => axScopedRead(client));
111
+ }
112
+
113
+ /**
114
+ * The one Accessibility state transaction: enable → read → disable.
115
+ * Claiming ownership before awaiting `Accessibility.enable` matters because a
116
+ * lost response does not prove the browser rejected the enable request. The
117
+ * matching disable therefore always runs, without touching the DOM domain.
118
+ * A disable failure prevents success — alone it throws a typed cleanup
119
+ * failure; paired with a primary failure it throws an `AggregateError`
120
+ * preserving both facts.
121
+ */
122
+ async function axScopedRead(client: AccessibilityClient): Promise<FullAXNode[]> {
123
+ let primaryFailed = false;
124
+ let primaryError: unknown;
125
+ let ownsAccessibility = false;
126
+
127
+ try {
128
+ ownsAccessibility = true;
129
+ await client.send('Accessibility.enable');
130
+ const response = await client.send('Accessibility.getFullAXTree');
131
+ const nodes = isRecord(response) ? response.nodes : undefined;
132
+ if (!Array.isArray(nodes) || !nodes.every(isFullAXNode)) {
133
+ throw captureError(
134
+ 'world',
135
+ 'malformed_protocol',
136
+ 'Accessibility.getFullAXTree returned a malformed nodes payload.',
137
+ { method: 'Accessibility.getFullAXTree', response },
138
+ );
139
+ }
140
+ return nodes;
141
+ } catch (error) {
142
+ primaryFailed = true;
143
+ primaryError = error;
144
+ throw error;
145
+ } finally {
146
+ if (ownsAccessibility) {
147
+ try {
148
+ await client.send('Accessibility.disable');
149
+ } catch (error) {
150
+ const cleanupError = captureError(
151
+ 'cleanup',
152
+ 'accessibility_cleanup_failed',
153
+ 'Accessibility.disable failed after a full AX tree read.',
154
+ error,
155
+ );
156
+ if (primaryFailed) {
157
+ throw new AggregateError(
158
+ [primaryError, cleanupError],
159
+ 'The full AX tree read failed and Accessibility cleanup also failed.',
160
+ { cause: primaryError },
161
+ );
162
+ }
163
+ throw cleanupError;
164
+ }
165
+ }
166
+ }
167
+ }
168
+
66
169
  export async function getAccessibilityTree(
67
- client: CDPClient,
170
+ client: AccessibilityClient,
68
171
  ): Promise<A11yNode[]> {
69
- await client.send('Accessibility.enable');
70
- const result = (await client.send('Accessibility.getFullAXTree')) as {
71
- nodes: AXNode[];
72
- };
73
- await client.send('Accessibility.disable');
74
-
75
- const nodeMap = new Map<string, AXNode>();
76
- for (const node of result.nodes) {
172
+ const nodes = await readFullAXTree(client);
173
+
174
+ const nodeMap = new Map<string, FullAXNode>();
175
+ for (const node of nodes) {
77
176
  nodeMap.set(node.nodeId, node);
78
177
  }
79
178
 
80
179
  const SKIP_ROLES = new Set(['none', 'generic', 'InlineTextBox']);
81
180
 
82
- function buildNode(node: AXNode): A11yNode[] {
181
+ function buildNode(node: FullAXNode): A11yNode[] {
83
182
  const role = node.role?.value ?? '';
84
183
  const childNodes = (node.childIds ?? []).flatMap((id) => {
85
184
  const child = nodeMap.get(id);
@@ -99,6 +198,6 @@ export async function getAccessibilityTree(
99
198
  ];
100
199
  }
101
200
 
102
- const root = result.nodes[0];
201
+ const root = nodes[0];
103
202
  return root ? buildNode(root) : [];
104
203
  }