@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
@@ -1,84 +1,215 @@
1
- import * as fs from 'fs';
1
+ import { PNG } from 'pngjs';
2
2
  import { CDPClient } from './client.js';
3
3
  import { nextStepPath } from '../session-context.js';
4
+ import { writeBinaryPrivate } from '../session/artifacts.js';
5
+ import { withScopeSerialization } from './scope-lock.js';
4
6
 
7
+ /**
8
+ * Downscales a PNG so its longest side fits within maxDim, using a box
9
+ * filter. Used when a clipped/scaled CDP capture is unavailable and the raw
10
+ * capture exceeds the dimension budget.
11
+ */
12
+ function downscalePngToFit(png: Buffer, maxDim: number): Buffer {
13
+ const src = PNG.sync.read(png);
14
+ const maxSide = Math.max(src.width, src.height);
15
+ if (maxSide <= maxDim) return png;
16
+ const ratio = maxDim / maxSide;
17
+ const dstW = Math.max(1, Math.round(src.width * ratio));
18
+ const dstH = Math.max(1, Math.round(src.height * ratio));
19
+ const dst = new PNG({ width: dstW, height: dstH });
20
+ for (let y = 0; y < dstH; y++) {
21
+ const sy0 = Math.floor((y * src.height) / dstH);
22
+ const sy1 = Math.max(sy0 + 1, Math.floor(((y + 1) * src.height) / dstH));
23
+ for (let x = 0; x < dstW; x++) {
24
+ const sx0 = Math.floor((x * src.width) / dstW);
25
+ const sx1 = Math.max(sx0 + 1, Math.floor(((x + 1) * src.width) / dstW));
26
+ let r = 0;
27
+ let g = 0;
28
+ let b = 0;
29
+ let a = 0;
30
+ let n = 0;
31
+ for (let sy = sy0; sy < sy1 && sy < src.height; sy++) {
32
+ for (let sx = sx0; sx < sx1 && sx < src.width; sx++) {
33
+ const i = (sy * src.width + sx) * 4;
34
+ r += src.data[i];
35
+ g += src.data[i + 1];
36
+ b += src.data[i + 2];
37
+ a += src.data[i + 3];
38
+ n++;
39
+ }
40
+ }
41
+ const j = (y * dstW + x) * 4;
42
+ dst.data[j] = Math.round(r / n);
43
+ dst.data[j + 1] = Math.round(g / n);
44
+ dst.data[j + 2] = Math.round(b / n);
45
+ dst.data[j + 3] = Math.round(a / n);
46
+ }
47
+ }
48
+ return PNG.sync.write(dst);
49
+ }
50
+
51
+ /**
52
+ * Captures one screenshot, applying (and always clearing) a device-metrics
53
+ * override when a viewport or full-page capture is requested. The override
54
+ * is target-visible state driven over the connection: a direct connection's
55
+ * override dies with its own websocket session, but the recorder-held
56
+ * connection is shared across commands, so the entire
57
+ * setDeviceMetricsOverride→capture→clear scope serializes under the owning
58
+ * session's `.viewport-scope.lock` (`withScopeSerialization`) when the
59
+ * client is the recorder-held adapter — otherwise a concurrent routed
60
+ * caller could clear this capture's live override mid-scope (or capture
61
+ * inside someone else's).
62
+ */
5
63
  export async function captureScreenshot(
6
64
  client: CDPClient,
7
65
  viewport?: { width: number; height: number },
8
66
  options?: { fullPage?: boolean },
67
+ ): Promise<Buffer> {
68
+ return withScopeSerialization(client, 'viewport', 'screenshot capture', () =>
69
+ viewportScopedCapture(client, viewport, options),
70
+ );
71
+ }
72
+
73
+ /**
74
+ * The one viewport/capture state transaction: override (when requested) →
75
+ * capture → clear. Restoration ownership is claimed BEFORE the override is
76
+ * awaited (a lost response does not prove Chrome rejected it); a clear
77
+ * failure prevents success — alone it throws, paired with a primary failure
78
+ * it throws an `AggregateError` preserving both facts.
79
+ */
80
+ async function viewportScopedCapture(
81
+ client: CDPClient,
82
+ viewport?: { width: number; height: number },
83
+ options?: { fullPage?: boolean },
9
84
  ): Promise<Buffer> {
10
85
  const MAX_DIM = 1600; // headroom below Anthropic's 2000px many-image limit
86
+ let ownsDeviceMetricsOverride = false;
87
+ let primaryFailed = false;
88
+ let primaryError: unknown;
11
89
 
12
- // Apply viewport emulation if requested
13
- if (viewport) {
14
- await client.send('Emulation.setDeviceMetricsOverride', {
15
- width: viewport.width,
16
- height: viewport.height,
17
- deviceScaleFactor: 1,
18
- mobile: false,
19
- });
20
- // Let the page re-layout at the new size
21
- await new Promise((r) => setTimeout(r, 150));
22
- }
90
+ try {
91
+ if (viewport) {
92
+ // A rejected response does not prove that Chrome rejected the request.
93
+ // Claim cleanup responsibility before awaiting every override request.
94
+ ownsDeviceMetricsOverride = true;
95
+ await client.send('Emulation.setDeviceMetricsOverride', {
96
+ width: viewport.width,
97
+ height: viewport.height,
98
+ deviceScaleFactor: 1,
99
+ mobile: false,
100
+ });
101
+ await new Promise((resolve) => setTimeout(resolve, 150));
102
+ }
23
103
 
24
- // For full-page capture, resize viewport to the full content height
25
- if (options?.fullPage) {
26
- const layoutMetrics = (await client.send('Page.getLayoutMetrics')) as {
27
- contentSize?: { width: number; height: number };
28
- cssVisualViewport?: { clientWidth: number };
104
+ if (options?.fullPage) {
105
+ const layoutMetrics = (await client.send('Page.getLayoutMetrics')) as {
106
+ contentSize?: { width: number; height: number };
107
+ cssVisualViewport?: { clientWidth: number };
108
+ };
109
+ const contentWidth = layoutMetrics.cssVisualViewport?.clientWidth ?? viewport?.width ?? 1280;
110
+ const contentHeight = layoutMetrics.contentSize?.height ?? viewport?.height ?? 800;
111
+ ownsDeviceMetricsOverride = true;
112
+ await client.send('Emulation.setDeviceMetricsOverride', {
113
+ width: contentWidth,
114
+ height: Math.ceil(contentHeight),
115
+ deviceScaleFactor: 1,
116
+ mobile: false,
117
+ });
118
+ await new Promise((resolve) => setTimeout(resolve, 150));
119
+ }
120
+
121
+ let screenshotOpts: Record<string, unknown> = {
122
+ format: 'png',
123
+ captureBeyondViewport: false,
29
124
  };
30
- const contentWidth = layoutMetrics.cssVisualViewport?.clientWidth ?? viewport?.width ?? 1280;
31
- const contentHeight = layoutMetrics.contentSize?.height ?? viewport?.height ?? 800;
32
- await client.send('Emulation.setDeviceMetricsOverride', {
33
- width: contentWidth,
34
- height: Math.ceil(contentHeight),
35
- deviceScaleFactor: 1,
36
- mobile: false,
37
- });
38
- await new Promise((r) => setTimeout(r, 150));
39
- }
40
125
 
41
- let screenshotOpts: Record<string, unknown> = {
42
- format: 'png',
43
- captureBeyondViewport: false,
44
- };
126
+ try {
127
+ const metrics = (await client.send('Page.getLayoutMetrics')) as {
128
+ cssVisualViewport?: { clientWidth: number; clientHeight: number; pageX: number; pageY: number };
129
+ };
130
+ // Snap the clip to integer CSS pixels: browser zoom (e.g. 110%) makes the
131
+ // CSS viewport fractional, and some Chromium-based browsers (Arc, at
132
+ // least) answer a clipped Page.captureScreenshot whose output dimensions
133
+ // are fractional with a SUCCESSFUL response carrying empty `data`.
134
+ const vw = Math.round(metrics.cssVisualViewport?.clientWidth ?? 0);
135
+ const vh = Math.round(metrics.cssVisualViewport?.clientHeight ?? 0);
136
+ const sx = Math.floor(metrics.cssVisualViewport?.pageX ?? 0);
137
+ const sy = Math.floor(metrics.cssVisualViewport?.pageY ?? 0);
45
138
 
46
- try {
47
- const metrics = (await client.send('Page.getLayoutMetrics')) as {
48
- cssVisualViewport?: { clientWidth: number; clientHeight: number; pageX: number; pageY: number };
49
- };
50
- const vw = metrics.cssVisualViewport?.clientWidth ?? 0;
51
- const vh = metrics.cssVisualViewport?.clientHeight ?? 0;
52
- const sx = metrics.cssVisualViewport?.pageX ?? 0;
53
- const sy = metrics.cssVisualViewport?.pageY ?? 0;
54
-
55
- const dprResult = (await client.send('Runtime.evaluate', {
56
- expression: 'window.devicePixelRatio',
57
- returnByValue: true,
58
- })) as { result: { value: number } };
59
- const dpr = dprResult.result.value ?? 1;
60
-
61
- const actualMaxSide = Math.max(vw, vh) * dpr;
62
- const scale = actualMaxSide > MAX_DIM ? MAX_DIM / actualMaxSide : 1 / dpr;
63
- screenshotOpts = {
64
- ...screenshotOpts,
65
- clip: { x: sx, y: sy, width: vw, height: vh, scale },
66
- };
67
- } catch {
68
- // Fallback: capture without downscaling
69
- }
139
+ const dprResult = (await client.send('Runtime.evaluate', {
140
+ expression: 'window.devicePixelRatio',
141
+ returnByValue: true,
142
+ })) as { result: { value: number } };
143
+ const dpr = dprResult.result.value ?? 1;
70
144
 
71
- const result = (await client.send(
72
- 'Page.captureScreenshot',
73
- screenshotOpts,
74
- )) as { data: string };
145
+ const actualMaxSide = Math.max(vw, vh) * dpr;
146
+ const scale = actualMaxSide > MAX_DIM ? MAX_DIM / actualMaxSide : 1 / dpr;
147
+ screenshotOpts = {
148
+ ...screenshotOpts,
149
+ clip: { x: sx, y: sy, width: vw, height: vh, scale },
150
+ };
151
+ } catch {
152
+ // Capture without downscaling when the optional metrics probe is unavailable.
153
+ }
75
154
 
76
- // Reset emulation so the browser window isn't stuck at the overridden size
77
- if (viewport || options?.fullPage) {
78
- await client.send('Emulation.clearDeviceMetricsOverride');
79
- }
155
+ const result = (await client.send(
156
+ 'Page.captureScreenshot',
157
+ screenshotOpts,
158
+ )) as { data?: string };
159
+ let png = Buffer.from(result.data ?? '', 'base64');
160
+
161
+ // A fractional scale (1/devicePixelRatio) can still produce fractional
162
+ // output dimensions, which the browsers above answer with empty data.
163
+ // Retry the same integer clip at scale 1, preserving full-page captures,
164
+ // then enforce the dimension budget locally.
165
+ if (png.length === 0 && screenshotOpts.clip) {
166
+ const clip = screenshotOpts.clip as Record<string, unknown>;
167
+ const retry = (await client.send('Page.captureScreenshot', {
168
+ ...screenshotOpts,
169
+ clip: { ...clip, scale: 1 },
170
+ })) as { data?: string };
171
+ png = Buffer.from(retry.data ?? '', 'base64');
172
+ if (png.length > 0) png = downscalePngToFit(png, MAX_DIM);
173
+ }
174
+
175
+ // If the browser rejects clipped capture entirely, an unclipped viewport
176
+ // capture is still better than a false successful 0-byte artifact.
177
+ if (png.length === 0 && screenshotOpts.clip && !options?.fullPage) {
178
+ const retry = (await client.send('Page.captureScreenshot', {
179
+ format: 'png',
180
+ captureBeyondViewport: false,
181
+ })) as { data?: string };
182
+ png = Buffer.from(retry.data ?? '', 'base64');
183
+ if (png.length > 0) png = downscalePngToFit(png, MAX_DIM);
184
+ }
80
185
 
81
- return Buffer.from(result.data, 'base64');
186
+ if (png.length === 0) {
187
+ throw new Error(
188
+ 'Page.captureScreenshot returned no image data (the tab may not be rendering); refusing to write a 0-byte PNG',
189
+ );
190
+ }
191
+
192
+ return png;
193
+ } catch (error) {
194
+ primaryFailed = true;
195
+ primaryError = error;
196
+ throw error;
197
+ } finally {
198
+ if (ownsDeviceMetricsOverride) {
199
+ try {
200
+ await client.send('Emulation.clearDeviceMetricsOverride');
201
+ } catch (cleanupError) {
202
+ if (primaryFailed) {
203
+ throw new AggregateError(
204
+ [primaryError, cleanupError],
205
+ 'Screenshot capture failed and device-metrics cleanup also failed.',
206
+ { cause: primaryError },
207
+ );
208
+ }
209
+ throw cleanupError;
210
+ }
211
+ }
212
+ }
82
213
  }
83
214
 
84
215
  export async function autoScreenshot(
@@ -88,13 +219,15 @@ export async function autoScreenshot(
88
219
  noScreenshot?: boolean,
89
220
  ): Promise<string | null> {
90
221
  if (noScreenshot) return null;
91
- const shotPath = nextStepPath(action, label);
222
+ const shotPath = await nextStepPath(action, label);
92
223
  if (!shotPath) return null;
93
224
 
94
225
  // Brief settle for UI to update
95
226
  await new Promise((r) => setTimeout(r, 300));
96
227
  const png = await captureScreenshot(client);
97
- fs.writeFileSync(shotPath, png);
228
+ // Shot path always resolves under the session dir (CAPTURE_ROOT); the
229
+ // private writer creates the file 0600 and re-ensures shots/ is 0700.
230
+ writeBinaryPrivate(shotPath, png);
98
231
  console.error(` [screenshot] ${shotPath}`);
99
232
  return shotPath;
100
233
  }
@@ -25,7 +25,7 @@ export async function getBrowserClient(
25
25
  throw new Error(
26
26
  `Port ${port} answers CDP's /json/version but has no browser-level WebSocket debugger URL — ` +
27
27
  `it's not a real browser (e.g. a Node/workerd inspector), so it can't host tabs. ` +
28
- `Run "capture detect" to find a real browser endpoint, or pass --port explicitly.`,
28
+ `Run "capture tab list" to find a real browser endpoint, or pass --port explicitly.`,
29
29
  );
30
30
  }
31
31
  const client = new CDPClient(version.webSocketDebuggerUrl);
@@ -208,6 +208,26 @@ export function requireTargetId(
208
208
  return targetId;
209
209
  }
210
210
 
211
+ /**
212
+ * Closes exactly one target through the browser-level connection, the mirror of
213
+ * `openTab`. Transactional owners (session-start rollback) call this to release
214
+ * ONLY the target this attempt opened; it never touches foreign/pre-existing
215
+ * tabs. Throws when `Target.closeTarget` reports the target was not closed.
216
+ */
217
+ export async function closeTarget(port: number, targetId: string): Promise<void> {
218
+ const { client } = await getBrowserClient(port);
219
+ try {
220
+ const result = (await client.send('Target.closeTarget', { targetId })) as {
221
+ success?: boolean;
222
+ };
223
+ if (result?.success === false) {
224
+ throw new Error(`Target.closeTarget reported no close for target ${targetId} on port ${port}.`);
225
+ }
226
+ } finally {
227
+ client.close();
228
+ }
229
+ }
230
+
211
231
  export async function openTab(port: number, url: string): Promise<CDPTarget> {
212
232
  const { client } = await getBrowserClient(port);
213
233
 
package/src/cdp/timing.ts CHANGED
@@ -20,13 +20,15 @@ export interface BracketedTiming<T> {
20
20
  /**
21
21
  * Runs `fn`, bracketed by two `performance.now()` reads taken in the page's document execution
22
22
  * context. Used to attribute a discrete action (e.g. an `Input.dispatch*` call) to a window in
23
- * the recorder's authoritative `performance.now()` clock domain — this is the mechanism behind
24
- * the recorder-client adapter's internal `performance.mark(input_name)` bracketing (U14).
23
+ * the recorder's authoritative `performance.now()` clock domain. The action's landmark label
24
+ * (`mark`, when the caller supplied one) is recorded host-side only — appended straight to
25
+ * `events.jsonl` alongside these two bracket numbers (see `../recorder-bridge.ts`'s `handleCdp`)
26
+ * — never written into the page's own PerformanceTimeline via `performance.mark()`: that page-
27
+ * visible side channel was removed (a predictable, page-observable channel the injected
28
+ * PerformanceObserver would otherwise re-observe as its own event, in violation of the
29
+ * observational-collector invariant against page-side channels).
25
30
  */
26
- export async function withDocumentPerformanceNow<T>(
27
- client: CDPClient,
28
- fn: () => Promise<T>,
29
- ): Promise<BracketedTiming<T>> {
31
+ export async function withDocumentPerformanceNow<T>(client: CDPClient, fn: () => Promise<T>): Promise<BracketedTiming<T>> {
30
32
  const startPerformanceNow = await readPerformanceNow(client);
31
33
  const result = await fn();
32
34
  const endPerformanceNow = await readPerformanceNow(client);
@@ -57,10 +59,13 @@ export interface TraceClockBaseline {
57
59
 
58
60
  /**
59
61
  * Reads one synchronized (`performance.now()`, wall-clock) pair from the page in a single
60
- * round trip. The recorder bridge captures this once at `--start` as the `capture-rec-start`
61
- * baseline stored in `markers.json`; screencast-frame `metadata.timestamp` (wall-clock seconds)
62
- * and Tracing `ts` (wall-clock-anchored microseconds) are then converted into the
63
- * `performance.now()` domain by subtracting this baseline's wall-clock offset.
62
+ * round trip. The recorder bridge reads this once when it arms (`rec-start`) as the anchor half
63
+ * of the three-way `markers.json` baseline; the other two members — the first screencast frame's
64
+ * `metadata.timestamp` (wall-clock seconds) and the first Tracing batch's earliest event `ts`
65
+ * (trace-clock microseconds) — are captured separately by the recorder bridge itself as those
66
+ * events arrive (not by this function, since neither exists yet at arm time). Post-processing
67
+ * converts frame-time and trace-time into the `performance.now()` domain by subtracting this
68
+ * baseline's wall-clock offset.
64
69
  */
65
70
  export async function readTraceClockBaseline(client: CDPClient): Promise<TraceClockBaseline> {
66
71
  const evaluation = (await client.send('Runtime.evaluate', {
package/src/cdp/types.ts CHANGED
@@ -12,24 +12,37 @@ export interface ParsedArgs {
12
12
  port?: number;
13
13
  out?: string;
14
14
  json?: boolean;
15
- interactive?: boolean;
16
- harOut?: string;
17
- record?: boolean;
18
15
  duration?: number;
19
16
  settle?: number;
20
17
  file?: string;
21
- nested?: boolean;
18
+ /** Session-filled internal slot: NOT CLI-settable (no --har flag, no env
19
+ * var). Auto-filled from the active session's harId so withConnection()
20
+ * can auto-append recorded traffic to the session HAR. */
22
21
  har?: string;
23
22
  new?: boolean;
24
23
  target?: string;
24
+ /** Where `target` was assigned from: an actual `--target` flag, active-session
25
+ * autofill, or the ambient `CDP_TARGET` env var. Recorded at the assignment
26
+ * point — browser-scope CDP (`cdp --browser`) attaches a flattened target
27
+ * session only when this is `'flag'`; provenance is never inferred later by
28
+ * comparing final strings. */
29
+ targetSource?: 'flag' | 'session' | 'env';
25
30
  url?: string;
26
- role?: string;
27
31
  into?: string;
28
32
  noScreenshot?: boolean;
29
33
  viewport?: string;
30
34
  fullPage?: boolean;
31
- height?: number;
32
35
  help?: boolean;
36
+ /** `--all` — boolean scope-widener (per-leaf semantics). */
37
+ all?: boolean;
38
+ /** `--session <id>` — explicit session id override (per-leaf semantics). */
39
+ session?: string;
40
+ /** `session start --hold` — hold one CDP browser connection open for the session's lifetime. */
41
+ hold?: boolean;
42
+ /** `session view --filter <section>` — show only one bundle-manifest section. */
43
+ filter?: string;
44
+ /** `session log --name <label>` */
45
+ name?: string;
33
46
  filterUrl?: string;
34
47
  filterStatus?: string;
35
48
  filterMethod?: string;
@@ -78,7 +91,7 @@ export interface ParsedArgs {
78
91
  from?: string;
79
92
  /** `sweep --to <val>` — raw string; axis units vary (px/dpr/zoom). */
80
93
  to?: string;
81
- /** `sweep --viewport-height <val>` — raw string, distinct from `--height`. */
94
+ /** `sweep --viewport-height <val>` — raw string for the viewport-height sweep axis. */
82
95
  viewportHeight?: string;
83
96
  /** `motion rec --stop --rec-id <id>` — explicit recording id override. */
84
97
  recId?: string;
@@ -105,4 +118,6 @@ export interface ParsedArgs {
105
118
  prop?: string;
106
119
  /** `motion response --action <action>` */
107
120
  action?: string;
121
+ /** `motion response --occurrence <n>` — 1-based occurrence of a repeated action label. */
122
+ occurrence?: number;
108
123
  }
@@ -0,0 +1,21 @@
1
+ import { invalidInput } from '../errors.js';
2
+
3
+ export interface Viewport {
4
+ readonly width: number;
5
+ readonly height: number;
6
+ }
7
+
8
+ const VIEWPORT_PATTERN = /^([1-9]\d*)x([1-9]\d*)$/;
9
+
10
+ /** Parses the one viewport grammar used by every live capture command. */
11
+ export function parseViewport(value: string): Viewport {
12
+ const match = VIEWPORT_PATTERN.exec(value);
13
+ if (match) {
14
+ const width = Number(match[1]);
15
+ const height = Number(match[2]);
16
+ if (Number.isSafeInteger(width) && Number.isSafeInteger(height)) {
17
+ return { width, height };
18
+ }
19
+ }
20
+ throw invalidInput(`--viewport must match <positive-safe-int>x<positive-safe-int> using lowercase x with no whitespace; received "${value}"`, 'invalid_viewport');
21
+ }
package/src/cdp.ts CHANGED
@@ -1,38 +1,16 @@
1
- #!/usr/bin/env tsx
2
1
  /**
3
- * CDP (Chrome DevTools Protocol) — browser automation via exec-first design.
2
+ * CDP (Chrome DevTools Protocol) — the library barrel over the CDP substrate.
4
3
  *
5
- * The agent writes arbitrary JS and passes it to `exec`. No training-wheel
6
- * commands for click/type/wait — the agent composes whatever it needs.
7
- *
8
- * Commands:
9
- * capture exec <code> Execute JS in a tab (the primary interface)
10
- * capture exec --file <path> Execute JS from file
11
- * capture detect Detect CDP port
12
- * capture list List all browser tabs
13
- * capture open <url> Open URL in browser (returns tab ID)
14
- * capture reset-tab <url> Abandon stuck tab, open fresh one
15
- * capture screenshot Capture screenshot
16
- * capture a11y Get accessibility tree
17
- * capture record Passive HAR recording
18
- * capture navigate <url> Navigate + record HAR
19
- * capture har create|read|delete Manage HAR recordings
20
- *
21
- * Options:
22
- * --port <port> Override CDP port (auto-detects if not specified)
23
- * --target <tabId> Target tab by exact ID (preferred, parallel-safe)
24
- * --new Force open a new tab (open command)
25
- * --record Enable HAR recording (exec)
26
- * --har <id> Append traffic to a HAR recording
27
- * --har-out <path> HAR output path
28
- * --file <path> Read JS from file (exec)
29
- * --duration <secs> Recording duration (record, default: 10)
30
- * --settle <ms> Settle time after navigation (navigate, default: 2000)
31
- * --out <path> Output path (screenshot)
32
- * --height <px> Override viewport height (screenshot)
33
- * --full-page Capture entire scrollable page (screenshot)
34
- * --json JSON output (a11y)
35
- * --interactive Interactive elements only (a11y)
4
+ * Re-exports the client, target discovery, screenshot, a11y, console-recording,
5
+ * and tab-lock building blocks, plus `cdpMain` (the routed command dispatch).
6
+ * HAR ownership is NOT exported here: the session HAR manager
7
+ * (`src/har-manager.ts`) fed by the internal streaming recorder
8
+ * (`src/cdp/har-recorder.ts`, a direct import of the recorder bridge) is the
9
+ * sole HAR lane.
10
+ * The command surface itself lives under `src/cdp/commands/` and is routed by
11
+ * `src/capture.ts` → `src/cdp/dispatch.ts`. This module is a pure barrel:
12
+ * `src/capture.ts` is the ONLY entrypoint and the ONLY error/termination
13
+ * boundary — nothing here executes, renders, or exits.
36
14
  */
37
15
 
38
16
  export { CDPClient } from './cdp/client.js';
@@ -42,32 +20,8 @@ export { listTargets, findTab, findTabById, openTab } from './cdp/targets.js';
42
20
  export { captureScreenshot } from './cdp/screenshot.js';
43
21
  export type { A11yNode } from './cdp/a11y.js';
44
22
  export { getAccessibilityTree } from './cdp/a11y.js';
45
- export { executeInBrowser } from './cdp/execute.js';
46
- export type { ExecuteOptions, ExecuteResult } from './cdp/execute.js';
47
- export { recordTraffic, navigateAndRecord, navigateAndWait } from './cdp/record.js';
48
- export type { RecordOptions, RecordResult, NavigateAndRecordOptions, NavigateAndRecordResult } from './cdp/record.js';
49
- export { HARRecorder } from './cdp/har-recorder.js';
23
+ export { navigateAndWait } from './cdp/record.js';
50
24
  export { ConsoleRecorder } from './cdp/console-recorder.js';
51
25
  export type { ConsoleEntry } from './cdp/console-recorder.js';
52
26
  export { acquireTabLock, isTabLocked, releaseTabLock, withTabLock } from './cdp/tab-lock.js';
53
27
  export { cdpMain } from './cdp/dispatch.js';
54
-
55
- // Only run CLI when executed directly (not imported via capture.ts)
56
- const isMainModule =
57
- process.argv[1]?.endsWith('cdp.ts') ||
58
- process.argv[1]?.endsWith('cdp.mjs');
59
- if (isMainModule) {
60
- cdpMain()
61
- .then(() => {
62
- // Flush stdout before exiting — process.exit() doesn't wait for stream drain
63
- if (process.stdout.writableNeedDrain) {
64
- process.stdout.once('drain', () => process.exit(0));
65
- } else {
66
- process.exit(0);
67
- }
68
- })
69
- .catch((err) => {
70
- console.error(err.message);
71
- process.exit(1);
72
- });
73
- }
@@ -0,0 +1,29 @@
1
+ /** Pure specification-help assembly from Capture's descriptor tree. */
2
+ import { type RegistryNode } from './registry.js';
3
+
4
+ const usage = (node: RegistryNode): string => `capture${node.path ? ` ${node.path}` : ''}`;
5
+ const parameter = (name: string, grammar: string, required: boolean, variadic = false): string => ` ${required ? '<' : '['}${name}${required ? '>' : ']'} ${grammar}${variadic ? ' (variadic)' : ''}`;
6
+
7
+ /** Renders only descriptor-owned content; help never parses inputs or performs effects. */
8
+ export function assembleHelp(node: RegistryNode): string {
9
+ const { help } = node;
10
+ const lines = [usage(node), '', help.description, '', `When to use: ${help.whenToUse}`, `Model: ${help.model ?? help.rubric}`];
11
+ if (help.globals) lines.push('', 'Globals:', help.globals);
12
+ if (help.ioContract) lines.push('', `I/O contract: ${help.ioContract}`);
13
+ if (node.kind === 'branch') {
14
+ const children = node.children.filter((child) => child.visibility === 'public');
15
+ if (children.length) {
16
+ lines.push('', 'Commands:');
17
+ for (const child of children) lines.push(` ${child.path.split(' ').at(-1)} ${child.help.description} When to use: ${child.help.whenToUse}`);
18
+ }
19
+ } else {
20
+ if (node.positionals.length || node.flags.length) {
21
+ lines.push('', 'Parameters:');
22
+ for (const positional of node.positionals) lines.push(parameter(positional.name, positional.grammar, positional.required, positional.variadic));
23
+ for (const flag of node.flags) lines.push(` ${flag.name}${flag.grammar === 'boolean' ? '' : ` <${flag.grammar}>`}${flag.required ? ' required' : ''}${flag.repeatable ? ' repeatable' : ''}${flag.default === undefined ? '' : ` default=${flag.default}`}${flag.values ? ` values=${flag.values.join('|')}` : ''}${flag.units ? ` units=${flag.units}` : ''}`);
24
+ }
25
+ lines.push('', `Constraints: ${help.constraints}`, `Output: ${help.orderedOutput}`, `Artifact ownership: ${help.artifactOwnership}`, `Effects: browser=${node.effects.browser}, session=${node.effects.session}, artifact=${node.effects.artifact}, environment=${node.effects.environment}`, `Recovery: ${node.recovery}`);
26
+ }
27
+ lines.push('', `Next action: ${help.followUp}`);
28
+ return `${lines.join('\n')}\n`;
29
+ }
@@ -0,0 +1,22 @@
1
+ /** Pure pre-dispatch control precedence for U15's future launcher wiring. */
2
+ import { assembleHelp } from './command-help.js';
3
+ import { hasHelpFlag, hasRootVersionFlag, walkCommand } from './command-walker.js';
4
+ import { CAPTURE_REGISTRY, type RegistryBranch, type RegistryNode } from './registry.js';
5
+
6
+ export type CommandResolution =
7
+ | { readonly kind: 'version' }
8
+ | { readonly kind: 'help'; readonly node: RegistryNode; readonly text: string }
9
+ | { readonly kind: 'dispatch'; readonly node: RegistryNode; readonly argv: readonly string[] }
10
+ | { readonly kind: 'unknown-path'; readonly received: string; readonly expected: readonly string[]; readonly path: string };
11
+
12
+ /**
13
+ * Applies only control precedence. It deliberately does not parse leaf argv,
14
+ * validate `--json`, invoke a handler, or allocate any effectful dependency.
15
+ */
16
+ export function resolveCommand(argv: readonly string[], root: RegistryBranch = CAPTURE_REGISTRY): CommandResolution {
17
+ if (hasRootVersionFlag(argv)) return { kind: 'version' };
18
+ const walked = walkCommand(argv, root);
19
+ if (walked.kind === 'unknown-path') return { kind: 'unknown-path', received: walked.received, expected: walked.expected, path: walked.resolved.path };
20
+ if (hasHelpFlag(walked.remaining)) return { kind: 'help', node: walked.node, text: assembleHelp(walked.node) };
21
+ return { kind: 'dispatch', node: walked.node, argv: walked.remaining };
22
+ }
@@ -0,0 +1,35 @@
1
+ /** Pure exact command-path resolution for the descriptor registry. */
2
+ import { CAPTURE_REGISTRY, type RegistryBranch, type RegistryNode } from './registry.js';
3
+
4
+ export type WalkResult =
5
+ | { readonly kind: 'resolved'; readonly node: RegistryNode; readonly remaining: readonly string[] }
6
+ | { readonly kind: 'unknown-path'; readonly received: string; readonly resolved: RegistryBranch; readonly expected: readonly string[]; readonly remaining: readonly string[] };
7
+
8
+ /**
9
+ * Resolves only exact canonical path tokens. It stops before the first option,
10
+ * resolves the deepest known leaf/branch, and leaves all leaf argv untouched.
11
+ * Parsing, output compatibility, effects, and handler dispatch are intentionally
12
+ * outside this pure walker.
13
+ */
14
+ export function walkCommand(argv: readonly string[], root: RegistryBranch = CAPTURE_REGISTRY): WalkResult {
15
+ let current: RegistryNode = root;
16
+ let index = 0;
17
+ while (current.kind === 'branch' && index < argv.length) {
18
+ const token = argv[index];
19
+ if (token.startsWith('-')) break;
20
+ const child: RegistryNode | undefined = current.children.find((candidate: RegistryNode) => candidate.path.split(' ').at(-1) === token);
21
+ if (!child) return { kind: 'unknown-path', received: token, resolved: current, expected: current.children.filter((candidate) => candidate.visibility === 'public').map((candidate) => candidate.path.split(' ').at(-1)!), remaining: argv.slice(index) };
22
+ current = child;
23
+ index += 1;
24
+ }
25
+ return { kind: 'resolved', node: current, remaining: argv.slice(index) };
26
+ }
27
+
28
+ export function hasHelpFlag(argv: readonly string[]): boolean {
29
+ return argv.includes('-h') || argv.includes('--help');
30
+ }
31
+
32
+ /** Root version is the sole accepted version spelling and wins before walking. */
33
+ export function hasRootVersionFlag(argv: readonly string[]): boolean {
34
+ return argv[0] === '--version';
35
+ }