@crouton-kit/capture 1.3.8 → 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 (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
@@ -9,70 +9,161 @@
9
9
  * per-CLIENT and reverts the instant its websocket disconnects, so a plain
10
10
  * one-shot call here only lasts for that single command \u2014 running inside a
11
11
  * held session is what keeps it alive across commands.
12
+ *
13
+ * Output: one `<cdp-result>` block via `src/output/render.ts` \u2014 the protocol
14
+ * result/event as a capped inline data payload, mirrored at full fidelity
15
+ * under `--json` (D11). Params stay inline `--params <json>`, a documented
16
+ * spec deviation: raw CDP params are small protocol-defined objects and this
17
+ * leaf is the diagnostic escape hatch, so file indirection buys nothing.
12
18
  */
13
19
 
14
20
  import * as fs from 'fs';
21
+ import { CaptureError, captureError, invalidInput } from '../../errors.js';
15
22
  import { getActiveSession } from '../../session-context.js';
16
23
  import { connectForCommand } from '../connection.js';
17
24
  import { getBrowserClient, findTabById } from '../targets.js';
18
25
  import { detectCdpPort } from '../detect.js';
19
26
  import { sendBridgeRequest } from '../bridge/client.js';
20
- import { type CDPClient } from '../client.js';
27
+ import { isRecorderHeldClient } from '../recorder-client.js';
21
28
  import { type ParsedArgs } from '../types.js';
29
+ import {
30
+ capped,
31
+ data,
32
+ emitResult,
33
+ line,
34
+ text,
35
+ type FactLine,
36
+ } from '../../output/render.js';
22
37
 
23
38
  const DEFAULT_TIMEOUT_MS = 10000;
24
39
 
25
- function printHelp(): void {
26
- console.log(
27
- `Usage: capture cdp <Domain.method> [--params '<json>'] [--browser] [--target <id>] [--wait-event <Domain.event>] [--timeout <ms>]
28
-
29
- Send a raw CDP command \u2014 the escape hatch for domains no other capture
30
- command wraps (Browser.grantPermissions, ServiceWorker.*, Target.*, ...).
31
-
32
- --browser Route through the held connection (session --hold)
33
- instead of a fresh page/tab websocket. Required for
34
- state that must survive across separate commands.
35
- --target <id> With --browser: attach to this target (a flattened CDP
36
- session on the SAME held connection) for target-scoped
37
- domains, e.g. ServiceWorker.enable / .deliverPushMessage.
38
- Without --browser: the page target to run against, as
39
- with every other capture command.
40
- --params '<json>' JSON-encoded params object for the method.
41
- --wait-event <name> Wait for (and return) the next occurrence of a CDP
42
- event, e.g. ServiceWorker.workerRegistrationUpdated.
43
- Can be combined with a method (sent first) or alone.
44
- --timeout <ms> Wait timeout (default: ${DEFAULT_TIMEOUT_MS}ms).
45
-
46
- Browser-level and target-enablement state reverts the instant its connection
47
- disconnects \u2014 a one-shot call here only lasts for that single command. Run
48
- inside a held session to keep it alive across commands:
49
-
50
- capture session start --url http://localhost:3000 --hold
51
- capture cdp Browser.grantPermissions --browser \\
52
- --params '{"origin":"http://localhost:3000","permissions":["notifications"]}'
53
- capture cdp ServiceWorker.enable --browser --target <pageTabId>
54
- capture cdp --browser --target <pageTabId> --wait-event ServiceWorker.workerRegistrationUpdated
55
- capture cdp ServiceWorker.deliverPushMessage --browser --target <pageTabId> \\
56
- --params '{"origin":"...","registrationId":"...","data":"..."}'
57
- capture session stop <session-id>
58
-
59
- Without --browser, the method runs against the current page target (session
60
- context or --target), like any other capture command \u2014 e.g. DOM.* or
61
- Accessibility.* calls beyond what \`capture a11y\` exposes.`,
40
+ /** Generous inline cap for the protocol result/event payload (D11): big
41
+ * enough for typical protocol objects, still bounded in the prose block.
42
+ * `--json` carries the payload uncapped (full fidelity). */
43
+ const GENEROUS_RESULT_CAP = 4000;
44
+
45
+ /** Root-help representation of this leaf, assembled by `src/capture.ts`. */
46
+ export const COMMAND_BLOCK = `<command name="cdp">
47
+ raw CDP escape hatch — send any protocol method capture doesn't wrap, or wait for a protocol event
48
+ use when no other capture command covers the protocol surface you need (Browser.*, ServiceWorker.*, Target.*, ...)
49
+ cdp [<Domain.method>] [--params <json>] [--browser] [--wait-event <name>] — \`capture cdp -h\`
50
+ </command>`;
51
+
52
+ const HELP = `capture cdp — send a raw CDP protocol method and/or wait for a protocol event: the escape hatch for domains no other capture command wraps (Browser.*, ServiceWorker.*, Target.*, ...).
53
+
54
+ Input:
55
+ <Domain.method> protocol method to send. At least one of <Domain.method> / --wait-event is required.
56
+ --params <json> JSON-encoded params object for the method. Spec deviation: params stay inline JSON (no file indirection) — raw CDP params are small protocol-defined objects.
57
+ --wait-event <Domain.event> wait for (and return) the next occurrence of a protocol event; combinable with a method (the method is sent first) or usable alone.
58
+ --browser route through the held connection (session start --hold) instead of a one-shot page websocket. Connection-scoped state (permission grants, domain enables) reverts the instant its connection closes — it survives across commands only inside a held session.
59
+ --target <id> with --browser: attach a flattened CDP session on the held connection to this target (for target-scoped domains); without --browser: the page target to run against. 8-char id prefix accepted.
60
+ --timeout <ms> event-wait timeout (default ${DEFAULT_TIMEOUT_MS}ms).
61
+
62
+ Output:
63
+ <cdp-result method=… wait-event=… scope=…> — the protocol result and/or awaited event as an escaped, length-capped JSON payload. --json mirrors the same block with the payload at full fidelity.
64
+
65
+ Effects:
66
+ Sends the method verbatim to the browser — whatever browser/page state the protocol call mutates, it mutates. Writes no artifacts.`;
67
+
68
+ /**
69
+ * The minimal client surface both scopes drive. Satisfied structurally by
70
+ * `CDPClient`, `RecorderHeldClient`, and test stubs \u2014 the injectable
71
+ * `connect` parameter on `runPageScope` exists so tests can exercise the
72
+ * command wiring against a stub without a live browser.
73
+ */
74
+ export interface CdpScopeClient {
75
+ send(method: string, params?: Record<string, unknown>): Promise<unknown>;
76
+ on(event: string, handler: (params: unknown) => void): void;
77
+ close(): void;
78
+ }
79
+
80
+ /** The browser-level one-shot client surface: the same shape as {@link CdpScopeClient}
81
+ * plus the flattened-session `send(..., timeout, sessionId)` overload. Satisfied
82
+ * by `CDPClient`; the injectable `deps` on `runBrowserScope` lets tests drive
83
+ * this without a live browser. */
84
+ export interface BrowserScopeClient extends CdpScopeClient {
85
+ send(method: string, params?: Record<string, unknown>, timeout?: number, sessionId?: string): Promise<unknown>;
86
+ }
87
+
88
+ /** Seams `runBrowserScope` reaches the browser through — real by default,
89
+ * injectable in `test/cdp-command.test.ts`. */
90
+ export interface BrowserScopeDeps {
91
+ sendBridgeRequest: typeof sendBridgeRequest;
92
+ getBrowserClient: (port: number) => Promise<{ client: BrowserScopeClient }>;
93
+ findTabById: (port: number, targetId: string) => Promise<{ id: string } | null>;
94
+ detectCdpPort: typeof detectCdpPort;
95
+ }
96
+
97
+ const DEFAULT_BROWSER_DEPS: BrowserScopeDeps = { sendBridgeRequest, getBrowserClient, findTabById, detectCdpPort };
98
+
99
+ export interface CdpResultOptions {
100
+ method?: string;
101
+ waitEvent?: string;
102
+ scope: 'browser' | 'page';
103
+ target?: string;
104
+ result?: unknown;
105
+ event?: unknown;
106
+ json?: boolean;
107
+ }
108
+
109
+ /**
110
+ * Emit the `<cdp-result>` block. The protocol result/event is embedded as a
111
+ * JSON-stringified data payload: capped at `GENEROUS_RESULT_CAP` in prose,
112
+ * uncapped (full fidelity) under `--json`. Exported for `test/cdp-command.test.ts`.
113
+ */
114
+ export function emitCdpResult(opts: CdpResultOptions): void {
115
+ const sections: FactLine[] = [];
116
+ const payloadCap = (payload: string): number => (opts.json ? payload.length : GENEROUS_RESULT_CAP);
117
+ if (opts.method) {
118
+ const payload = JSON.stringify(opts.result) ?? 'undefined';
119
+ sections.push(line(text`result: `, data(capped(payload, payloadCap(payload)))));
120
+ }
121
+ if (opts.waitEvent) {
122
+ const payload = JSON.stringify(opts.event) ?? 'undefined';
123
+ sections.push(line(text`event: `, data(capped(payload, payloadCap(payload)))));
124
+ }
125
+ emitResult(
126
+ {
127
+ tag: 'cdp-result',
128
+ attrs: {
129
+ method: opts.method,
130
+ 'wait-event': opts.waitEvent,
131
+ scope: opts.scope,
132
+ target: opts.target,
133
+ },
134
+ sections,
135
+ },
136
+ { json: opts.json },
62
137
  );
63
138
  }
64
139
 
140
+ /** Recovery line for a rejected bare (target-less) protocol call: many CDP
141
+ * domains are tab-scoped, and Chrome often rejects them with only "Internal
142
+ * error" when sent on the bare browser connection. */
143
+ const TAB_SCOPE_RECOVERY =
144
+ 'Many CDP domains (Storage.*, Page.*, DOM.*, Emulation.*, ...) are tab-scoped, not connection-scoped — re-run with --target <tabId> (`capture tab list` shows available tabs).';
145
+
146
+ /** Appends the tab-scope recovery hint when the failed call was target-less. */
147
+ function cdpFailed(message: string, flagTarget: string | undefined, cause?: unknown): CaptureError {
148
+ return captureError('world', 'cdp_failed', flagTarget ? message : `${message} ${TAB_SCOPE_RECOVERY}`, cause);
149
+ }
150
+
65
151
  export async function cmdCdp(parsed: ParsedArgs, _args: string[]): Promise<void> {
66
152
  if (parsed.help) {
67
- printHelp();
68
- process.exit(0);
153
+ console.log(HELP);
154
+ return;
69
155
  }
70
156
 
157
+ // Positional cardinality (0..1, where 0 requires --wait-event) is enforced
158
+ // by `validateCliInvocation` before dispatch reaches this leaf; this guard
159
+ // covers direct programmatic callers only. Failures cross the boundary as
160
+ // typed CaptureErrors — capture.ts is the sole renderer/exit-status owner.
71
161
  const method = parsed.positional[0];
72
162
  if (!method && !parsed.waitEvent) {
73
- console.error('ERROR: Provide a CDP method (e.g. Browser.grantPermissions) or --wait-event.\n');
74
- printHelp();
75
- process.exit(1);
163
+ throw invalidInput(
164
+ 'received: neither a <Domain.method> positional nor --wait-event; expected: at least one of the two. Run `capture cdp -h` for the input schema.',
165
+ 'missing_method_and_event',
166
+ );
76
167
  }
77
168
 
78
169
  let params: Record<string, unknown> | undefined;
@@ -80,70 +171,102 @@ export async function cmdCdp(parsed: ParsedArgs, _args: string[]): Promise<void>
80
171
  try {
81
172
  params = JSON.parse(parsed.params);
82
173
  } catch (err) {
83
- console.error(`ERROR: --params is not valid JSON: ${err instanceof Error ? err.message : err}`);
84
- process.exit(1);
174
+ throw invalidInput(
175
+ `received: --params that is not valid JSON (${err instanceof Error ? err.message : String(err)}); expected: a JSON-encoded params object.`,
176
+ 'invalid_params_json',
177
+ );
85
178
  }
86
179
  }
87
180
 
88
181
  const timeoutMs = parsed.timeoutMs ?? DEFAULT_TIMEOUT_MS;
89
182
 
90
183
  if (parsed.browser) {
91
- await runBrowserScope(method, params, parsed, timeoutMs);
184
+ await runBrowserScope(method, params, parsed, timeoutMs, DEFAULT_BROWSER_DEPS);
92
185
  return;
93
186
  }
94
187
 
95
- await runPageScope(method, params, parsed, timeoutMs);
188
+ try {
189
+ await runPageScope(method, params, parsed, timeoutMs);
190
+ } catch (err) {
191
+ if (err instanceof CaptureError) throw err;
192
+ throw captureError(
193
+ 'world',
194
+ 'cdp_failed',
195
+ `\`${invocationLabel(method, parsed.waitEvent)}\` failed on the page connection: ${err instanceof Error ? err.message : String(err)}`,
196
+ err,
197
+ );
198
+ }
199
+ }
200
+
201
+ function invocationLabel(method: string | undefined, waitEvent: string | undefined): string {
202
+ return method ?? `--wait-event ${waitEvent}`;
96
203
  }
97
204
 
98
- async function runBrowserScope(
205
+ /**
206
+ * Exported for `test/cdp-command.test.ts`. Browser-scope CDP is connection-
207
+ * level, so a target is only meaningful when the caller *explicitly* asked for
208
+ * one with `--target` (`parsed.targetSource === 'flag'`). Session/env target
209
+ * autofill exists to scope ordinary PAGE commands and must never silently
210
+ * flatten a browser-level call onto some other tab — so this reads the flag-
211
+ * sourced target directly instead of inferring provenance from the final
212
+ * string.
213
+ */
214
+ export async function runBrowserScope(
99
215
  method: string | undefined,
100
216
  params: Record<string, unknown> | undefined,
101
217
  parsed: ParsedArgs,
102
218
  timeoutMs: number,
219
+ deps: BrowserScopeDeps = DEFAULT_BROWSER_DEPS,
103
220
  ): Promise<void> {
221
+ const flagTarget = parsed.targetSource === 'flag' ? parsed.target : undefined;
104
222
  const session = getActiveSession();
105
223
  if (session?.bridgeSocket && fs.existsSync(session.bridgeSocket)) {
106
- const resp = await sendBridgeRequest(session.bridgeSocket, {
224
+ const resp = await deps.sendBridgeRequest(session.bridgeSocket, {
107
225
  method,
108
226
  params,
109
- targetId: parsed.target,
227
+ targetId: flagTarget,
110
228
  waitEvent: parsed.waitEvent,
111
229
  timeoutMs,
112
230
  });
113
231
  if (!resp.ok) {
114
- console.error(`ERROR: ${resp.error}`);
115
- if (!parsed.target) {
116
- console.error(
117
- '\nMany CDP domains (Storage.*, Page.*, DOM.*, Emulation.*, ...) are scoped to a specific ' +
118
- 'tab, not the browser connection as a whole \u2014 sent bare like this, Chrome often rejects them ' +
119
- 'with just "Internal error" and no further detail. Pass --target <tabId> to attach to the tab ' +
120
- 'the call should apply to (run "capture list" to find one).',
121
- );
122
- }
123
- process.exit(1);
232
+ throw cdpFailed(
233
+ `\`${invocationLabel(method, parsed.waitEvent)}\` failed over the held connection: ${resp.error ?? 'unknown bridge error'}`,
234
+ flagTarget,
235
+ );
124
236
  }
125
- console.log(JSON.stringify({ result: resp.result, event: resp.event }, null, 2));
237
+ emitCdpResult({
238
+ method,
239
+ waitEvent: parsed.waitEvent,
240
+ scope: 'browser',
241
+ target: flagTarget,
242
+ result: resp.result,
243
+ event: resp.event,
244
+ json: parsed.json,
245
+ });
126
246
  return;
127
247
  }
128
248
 
249
+ // In-flight diagnostic (stderr): the one-shot connection semantics fact.
129
250
  console.error(
130
- 'No held session bridge active \u2014 this connection closes when the command exits, ' +
131
- 'so any browser-level grant or target enablement made here reverts immediately after.\n' +
132
- 'For state that must survive multiple commands: capture session start --hold\n',
251
+ 'No held session bridge active \u2014 this one-shot connection closes when the command exits, ' +
252
+ 'so any browser-level grant or target enablement made here reverts immediately after.',
133
253
  );
134
254
 
135
- const port = parsed.port ?? (await detectCdpPort());
136
- const { client } = await getBrowserClient(port);
255
+ const port = parsed.port ?? (await deps.detectCdpPort());
256
+ const { client } = await deps.getBrowserClient(port);
137
257
  try {
138
258
  let sessionId: string | undefined;
139
- if (parsed.target) {
259
+ if (flagTarget) {
140
260
  // Accept the same 8-char-prefix targeting every other capture command
141
- // promises (see the top-level --help TARGETING section) instead of
261
+ // promises (see the root help's targeting contract) instead of
142
262
  // requiring the full 32-char target id here.
143
- const tab = await findTabById(port, parsed.target);
263
+ const tab = await deps.findTabById(port, flagTarget);
144
264
  if (!tab) {
145
- console.error(`ERROR: No target found for "${parsed.target}" on port ${port}. Run "capture list" to see available tabs.`);
146
- process.exit(1);
265
+ throw captureError(
266
+ 'precondition',
267
+ 'target_not_found',
268
+ `received: --target ${flagTarget} with no matching target on port ${port}; expected: an existing target id (8-char prefix accepted). \`capture tab list\` shows available targets.`,
269
+ );
147
270
  }
148
271
  const attached = (await client.send('Target.attachToTarget', {
149
272
  targetId: tab.id,
@@ -154,42 +277,88 @@ async function runBrowserScope(
154
277
  const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : undefined;
155
278
  const result = method ? await client.send(method, params ?? {}, 60000, sessionId) : undefined;
156
279
  const event = eventPromise ? await eventPromise : undefined;
157
- console.log(JSON.stringify({ result, event }, null, 2));
280
+ emitCdpResult({
281
+ method,
282
+ waitEvent: parsed.waitEvent,
283
+ scope: 'browser',
284
+ target: flagTarget,
285
+ result,
286
+ event,
287
+ json: parsed.json,
288
+ });
158
289
  } catch (err) {
159
- const message = err instanceof Error ? err.message : String(err);
160
- console.error(`ERROR: ${message}`);
161
- if (!parsed.target) {
162
- console.error(
163
- '\nMany CDP domains (Storage.*, Page.*, DOM.*, Emulation.*, ...) are scoped to a specific ' +
164
- 'tab, not the browser connection as a whole \u2014 sent bare like this, Chrome often rejects them ' +
165
- 'with just "Internal error" and no further detail. Pass --target <tabId> to attach to the tab ' +
166
- `the call should apply to (run "capture list" to find one${method ? `, e.g. capture cdp ${method} --browser --target <tabId> ...` : ''}).`,
167
- );
168
- }
169
- process.exit(1);
290
+ if (err instanceof CaptureError) throw err;
291
+ throw cdpFailed(
292
+ `\`${invocationLabel(method, parsed.waitEvent)}\` failed on the one-shot browser connection: ${err instanceof Error ? err.message : String(err)}`,
293
+ flagTarget,
294
+ err,
295
+ );
170
296
  } finally {
171
297
  client.close();
172
298
  }
173
299
  }
174
300
 
175
- async function runPageScope(
301
+ /**
302
+ * Exported for testing (`test/recorder-navigate-waitevent.test.ts`,
303
+ * `test/cdp-command.test.ts`): the recorder-held branch below
304
+ * (`isRecorderHeldClient(client)`) is the actual command wiring
305
+ * `capture cdp --wait-event` runs under an active recording, and the
306
+ * injectable `connect` lets `test/cdp-command.test.ts` drive the non-held
307
+ * branch against a stub client.
308
+ */
309
+ export async function runPageScope(
176
310
  method: string | undefined,
177
311
  params: Record<string, unknown> | undefined,
178
312
  parsed: ParsedArgs,
179
313
  timeoutMs: number,
314
+ connect: (parsed: ParsedArgs) => Promise<{ client: CdpScopeClient }> = connectForCommand,
180
315
  ): Promise<void> {
181
- const { client } = await connectForCommand(parsed);
316
+ const { client } = await connect(parsed);
182
317
  try {
183
- const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : undefined;
184
- const result = method ? await client.send(method, params ?? {}) : undefined;
185
- const event = eventPromise ? await eventPromise : undefined;
186
- console.log(JSON.stringify({ result, event }, null, 2));
318
+ let result: unknown;
319
+ let event: unknown;
320
+ if (isRecorderHeldClient(client)) {
321
+ // The recorder-held adapter's `.on()` is a documented no-op (nothing
322
+ // pushes unsolicited events back over its one-request-one-response
323
+ // socket) — `waitForEventOnce` below would hang until its own timeout.
324
+ // `.waitEvent()`/`.dispatch()` are the real event-wait surface for this
325
+ // adapter, routing the wait through the recorder bridge's own event
326
+ // broker. When both a method and a wait-event are requested,
327
+ // `.dispatch()` carries them in ONE request so the bridge arms the wait
328
+ // before dispatching the call — sending them as two separate requests
329
+ // (`.send()` then `.waitEvent()`) risks the action firing the event
330
+ // before the wait-only request even reaches the bridge. This command has
331
+ // no fragment-nav-style multi-call logic and already throws on an
332
+ // event-wait timeout, so one combined request preserves the complete
333
+ // result contract (contrast `page/navigate.ts`'s multi-call
334
+ // `navigateAtomicWithFragmentFix`).
335
+ if (method) {
336
+ const combined = await client.dispatch(method, params ?? {}, parsed.waitEvent, timeoutMs);
337
+ result = combined.result;
338
+ event = combined.event;
339
+ } else if (parsed.waitEvent) {
340
+ event = await client.waitEvent(parsed.waitEvent, timeoutMs);
341
+ }
342
+ } else {
343
+ const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : undefined;
344
+ result = method ? await client.send(method, params ?? {}) : undefined;
345
+ event = eventPromise ? await eventPromise : undefined;
346
+ }
347
+ emitCdpResult({
348
+ method,
349
+ waitEvent: parsed.waitEvent,
350
+ scope: 'page',
351
+ target: parsed.target,
352
+ result,
353
+ event,
354
+ json: parsed.json,
355
+ });
187
356
  } finally {
188
357
  client.close();
189
358
  }
190
359
  }
191
360
 
192
- function waitForEventOnce(client: CDPClient, eventName: string, timeoutMs: number): Promise<unknown> {
361
+ function waitForEventOnce(client: CdpScopeClient, eventName: string, timeoutMs: number): Promise<unknown> {
193
362
  return new Promise((resolve, reject) => {
194
363
  const timer = setTimeout(
195
364
  () => reject(new Error(`Timed out after ${timeoutMs}ms waiting for event "${eventName}"`)),
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `--gate` (exit 2 on findings/changes) is scoped to exactly two leaves —
3
+ * `measure check` and `measure diff` — per the observational-collector
4
+ * posture invariant (no collector or query leaf grades/gates its own
5
+ * output; see I-8). The shared dispatch path (`src/cdp/dispatch.ts`)
6
+ * invokes this guard ONCE per invocation, via `isGateLeaf`, for every
7
+ * command on the whole surface, so a caller that supplies `--gate`
8
+ * anywhere else gets a typed rejection (thrown here, rendered at the
9
+ * `src/capture.ts` root boundary) before any branch main runs instead of
10
+ * the flag being silently accepted and ignored. The dispatch guard is the
11
+ * ONLY caller of `rejectUnsupportedGate`; no leaf calls it directly.
12
+ */
13
+ import { type ParsedArgs } from '../types.js';
14
+ import { invalidInput } from '../../errors.js';
15
+
16
+ /** True iff this invocation is one of the exactly two leaves that accept
17
+ * `--gate`: `measure check` and `measure diff`. Leaf detection is
18
+ * `parsed.command` plus the first positional (the branch-leaf token —
19
+ * branch routers shift it off before their leaves run, but dispatch sees
20
+ * it in place). */
21
+ export function isGateLeaf(parsed: ParsedArgs): boolean {
22
+ if (parsed.command !== 'measure') return false;
23
+ const leaf = parsed.positional[0];
24
+ return leaf === 'check' || leaf === 'diff';
25
+ }
26
+
27
+ /**
28
+ * Throws the typed `--gate` rejection for a leaf that doesn't support it;
29
+ * returns normally when `parsed.gate` is unset. The throw crosses the shared
30
+ * dispatch path unrendered — the root boundary in `src/capture.ts` is the one
31
+ * place that renders it and sets the exit code.
32
+ *
33
+ * `command` is the leaf's own dotted usage name (e.g. `"measure snap"`,
34
+ * `"motion jank"`), carried in the message so the rejection still names the
35
+ * exact leaf.
36
+ */
37
+ export function rejectUnsupportedGate(parsed: ParsedArgs, command: string): void {
38
+ if (!parsed.gate) return;
39
+ throw invalidInput(
40
+ `\`--gate\` is not accepted on \`${command}\`. received: --gate; expected: no gate flag on this leaf. \`--gate\` (exit 2 on findings/changes) is accepted only by \`measure check\` and \`measure diff\`.`,
41
+ 'unsupported_flag',
42
+ );
43
+ }