@crouton-kit/capture 1.3.8 → 1.3.10

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 (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  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 +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  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/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. 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.8",
3
+ "version": "1.3.10",
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",
package/src/capture.ts CHANGED
@@ -1,190 +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 "measure":
77
- case "motion":
78
- case "__bridge-serve":
79
- return cdpMain();
80
-
81
- default:
82
- console.log(`Capture — browser automation over CDP for validating UI features.
83
-
84
- Most tasks start with a session. Once a session is active, every command
85
- auto-fills --target (the tab) and --har (the recording) — don't thread
86
- those flags yourself.
87
-
88
- TYPICAL WORKFLOW
89
-
90
- 1. Start a session (opens tab, starts HAR, marks it active):
91
- capture session start --url http://localhost:3000
92
-
93
- 2. Interact — no --target / --har needed:
94
- capture a11y --interactive See what's on the page
95
- capture click "Sign in" Click by accessible name (auto-screenshots)
96
- capture type "hi@me.com" --into "Email"
97
- capture screenshot Save current state
98
- capture navigate https://... Navigate within the session
99
- capture exec "document.title" Run JS (expressions, return, await)
100
- capture har read --filter-url /api Inspect recorded traffic
101
-
102
- 3. Bundle and inspect:
103
- capture session stop <session-id>
104
- capture session view <session-id>
105
-
106
- SESSION COMMANDS
107
-
108
- session start [--url <url>] Start a session
109
- session stop <session-id> Finalize and bundle artifacts
110
- session list List active and stopped sessions
111
- session view <id> [--filter section] section = screenshots|har|a11y|logs
112
- log <path> [--name label] Tail a log file into the active session
113
-
114
- INTERACTION COMMANDS (work inside or outside a session)
115
-
116
- a11y [--interactive] [--json] Accessibility tree (use this first to see elements)
117
- click "name" [--role <role>] Click by accessible name
118
- type "text" [--into "Field"] Type into focused element or named field
119
- screenshot [--out <path>] [--full-page] Screenshot (viewport: desktop|desktop-wide|tablet|mobile)
120
- exec <code> | exec --file <path> Evaluate JS; expressions, return, and await are supported
121
- navigate <url> [--settle <ms>] Navigate the current tab + record HAR
122
-
123
- MEASUREMENT & MOTION
124
-
125
- measure snap|check|diff|census|explain|sweep|map Enriched snapshot substrate + read-only queries
126
- motion rec|mask|timeline|jank|response Recorder lifecycle + read-only queries over a finalized recording
127
-
128
- capture measure --help Full leaf reference (input constraints per leaf)
129
- capture motion --help Full leaf reference (input constraints per leaf)
130
-
131
- DIAGNOSTICS & ONE-OFFS (no session needed)
132
-
133
- detect Find running CDP endpoints
134
- list List open tabs across endpoints
135
- open <url> [--new] Open a URL, return its tab id
136
- reset-tab <url> Abandon a stuck tab, open fresh (updates session)
137
- record [--duration <secs>] Passive HAR recording; parallel-safe capture
138
- network <offline|online> Toggle connectivity for a tab
139
- har create | read [id] | delete <id> Manage standalone HAR recordings
140
-
141
- RAW CDP ESCAPE HATCH
142
-
143
- cdp <Domain.method> [--params json] Send any CDP command capture doesn't wrap
144
- [--browser] Target the browser connection (Browser.*, ServiceWorker.*)
145
- [--wait-event name] Wait for a CDP event, e.g. after ServiceWorker.enable
146
-
147
- Browser-level state (permission grants, ServiceWorker enablement) reverts
148
- the instant its connection closes. session start --hold keeps ONE browser
149
- connection open across commands so that state survives between them:
150
-
151
- capture session start --url http://localhost:3000 --hold
152
- capture cdp Browser.grantPermissions --browser --params '{"origin":"...","permissions":["notifications"]}'
153
- capture cdp ServiceWorker.enable --browser --target <pageTabId>
154
- capture cdp ServiceWorker.deliverPushMessage --browser --target <pageTabId> --params '{...}'
155
- capture session stop <session-id>
156
-
157
- capture cdp --help Full reference
158
-
159
- LIBRARY (vault libs — dev checkout only)
160
-
161
- lib list List available libs
162
- lib search "<query>" [--limit N] Fuzzy-search functions across libs
163
- lib show <name> Lib + function summaries
164
- lib read <name> [fn…] Full input/output schemas (+ .ts source path)
165
- exec "import {fn} from 'libs/<name>'; return await fn({…})" Run a lib in the active tab
166
-
167
- TARGETING (only when NOT in a session, or picking a parallel tab)
168
-
169
- CDP_PORT / CDP_TARGET Orchestrators can pin the active browser + tab
170
- --target <tabId> Exact id; a prefix of 8 chars is enough (preferred, parallel-safe)
171
- --url <pattern> Fuzzy URL match against open tabs
172
- NOTE: on \`session start\`, --url is a URL to OPEN, not a pattern.
173
-
174
- HELP
77
+ if (command === undefined || command === '-h') {
78
+ console.log(rootHelp());
79
+ return;
80
+ }
175
81
 
176
- capture <command> --help Per-command usage, e.g. capture click --help, capture har --help
177
- 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
+ }
178
88
 
179
- PREREQ — a browser with CDP must be running:
180
- Arc enabled by default
181
- Chrome / Chromium --remote-debugging-port=9222
182
- Electron apps CDP exposed automatically
183
- Verify with: capture detect`);
89
+ if ((ROOTS as readonly string[]).includes(command) || command === '__bridge-serve') {
90
+ return cdpMain();
184
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
+ );
185
98
  }
186
99
 
187
- main().catch((err) => {
188
- console.error(err.message);
189
- process.exit(1);
100
+ main().catch((error) => {
101
+ emitResult(failureResult(error), { json: process.argv.includes('--json') });
102
+ process.exitCode = 1;
190
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
  }