@crouton-kit/capture 1.3.7 → 1.3.8

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 (40) hide show
  1. package/bin/capture +2807 -1312
  2. package/package.json +4 -1
  3. package/src/capture.ts +10 -0
  4. package/src/cdp/args.ts +99 -10
  5. package/src/cdp/bridge/protocol.ts +99 -0
  6. package/src/cdp/bridge/server.ts +105 -43
  7. package/src/cdp/bridge/spawn.ts +62 -0
  8. package/src/cdp/commands/bridge-serve.ts +28 -3
  9. package/src/cdp/commands/cdp.ts +30 -2
  10. package/src/cdp/commands/measure/census.ts +31 -0
  11. package/src/cdp/commands/measure/check.ts +35 -0
  12. package/src/cdp/commands/measure/diff.ts +32 -0
  13. package/src/cdp/commands/measure/explain.ts +31 -0
  14. package/src/cdp/commands/measure/index.ts +114 -0
  15. package/src/cdp/commands/measure/map-focus.ts +24 -0
  16. package/src/cdp/commands/measure/map-layers.ts +25 -0
  17. package/src/cdp/commands/measure/map-scroll.ts +24 -0
  18. package/src/cdp/commands/measure/snap.ts +36 -0
  19. package/src/cdp/commands/measure/sweep.ts +30 -0
  20. package/src/cdp/commands/motion/index.ts +60 -0
  21. package/src/cdp/commands/motion/jank.ts +22 -0
  22. package/src/cdp/commands/motion/mask.ts +23 -0
  23. package/src/cdp/commands/motion/rec.ts +33 -0
  24. package/src/cdp/commands/motion/response.ts +26 -0
  25. package/src/cdp/commands/motion/timeline.ts +27 -0
  26. package/src/cdp/coordinates.ts +158 -0
  27. package/src/cdp/dispatch.ts +4 -0
  28. package/src/cdp/domains.ts +75 -0
  29. package/src/cdp/recorder-bridge.ts +521 -0
  30. package/src/cdp/source-map.ts +309 -0
  31. package/src/cdp/timing.ts +82 -0
  32. package/src/cdp/types.ts +66 -0
  33. package/src/cdp/usage.ts +2 -0
  34. package/src/output/artifact.ts +520 -0
  35. package/src/output/diff.ts +111 -0
  36. package/src/output/render.ts +593 -0
  37. package/src/output/selector.ts +185 -0
  38. package/src/session/artifacts.ts +229 -0
  39. package/src/session/commands.ts +119 -15
  40. package/src/session-context.ts +48 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.3.7",
3
+ "version": "1.3.8",
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"
@@ -33,6 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "devDependencies": {
36
+ "@types/pngjs": "^6.0.5",
36
37
  "@types/ws": "^8.18.1",
37
38
  "esbuild": "^0.24.0",
38
39
  "tsx": "^4",
@@ -40,6 +41,8 @@
40
41
  "zod": "^4.3.5"
41
42
  },
42
43
  "dependencies": {
44
+ "pixelmatch": "7.2.0",
45
+ "pngjs": "7.0.0",
43
46
  "ws": "^8.19.0"
44
47
  }
45
48
  }
package/src/capture.ts CHANGED
@@ -73,6 +73,8 @@ async function main(): Promise<void> {
73
73
  case "network":
74
74
  case "list":
75
75
  case "cdp":
76
+ case "measure":
77
+ case "motion":
76
78
  case "__bridge-serve":
77
79
  return cdpMain();
78
80
 
@@ -118,6 +120,14 @@ INTERACTION COMMANDS (work inside or outside a session)
118
120
  exec <code> | exec --file <path> Evaluate JS; expressions, return, and await are supported
119
121
  navigate <url> [--settle <ms>] Navigate the current tab + record HAR
120
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
+
121
131
  DIAGNOSTICS & ONE-OFFS (no session needed)
122
132
 
123
133
  detect Find running CDP endpoints
package/src/cdp/args.ts CHANGED
@@ -64,7 +64,11 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
64
64
  parsed.target = next;
65
65
  i++;
66
66
  } else if (arg === '--url' && next) {
67
+ // Kept last-wins for every existing command's single-target semantics.
68
+ // Also accumulated into `urls` so census/sweep-style leaves can accept
69
+ // the flag repeated for a multi-page set without disturbing that.
67
70
  parsed.url = next;
71
+ (parsed.urls ??= []).push(next);
68
72
  i++;
69
73
  } else if (arg === '--role' && next) {
70
74
  parsed.role = next;
@@ -75,7 +79,12 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
75
79
  } else if (arg === '--no-screenshot') {
76
80
  parsed.noScreenshot = true;
77
81
  } else if (arg === '--viewport' && next) {
82
+ // Kept last-wins for every existing command's single-value semantics.
83
+ // Also accumulated into `viewports` so check/snap/census-style leaves
84
+ // can accept the flag repeated for a multi-viewport sweep, mirroring
85
+ // the `--url` → { url, urls[] } pattern above.
78
86
  parsed.viewport = next;
87
+ (parsed.viewports ??= []).push(next);
79
88
  i++;
80
89
  } else if (arg === '--full-page') {
81
90
  parsed.fullPage = true;
@@ -108,6 +117,79 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
108
117
  } else if (arg === '--socket' && next) {
109
118
  parsed.socket = next;
110
119
  i++;
120
+ } else if (arg === '--freeze-animations') {
121
+ parsed.freezeAnimations = true;
122
+ } else if (arg === '--settle-timeout' && next) {
123
+ parsed.settleTimeout = parseInt(next, 10);
124
+ i++;
125
+ } else if (arg === '--capture-unsettled') {
126
+ parsed.captureUnsettled = true;
127
+ } else if (arg === '--pixels') {
128
+ parsed.pixels = true;
129
+ } else if (arg === '--state' && next) {
130
+ (parsed.state ??= []).push(next);
131
+ i++;
132
+ } else if (arg === '--for' && next) {
133
+ parsed.for = next;
134
+ i++;
135
+ } else if (arg === '--before' && next) {
136
+ parsed.before = next;
137
+ i++;
138
+ } else if (arg === '--after' && next) {
139
+ parsed.after = next;
140
+ i++;
141
+ } else if (arg === '--full') {
142
+ parsed.full = true;
143
+ } else if (arg === '--gate') {
144
+ parsed.gate = true;
145
+ } else if (arg === '--snap' && next) {
146
+ (parsed.snap ??= []).push(next);
147
+ i++;
148
+ } else if (arg === '--set-file' && next) {
149
+ parsed.setFile = next;
150
+ i++;
151
+ } else if (arg === '--axis' && next) {
152
+ parsed.axis = next;
153
+ i++;
154
+ } else if (arg === '--from' && next) {
155
+ // Raw string — axis units vary (width px / dpr float / zoom); parseInt
156
+ // would lossily truncate a non-integer axis value.
157
+ parsed.from = next;
158
+ i++;
159
+ } else if (arg === '--to' && next) {
160
+ parsed.to = next;
161
+ i++;
162
+ } else if (arg === '--viewport-height' && next) {
163
+ parsed.viewportHeight = next;
164
+ i++;
165
+ } else if (arg === '--rec-id' && next) {
166
+ parsed.recId = next;
167
+ i++;
168
+ } else if (arg === '--selector' && next) {
169
+ parsed.selector = next;
170
+ i++;
171
+ } else if (arg === '--size') {
172
+ parsed.size = true;
173
+ } else if (arg === '--text') {
174
+ parsed.text = true;
175
+ } else if (arg === '--form') {
176
+ parsed.form = true;
177
+ } else if (arg === '--start') {
178
+ parsed.start = true;
179
+ } else if (arg === '--stop') {
180
+ parsed.stop = true;
181
+ } else if (arg === '--do' && next) {
182
+ parsed.do = next;
183
+ i++;
184
+ } else if (arg === '--element' && next) {
185
+ parsed.element = next;
186
+ i++;
187
+ } else if (arg === '--prop' && next) {
188
+ parsed.prop = next;
189
+ i++;
190
+ } else if (arg === '--action' && next) {
191
+ parsed.action = next;
192
+ i++;
111
193
  } else if (arg === '--help' || arg === '-h') {
112
194
  parsed.help = true;
113
195
  } else if (arg.startsWith('--')) {
@@ -118,7 +200,23 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
118
200
  }
119
201
  }
120
202
 
121
- // Fill gaps from environment variables (set by pipeline orchestrators)
203
+ // Fill gaps from the active session first. A session is an explicit,
204
+ // scoped choice the caller made (`capture session start`), so its
205
+ // target/har must win over ambient CDP_PORT/CDP_TARGET/CDP_HAR_ID env vars
206
+ // — those are meant for orchestrators only when NOT in a session (see
207
+ // `capture --help` TARGETING section). Letting a stale/inherited env var
208
+ // outrank an active session is how a command silently ends up on the
209
+ // wrong tab instead of the session's own.
210
+ const session = getActiveSession();
211
+ if (session) {
212
+ if (!parsed.har && session.harId) parsed.har = session.harId;
213
+ // An explicit --url picks a different (parallel) tab than the session's
214
+ // own — don't let the session's targetId clobber that choice.
215
+ if (!parsed.target && !parsed.url && session.targetId) parsed.target = session.targetId;
216
+ }
217
+
218
+ // Fill any still-empty gaps from environment variables (set by pipeline
219
+ // orchestrators that aren't using the session concept).
122
220
  if (!parsed.port && process.env.CDP_PORT) {
123
221
  const envPort = Number.parseInt(process.env.CDP_PORT, 10);
124
222
  if (Number.isNaN(envPort)) {
@@ -134,14 +232,5 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
134
232
  parsed.har = process.env.CDP_HAR_ID;
135
233
  }
136
234
 
137
- // Fill remaining gaps from active session context (explicit flags always win)
138
- const session = getActiveSession();
139
- if (session) {
140
- if (!parsed.har && session.harId) parsed.har = session.harId;
141
- // An explicit --url picks a different (parallel) tab than the session's
142
- // own — don't let the session's targetId clobber that choice.
143
- if (!parsed.target && !parsed.url && session.targetId) parsed.target = session.targetId;
144
- }
145
-
146
235
  return parsed as ParsedArgs;
147
236
  }
@@ -36,3 +36,102 @@ export interface BridgeResponseErr {
36
36
  }
37
37
 
38
38
  export type BridgeResponse = BridgeResponseOk | BridgeResponseErr;
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Recorder-mode protocol — the SAME NDJSON-over-unix-socket wire format as
42
+ // above, specialized for `capture motion rec` (see ../recorder-bridge.ts).
43
+ // The generic `BridgeRequest`/`BridgeResponse` are unchanged and still
44
+ // govern `capture cdp --browser` / `session start --hold`; a recorder-mode
45
+ // bridge process (spawned in recorder mode, see ../bridge/spawn.ts) speaks
46
+ // this union instead, one request per connection, one response, same as
47
+ // the plain bridge.
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /** One synchronized (performance.now, wall-clock) pair, captured when the recorder arms. */
51
+ export interface RecorderClockBaselines {
52
+ performanceNowMs: number;
53
+ wallClockMs: number;
54
+ }
55
+
56
+ /**
57
+ * Arms the recorder on its held tab connection: enables the motion-rec CDP
58
+ * domains, starts `Page.startScreencast` + `Tracing`, injects the
59
+ * Mutation/Resize/Performance observers, and captures the clock baseline.
60
+ * A recorder process only accepts one `rec-start` while `state==="idle"`.
61
+ */
62
+ export interface RecStartRequest {
63
+ reqId: number;
64
+ type: 'rec-start';
65
+ }
66
+
67
+ export interface RecStartResponseOk {
68
+ reqId: number;
69
+ ok: true;
70
+ type: 'rec-start';
71
+ /** Returned for the caller to persist into `markers.json` — the recorder itself does not write that file. */
72
+ markers: RecorderClockBaselines;
73
+ }
74
+
75
+ /**
76
+ * Stops screencast + tracing, flushes the injected observers, and tears
77
+ * down the recorder's subscriptions (not the socket — the caller closes
78
+ * the bridge process once it has read the response). Returns counts for
79
+ * the caller's `meta.json`; the recorder itself does not write that file.
80
+ */
81
+ export interface RecStopRequest {
82
+ reqId: number;
83
+ type: 'rec-stop';
84
+ }
85
+
86
+ export interface RecStopResponseOk {
87
+ reqId: number;
88
+ ok: true;
89
+ type: 'rec-stop';
90
+ frameCount: number;
91
+ eventCount: number;
92
+ durationMs: number;
93
+ }
94
+
95
+ /**
96
+ * A CDP request routed through the recorder's held tab connection — the
97
+ * mechanism intervening session commands (`click`, `type`, `navigate`, ...)
98
+ * use during a composed recording. Setting `mark` brackets the dispatch
99
+ * with two performance.now() reads taken in the page's execution context
100
+ * (`../timing.ts`'s `withDocumentPerformanceNow`) and appends a labeled
101
+ * input-landmark record to `events.jsonl`, tying the action to the frame
102
+ * timeline — this is the "marked CDP request message" the protocol
103
+ * supports. Omit `mark` for a plain passthrough call (still observed
104
+ * natively by the recorder's own event subscriptions, just not logged as a
105
+ * distinct input landmark).
106
+ */
107
+ export interface RecCdpRequest {
108
+ reqId: number;
109
+ type: 'cdp';
110
+ method: string;
111
+ params?: Record<string, unknown>;
112
+ mark?: string;
113
+ waitEvent?: string;
114
+ timeoutMs?: number;
115
+ }
116
+
117
+ export interface RecCdpResponseOk {
118
+ reqId: number;
119
+ ok: true;
120
+ type: 'cdp';
121
+ result?: unknown;
122
+ event?: unknown;
123
+ }
124
+
125
+ export type RecorderRequest = RecStartRequest | RecStopRequest | RecCdpRequest;
126
+
127
+ export interface RecorderResponseErr {
128
+ reqId: number;
129
+ ok: false;
130
+ type: RecorderRequest['type'];
131
+ error: string;
132
+ }
133
+
134
+ export type RecStartResponse = RecStartResponseOk | RecorderResponseErr;
135
+ export type RecStopResponse = RecStopResponseOk | RecorderResponseErr;
136
+ export type RecCdpResponse = RecCdpResponseOk | RecorderResponseErr;
137
+ export type RecorderResponse = RecStartResponse | RecStopResponse | RecCdpResponse;
@@ -14,7 +14,7 @@
14
14
  import * as fs from 'fs';
15
15
  import * as net from 'net';
16
16
  import * as path from 'path';
17
- import { getBrowserClient } from '../targets.js';
17
+ import { getBrowserClient, findTabById } from '../targets.js';
18
18
  import { type CDPClient } from '../client.js';
19
19
  import { type BridgeRequest, type BridgeResponse } from './protocol.js';
20
20
 
@@ -22,12 +22,20 @@ interface EventWaiter {
22
22
  resolve: (v: unknown) => void;
23
23
  }
24
24
 
25
- class EventBroker {
25
+ /**
26
+ * Buffers CDP events by name and resolves `wait()` callers FIFO, either
27
+ * immediately (from the buffer) or when the next matching event arrives.
28
+ * Shared by the plain browser-level bridge and the recorder bridge
29
+ * (`../recorder-bridge.ts`) — both hold one long-lived `CDPClient` and need
30
+ * the same "consume the next occurrence of this event" primitive for
31
+ * `waitEvent`-bearing requests.
32
+ */
33
+ export class EventBroker {
26
34
  private queues = new Map<string, unknown[]>();
27
35
  private waiters = new Map<string, EventWaiter[]>();
28
36
  private listening = new Set<string>();
29
37
 
30
- constructor(private client: CDPClient) {}
38
+ constructor(private client: Pick<CDPClient, 'on'>) {}
31
39
 
32
40
  private ensureListening(eventName: string): void {
33
41
  if (this.listening.has(eventName)) return;
@@ -75,7 +83,8 @@ class EventBroker {
75
83
  }
76
84
 
77
85
  export async function runBridgeServer(socketPath: string, port?: number): Promise<void> {
78
- const { client } = await getBrowserClient(await resolvePort(port));
86
+ const resolvedPort = await resolvePort(port);
87
+ const { client } = await getBrowserClient(resolvedPort);
79
88
  const events = new EventBroker(client);
80
89
 
81
90
  // Cache flattened Target.attachToTarget sessions so repeated requests
@@ -85,33 +94,20 @@ export async function runBridgeServer(socketPath: string, port?: number): Promis
85
94
  async function attach(targetId: string): Promise<string> {
86
95
  const cached = targetSessions.get(targetId);
87
96
  if (cached) return cached;
88
- const result = (await client.send('Target.attachToTarget', { targetId, flatten: true })) as {
97
+ // Accept the same 8-char-prefix targeting every other capture command
98
+ // promises (see the top-level --help TARGETING section) instead of
99
+ // requiring the full 32-char target id here.
100
+ const tab = await findTabById(resolvedPort, targetId);
101
+ if (!tab) {
102
+ throw new Error(`No target found for "${targetId}" on port ${resolvedPort}. Run "capture list" to see available tabs.`);
103
+ }
104
+ const result = (await client.send('Target.attachToTarget', { targetId: tab.id, flatten: true })) as {
89
105
  sessionId: string;
90
106
  };
91
107
  targetSessions.set(targetId, result.sessionId);
92
108
  return result.sessionId;
93
109
  }
94
110
 
95
- fs.mkdirSync(path.dirname(socketPath), { recursive: true });
96
- try {
97
- fs.unlinkSync(socketPath);
98
- } catch {
99
- // No stale socket to remove.
100
- }
101
-
102
- const server = net.createServer((socket) => {
103
- let buffer = '';
104
- socket.on('data', (chunk) => {
105
- buffer += chunk.toString('utf-8');
106
- let idx: number;
107
- while ((idx = buffer.indexOf('\n')) >= 0) {
108
- const line = buffer.slice(0, idx);
109
- buffer = buffer.slice(idx + 1);
110
- if (line.trim()) void handleLine(line, socket);
111
- }
112
- });
113
- });
114
-
115
111
  async function handleLine(line: string, socket: net.Socket): Promise<void> {
116
112
  let req: BridgeRequest;
117
113
  try {
@@ -132,23 +128,97 @@ export async function runBridgeServer(socketPath: string, port?: number): Promis
132
128
  socket.write(JSON.stringify(resp) + '\n');
133
129
  }
134
130
 
131
+ const server = await listenNdjsonSocket(socketPath, handleLine);
132
+
135
133
  const cleanup = (): void => {
136
- try {
137
- server.close();
138
- } catch {
139
- // Already closed.
140
- }
134
+ closeNdjsonSocket(server, socketPath);
141
135
  try {
142
136
  client.close();
143
137
  } catch {
144
138
  // Already closed.
145
139
  }
146
- try {
147
- fs.unlinkSync(socketPath);
148
- } catch {
149
- // Already gone.
150
- }
151
140
  };
141
+ installProcessCleanup(cleanup, client);
142
+ // Intentionally does not resolve further work here: the open server and
143
+ // the live websocket keep the event loop (and this detached process) alive
144
+ // until `stopBridge()` sends SIGTERM.
145
+ }
146
+
147
+ /**
148
+ * Ensures `socketPath`'s parent directory exists and removes any stale
149
+ * socket file left at that path (a previous bridge process that died
150
+ * without cleaning up). Shared preparation step before binding a new
151
+ * `net.Server` there.
152
+ */
153
+ export function prepareSocketPath(socketPath: string): void {
154
+ fs.mkdirSync(path.dirname(socketPath), { recursive: true });
155
+ try {
156
+ fs.unlinkSync(socketPath);
157
+ } catch {
158
+ // No stale socket to remove.
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Binds a Unix-domain `net.Server` at `socketPath` that frames each
164
+ * connection's input as newline-delimited JSON, invoking `handleLine` once
165
+ * per complete line. This is the wire framing every bridge mode (plain
166
+ * browser-level bridge, recorder bridge) shares — "one request per
167
+ * connection, one response, then the socket closes" is a convention the
168
+ * caller's `handleLine` implements by writing exactly one response per
169
+ * line and letting the client end the connection; the server itself is
170
+ * agnostic to that convention and would happily frame a connection that
171
+ * sends multiple lines.
172
+ */
173
+ export async function listenNdjsonSocket(
174
+ socketPath: string,
175
+ handleLine: (line: string, socket: net.Socket) => void | Promise<void>,
176
+ ): Promise<net.Server> {
177
+ prepareSocketPath(socketPath);
178
+
179
+ const server = net.createServer((socket) => {
180
+ let buffer = '';
181
+ socket.on('data', (chunk) => {
182
+ buffer += chunk.toString('utf-8');
183
+ let idx: number;
184
+ while ((idx = buffer.indexOf('\n')) >= 0) {
185
+ const line = buffer.slice(0, idx);
186
+ buffer = buffer.slice(idx + 1);
187
+ if (line.trim()) void handleLine(line, socket);
188
+ }
189
+ });
190
+ });
191
+
192
+ await new Promise<void>((resolve, reject) => {
193
+ server.on('error', reject);
194
+ server.listen(socketPath, () => resolve());
195
+ });
196
+ return server;
197
+ }
198
+
199
+ /** Closes the socket server and best-effort unlinks the socket file. */
200
+ export function closeNdjsonSocket(server: net.Server, socketPath: string): void {
201
+ try {
202
+ server.close();
203
+ } catch {
204
+ // Already closed.
205
+ }
206
+ try {
207
+ fs.unlinkSync(socketPath);
208
+ } catch {
209
+ // Already gone.
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Wires SIGTERM/SIGINT and the held client's disconnect into one `cleanup`
215
+ * call followed by `process.exit(0)` — the shutdown sequence every
216
+ * detached bridge process (plain or recorder) uses.
217
+ */
218
+ export function installProcessCleanup(
219
+ cleanup: () => void,
220
+ client: Pick<CDPClient, 'onDisconnect'>,
221
+ ): void {
152
222
  process.on('SIGTERM', () => {
153
223
  cleanup();
154
224
  process.exit(0);
@@ -162,14 +232,6 @@ export async function runBridgeServer(socketPath: string, port?: number): Promis
162
232
  cleanup();
163
233
  process.exit(0);
164
234
  });
165
-
166
- await new Promise<void>((resolve, reject) => {
167
- server.on('error', reject);
168
- server.listen(socketPath, () => resolve());
169
- });
170
- // Intentionally does not resolve further work here: the open server and
171
- // the live websocket keep the event loop (and this detached process) alive
172
- // until `stopBridge()` sends SIGTERM.
173
235
  }
174
236
 
175
237
  async function resolvePort(port?: number): Promise<number> {
@@ -12,6 +12,11 @@ export function bridgeSocketPath(sessionDir: string): string {
12
12
  return path.join(sessionDir, 'bridge.sock');
13
13
  }
14
14
 
15
+ /** The recorder bridge's own socket, scoped to its recording dir so it can't collide with a session's plain `--hold` bridge. */
16
+ export function recorderSocketPath(recDir: string): string {
17
+ return path.join(recDir, 'recorder.sock');
18
+ }
19
+
15
20
  export async function startBridge(
16
21
  sessionDir: string,
17
22
  port: number,
@@ -63,3 +68,60 @@ export function stopBridge(pid: number | null | undefined, socketPath: string |
63
68
  }
64
69
  }
65
70
  }
71
+
72
+ /**
73
+ * Spawns the same detached `__bridge-serve` entrypoint in recorder mode:
74
+ * it connects to one tab (`targetId`) instead of the browser level, and
75
+ * drives `capture motion rec`'s screencast/tracing/observers instead of
76
+ * proxying arbitrary `--browser` CDP calls. Mirrors `startBridge()`'s
77
+ * spawn + "wait for the socket file to appear" pattern; the caller
78
+ * (U14's lifecycle routing) tears it down with the same `stopBridge()`
79
+ * used for the plain held bridge.
80
+ *
81
+ * Recorder mode is selected via a positional (`recorder <recDir>`) rather
82
+ * than a new flag — `capture`'s CLI arg parser (`src/cdp/args.ts`) is not
83
+ * owned by this unit, but `positional` is already generic passthrough.
84
+ */
85
+ export async function startRecorderBridge(
86
+ socketPath: string,
87
+ port: number,
88
+ targetId: string,
89
+ recDir: string,
90
+ timeoutMs = 5000,
91
+ ): Promise<{ socketPath: string; pid: number }> {
92
+ const scriptPath = process.argv[1];
93
+ const child = spawn(
94
+ process.execPath,
95
+ [
96
+ scriptPath,
97
+ '__bridge-serve',
98
+ '--socket',
99
+ socketPath,
100
+ '--port',
101
+ String(port),
102
+ '--target',
103
+ targetId,
104
+ 'recorder',
105
+ recDir,
106
+ ],
107
+ { detached: true, stdio: 'ignore' },
108
+ );
109
+ child.unref();
110
+ const pid = child.pid;
111
+ if (!pid) {
112
+ throw new Error('Failed to spawn recorder bridge process.');
113
+ }
114
+
115
+ const start = Date.now();
116
+ while (!fs.existsSync(socketPath)) {
117
+ if (Date.now() - start > timeoutMs) {
118
+ throw new Error(
119
+ `Recorder bridge (pid ${pid}) did not come up within ${timeoutMs}ms. ` +
120
+ `Check that target "${targetId}" is reachable on port ${port}.`,
121
+ );
122
+ }
123
+ await new Promise((r) => setTimeout(r, 50));
124
+ }
125
+
126
+ return { socketPath, pid };
127
+ }
@@ -1,16 +1,41 @@
1
1
  import { runBridgeServer } from '../bridge/server.js';
2
+ import { runRecorderBridge } from '../recorder-bridge.js';
2
3
  import { type ParsedArgs } from '../types.js';
3
4
 
4
5
  /**
5
6
  * Hidden entrypoint spawned detached by `capture session start --hold`
6
- * (see `bridge/spawn.ts`). Not documented in `capture --help` \u2014 nothing
7
- * calls this directly.
7
+ * (see `bridge/spawn.ts`'s `startBridge()`) or by `capture motion rec
8
+ * --start` in recorder mode (`startRecorderBridge()`). Not documented in
9
+ * `capture --help` — nothing calls this directly.
10
+ *
11
+ * Recorder mode is selected by a `recorder <recDir>` positional rather
12
+ * than a new flag, since `src/cdp/args.ts`'s fixed flag set isn't owned by
13
+ * this unit but `parsed.positional` is already generic passthrough:
14
+ * capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> recorder <recDir>
8
15
  */
9
16
  export async function cmdBridgeServe(parsed: ParsedArgs, _args: string[]): Promise<void> {
10
17
  if (!parsed.socket) {
11
- console.error('Usage: capture __bridge-serve --socket <path> [--port <cdpPort>]');
18
+ console.error(
19
+ 'Usage: capture __bridge-serve --socket <path> [--port <cdpPort>] [--target <tabId> recorder <recDir>]',
20
+ );
12
21
  process.exit(1);
13
22
  }
23
+
24
+ if (parsed.positional[0] === 'recorder') {
25
+ const recDir = parsed.positional[1];
26
+ if (!parsed.target || !recDir) {
27
+ console.error(
28
+ 'Usage: capture __bridge-serve --socket <path> --port <cdpPort> --target <tabId> recorder <recDir>',
29
+ );
30
+ process.exit(1);
31
+ }
32
+ await runRecorderBridge({ socketPath: parsed.socket, port: parsed.port, targetId: parsed.target, recDir });
33
+ // Deliberately does not exit: the open Unix socket server and the live
34
+ // tab websocket keep this detached process alive until the caller sends
35
+ // it SIGTERM (same teardown as the plain held bridge).
36
+ return;
37
+ }
38
+
14
39
  await runBridgeServer(parsed.socket, parsed.port);
15
40
  // Deliberately does not exit: the open Unix socket server and the live
16
41
  // browser websocket keep this detached process alive until `session stop`
@@ -14,7 +14,7 @@
14
14
  import * as fs from 'fs';
15
15
  import { getActiveSession } from '../../session-context.js';
16
16
  import { connectForCommand } from '../connection.js';
17
- import { getBrowserClient } from '../targets.js';
17
+ import { getBrowserClient, findTabById } from '../targets.js';
18
18
  import { detectCdpPort } from '../detect.js';
19
19
  import { sendBridgeRequest } from '../bridge/client.js';
20
20
  import { type CDPClient } from '../client.js';
@@ -112,6 +112,14 @@ async function runBrowserScope(
112
112
  });
113
113
  if (!resp.ok) {
114
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
+ }
115
123
  process.exit(1);
116
124
  }
117
125
  console.log(JSON.stringify({ result: resp.result, event: resp.event }, null, 2));
@@ -129,8 +137,16 @@ async function runBrowserScope(
129
137
  try {
130
138
  let sessionId: string | undefined;
131
139
  if (parsed.target) {
140
+ // Accept the same 8-char-prefix targeting every other capture command
141
+ // promises (see the top-level --help TARGETING section) instead of
142
+ // requiring the full 32-char target id here.
143
+ const tab = await findTabById(port, parsed.target);
144
+ 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);
147
+ }
132
148
  const attached = (await client.send('Target.attachToTarget', {
133
- targetId: parsed.target,
149
+ targetId: tab.id,
134
150
  flatten: true,
135
151
  })) as { sessionId: string };
136
152
  sessionId = attached.sessionId;
@@ -139,6 +155,18 @@ async function runBrowserScope(
139
155
  const result = method ? await client.send(method, params ?? {}, 60000, sessionId) : undefined;
140
156
  const event = eventPromise ? await eventPromise : undefined;
141
157
  console.log(JSON.stringify({ result, event }, null, 2));
158
+ } 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);
142
170
  } finally {
143
171
  client.close();
144
172
  }