@crouton-kit/capture 1.3.6 → 1.3.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.3.6",
3
+ "version": "1.3.7",
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"
package/src/capture.ts CHANGED
@@ -72,6 +72,8 @@ async function main(): Promise<void> {
72
72
  case "lib":
73
73
  case "network":
74
74
  case "list":
75
+ case "cdp":
76
+ case "__bridge-serve":
75
77
  return cdpMain();
76
78
 
77
79
  default:
@@ -126,6 +128,24 @@ DIAGNOSTICS & ONE-OFFS (no session needed)
126
128
  network <offline|online> Toggle connectivity for a tab
127
129
  har create | read [id] | delete <id> Manage standalone HAR recordings
128
130
 
131
+ RAW CDP ESCAPE HATCH
132
+
133
+ cdp <Domain.method> [--params json] Send any CDP command capture doesn't wrap
134
+ [--browser] Target the browser connection (Browser.*, ServiceWorker.*)
135
+ [--wait-event name] Wait for a CDP event, e.g. after ServiceWorker.enable
136
+
137
+ Browser-level state (permission grants, ServiceWorker enablement) reverts
138
+ the instant its connection closes. session start --hold keeps ONE browser
139
+ connection open across commands so that state survives between them:
140
+
141
+ capture session start --url http://localhost:3000 --hold
142
+ capture cdp Browser.grantPermissions --browser --params '{"origin":"...","permissions":["notifications"]}'
143
+ capture cdp ServiceWorker.enable --browser --target <pageTabId>
144
+ capture cdp ServiceWorker.deliverPushMessage --browser --target <pageTabId> --params '{...}'
145
+ capture session stop <session-id>
146
+
147
+ capture cdp --help Full reference
148
+
129
149
  LIBRARY (vault libs — dev checkout only)
130
150
 
131
151
  lib list List available libs
package/src/cdp/args.ts CHANGED
@@ -94,6 +94,20 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
94
94
  } else if (arg === '--limit' && next) {
95
95
  parsed.limit = parseInt(next, 10);
96
96
  i++;
97
+ } else if (arg === '--browser') {
98
+ parsed.browser = true;
99
+ } else if (arg === '--params' && next) {
100
+ parsed.params = next;
101
+ i++;
102
+ } else if (arg === '--wait-event' && next) {
103
+ parsed.waitEvent = next;
104
+ i++;
105
+ } else if (arg === '--timeout' && next) {
106
+ parsed.timeoutMs = parseInt(next, 10);
107
+ i++;
108
+ } else if (arg === '--socket' && next) {
109
+ parsed.socket = next;
110
+ i++;
97
111
  } else if (arg === '--help' || arg === '-h') {
98
112
  parsed.help = true;
99
113
  } else if (arg.startsWith('--')) {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Client side of the bridge protocol \u2014 used by `capture cdp --browser` to
3
+ * relay one request to an already-running bridge server over its socket.
4
+ */
5
+
6
+ import * as net from 'net';
7
+ import { type BridgeRequest, type BridgeResponse } from './protocol.js';
8
+
9
+ let reqCounter = 0;
10
+
11
+ export function sendBridgeRequest(
12
+ socketPath: string,
13
+ req: Omit<BridgeRequest, 'reqId'>,
14
+ ): Promise<BridgeResponse> {
15
+ const wireTimeoutMs = (req.timeoutMs ?? 10000) + 5000;
16
+ return new Promise((resolve, reject) => {
17
+ const reqId = ++reqCounter;
18
+ const socket = net.createConnection(socketPath);
19
+ let buffer = '';
20
+
21
+ const timer = setTimeout(() => {
22
+ socket.destroy();
23
+ reject(new Error(`Bridge request timed out after ${wireTimeoutMs}ms (socket: ${socketPath})`));
24
+ }, wireTimeoutMs);
25
+
26
+ socket.on('connect', () => {
27
+ socket.write(JSON.stringify({ ...req, reqId } satisfies BridgeRequest) + '\n');
28
+ });
29
+
30
+ socket.on('data', (chunk) => {
31
+ buffer += chunk.toString('utf-8');
32
+ const idx = buffer.indexOf('\n');
33
+ if (idx < 0) return;
34
+ clearTimeout(timer);
35
+ const line = buffer.slice(0, idx);
36
+ socket.end();
37
+ try {
38
+ resolve(JSON.parse(line) as BridgeResponse);
39
+ } catch (err) {
40
+ reject(err instanceof Error ? err : new Error(String(err)));
41
+ }
42
+ });
43
+
44
+ socket.on('error', (err) => {
45
+ clearTimeout(timer);
46
+ reject(err);
47
+ });
48
+ });
49
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Wire protocol between `capture cdp --browser` and the held bridge process
3
+ * (`capture __bridge-serve`). Newline-delimited JSON over a Unix domain
4
+ * socket — one request per connection, one response, then the socket closes.
5
+ */
6
+
7
+ export interface BridgeRequest {
8
+ reqId: number;
9
+ /** CDP method, e.g. "Browser.grantPermissions". Omit when only waiting for an event. */
10
+ method?: string;
11
+ params?: Record<string, unknown>;
12
+ /**
13
+ * Target (page or worker) to scope this call to, via a flattened
14
+ * `Target.attachToTarget` session on the SAME held browser websocket.
15
+ * Required for domains that live on a target rather than the browser
16
+ * itself (`ServiceWorker.*`, `Page.*`, ...) — omit for pure browser-level
17
+ * domains (`Browser.*`, `Target.*`).
18
+ */
19
+ targetId?: string;
20
+ /** CDP event name to wait for (consumes the next occurrence, FIFO, buffered if it already fired). */
21
+ waitEvent?: string;
22
+ timeoutMs?: number;
23
+ }
24
+
25
+ export interface BridgeResponseOk {
26
+ reqId: number;
27
+ ok: true;
28
+ result?: unknown;
29
+ event?: unknown;
30
+ }
31
+
32
+ export interface BridgeResponseErr {
33
+ reqId: number;
34
+ ok: false;
35
+ error: string;
36
+ }
37
+
38
+ export type BridgeResponse = BridgeResponseOk | BridgeResponseErr;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The bridge server: holds ONE browser-level CDP connection open for the
3
+ * lifetime of a held session and exposes it to (many, sequential) `capture
4
+ * cdp --browser` invocations over a Unix socket.
5
+ *
6
+ * Why this exists: `Browser.grantPermissions`, `ServiceWorker.enable`, and
7
+ * other browser-scoped CDP state are per-CLIENT and revert the instant the
8
+ * granting websocket disconnects. `capture` normally opens/closes a fresh
9
+ * websocket per command, so that state was gone by the next command. Running
10
+ * this as a long-lived detached process is what keeps ONE websocket (and
11
+ * therefore that state) alive across many separate `capture` invocations.
12
+ */
13
+
14
+ import * as fs from 'fs';
15
+ import * as net from 'net';
16
+ import * as path from 'path';
17
+ import { getBrowserClient } from '../targets.js';
18
+ import { type CDPClient } from '../client.js';
19
+ import { type BridgeRequest, type BridgeResponse } from './protocol.js';
20
+
21
+ interface EventWaiter {
22
+ resolve: (v: unknown) => void;
23
+ }
24
+
25
+ class EventBroker {
26
+ private queues = new Map<string, unknown[]>();
27
+ private waiters = new Map<string, EventWaiter[]>();
28
+ private listening = new Set<string>();
29
+
30
+ constructor(private client: CDPClient) {}
31
+
32
+ private ensureListening(eventName: string): void {
33
+ if (this.listening.has(eventName)) return;
34
+ this.listening.add(eventName);
35
+ this.client.on(eventName, (params) => {
36
+ const waiters = this.waiters.get(eventName);
37
+ if (waiters && waiters.length > 0) {
38
+ waiters.shift()!.resolve(params);
39
+ return;
40
+ }
41
+ const q = this.queues.get(eventName) ?? [];
42
+ q.push(params);
43
+ // Cap the buffer so an event nobody ever asks for can't leak memory.
44
+ if (q.length > 50) q.shift();
45
+ this.queues.set(eventName, q);
46
+ });
47
+ }
48
+
49
+ wait(eventName: string, timeoutMs: number): Promise<unknown> {
50
+ this.ensureListening(eventName);
51
+ const q = this.queues.get(eventName);
52
+ if (q && q.length > 0) {
53
+ return Promise.resolve(q.shift());
54
+ }
55
+ return new Promise((resolve, reject) => {
56
+ const list = this.waiters.get(eventName) ?? [];
57
+ const entry: EventWaiter = {
58
+ resolve: (v) => {
59
+ clearTimeout(timer);
60
+ resolve(v);
61
+ },
62
+ };
63
+ const timer = setTimeout(() => {
64
+ const current = this.waiters.get(eventName);
65
+ if (current) {
66
+ const idx = current.indexOf(entry);
67
+ if (idx >= 0) current.splice(idx, 1);
68
+ }
69
+ reject(new Error(`Timed out after ${timeoutMs}ms waiting for event "${eventName}"`));
70
+ }, timeoutMs);
71
+ list.push(entry);
72
+ this.waiters.set(eventName, list);
73
+ });
74
+ }
75
+ }
76
+
77
+ export async function runBridgeServer(socketPath: string, port?: number): Promise<void> {
78
+ const { client } = await getBrowserClient(await resolvePort(port));
79
+ const events = new EventBroker(client);
80
+
81
+ // Cache flattened Target.attachToTarget sessions so repeated requests
82
+ // against the same target (e.g. ServiceWorker.enable then
83
+ // ServiceWorker.deliverPushMessage) reuse one attach instead of piling up.
84
+ const targetSessions = new Map<string, string>();
85
+ async function attach(targetId: string): Promise<string> {
86
+ const cached = targetSessions.get(targetId);
87
+ if (cached) return cached;
88
+ const result = (await client.send('Target.attachToTarget', { targetId, flatten: true })) as {
89
+ sessionId: string;
90
+ };
91
+ targetSessions.set(targetId, result.sessionId);
92
+ return result.sessionId;
93
+ }
94
+
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
+ async function handleLine(line: string, socket: net.Socket): Promise<void> {
116
+ let req: BridgeRequest;
117
+ try {
118
+ req = JSON.parse(line);
119
+ } catch {
120
+ return;
121
+ }
122
+ let resp: BridgeResponse;
123
+ try {
124
+ const sessionId = req.targetId ? await attach(req.targetId) : undefined;
125
+ const eventPromise = req.waitEvent ? events.wait(req.waitEvent, req.timeoutMs ?? 10000) : undefined;
126
+ const result = req.method ? await client.send(req.method, req.params ?? {}, 60000, sessionId) : undefined;
127
+ const event = eventPromise ? await eventPromise : undefined;
128
+ resp = { reqId: req.reqId, ok: true, result, event };
129
+ } catch (err) {
130
+ resp = { reqId: req.reqId, ok: false, error: err instanceof Error ? err.message : String(err) };
131
+ }
132
+ socket.write(JSON.stringify(resp) + '\n');
133
+ }
134
+
135
+ const cleanup = (): void => {
136
+ try {
137
+ server.close();
138
+ } catch {
139
+ // Already closed.
140
+ }
141
+ try {
142
+ client.close();
143
+ } catch {
144
+ // Already closed.
145
+ }
146
+ try {
147
+ fs.unlinkSync(socketPath);
148
+ } catch {
149
+ // Already gone.
150
+ }
151
+ };
152
+ process.on('SIGTERM', () => {
153
+ cleanup();
154
+ process.exit(0);
155
+ });
156
+ process.on('SIGINT', () => {
157
+ cleanup();
158
+ process.exit(0);
159
+ });
160
+ client.onDisconnect(() => {
161
+ // The browser (or its CDP endpoint) went away — nothing left to bridge.
162
+ cleanup();
163
+ process.exit(0);
164
+ });
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
+ }
174
+
175
+ async function resolvePort(port?: number): Promise<number> {
176
+ if (port) return port;
177
+ const { detectCdpPort } = await import('../detect.js');
178
+ return detectCdpPort();
179
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Spawns/stops the detached bridge process for a held session
3
+ * (`capture session start --hold`). Mirrors the existing detached-child +
4
+ * pid-tracking pattern used for session log tailers (`session/commands.ts`).
5
+ */
6
+
7
+ import { spawn } from 'child_process';
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+
11
+ export function bridgeSocketPath(sessionDir: string): string {
12
+ return path.join(sessionDir, 'bridge.sock');
13
+ }
14
+
15
+ export async function startBridge(
16
+ sessionDir: string,
17
+ port: number,
18
+ timeoutMs = 5000,
19
+ ): Promise<{ socketPath: string; pid: number }> {
20
+ const socketPath = bridgeSocketPath(sessionDir);
21
+ // Re-invoke the currently-running capture entrypoint in a hidden mode.
22
+ // Only works against the built bin (a bundled, plain-node-runnable JS
23
+ // file) \u2014 running `npm run dev` (tsx) can't self-spawn this way.
24
+ const scriptPath = process.argv[1];
25
+ const child = spawn(
26
+ process.execPath,
27
+ [scriptPath, '__bridge-serve', '--socket', socketPath, '--port', String(port)],
28
+ { detached: true, stdio: 'ignore' },
29
+ );
30
+ child.unref();
31
+ const pid = child.pid;
32
+ if (!pid) {
33
+ throw new Error('Failed to spawn CDP bridge process.');
34
+ }
35
+
36
+ const start = Date.now();
37
+ while (!fs.existsSync(socketPath)) {
38
+ if (Date.now() - start > timeoutMs) {
39
+ throw new Error(
40
+ `CDP bridge (pid ${pid}) did not come up within ${timeoutMs}ms. ` +
41
+ `Check that a browser is reachable on port ${port} (capture detect).`,
42
+ );
43
+ }
44
+ await new Promise((r) => setTimeout(r, 50));
45
+ }
46
+
47
+ return { socketPath, pid };
48
+ }
49
+
50
+ export function stopBridge(pid: number | null | undefined, socketPath: string | null | undefined): void {
51
+ if (pid) {
52
+ try {
53
+ process.kill(pid, 'SIGTERM');
54
+ } catch {
55
+ // Already dead.
56
+ }
57
+ }
58
+ if (socketPath) {
59
+ try {
60
+ fs.unlinkSync(socketPath);
61
+ } catch {
62
+ // Already gone.
63
+ }
64
+ }
65
+ }
package/src/cdp/client.ts CHANGED
@@ -60,13 +60,14 @@ export class CDPClient {
60
60
  method: string,
61
61
  params: Record<string, unknown> = {},
62
62
  timeout = 60000,
63
+ sessionId?: string,
63
64
  ): Promise<unknown> {
64
65
  await this.ready;
65
66
  const id = ++this.messageId;
66
67
 
67
68
  return new Promise((resolve, reject) => {
68
69
  this.pendingRequests.set(id, { resolve, reject });
69
- this.ws.send(JSON.stringify({ id, method, params }));
70
+ this.ws.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params }));
70
71
 
71
72
  const timer = setTimeout(() => {
72
73
  if (this.pendingRequests.has(id)) {
@@ -86,6 +87,11 @@ export class CDPClient {
86
87
  this.eventHandlers.get(event)!.push(handler);
87
88
  }
88
89
 
90
+ /** Fires when the underlying websocket goes away (browser closed, crashed, CDP endpoint dropped). */
91
+ onDisconnect(handler: () => void): void {
92
+ this.ws.on('close', handler);
93
+ }
94
+
89
95
  close(): void {
90
96
  // Clear all pending timeouts to allow process to exit
91
97
  for (const timer of this.pendingTimeouts.values()) {
@@ -0,0 +1,18 @@
1
+ import { runBridgeServer } from '../bridge/server.js';
2
+ import { type ParsedArgs } from '../types.js';
3
+
4
+ /**
5
+ * 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.
8
+ */
9
+ export async function cmdBridgeServe(parsed: ParsedArgs, _args: string[]): Promise<void> {
10
+ if (!parsed.socket) {
11
+ console.error('Usage: capture __bridge-serve --socket <path> [--port <cdpPort>]');
12
+ process.exit(1);
13
+ }
14
+ await runBridgeServer(parsed.socket, parsed.port);
15
+ // Deliberately does not exit: the open Unix socket server and the live
16
+ // browser websocket keep this detached process alive until `session stop`
17
+ // sends it SIGTERM.
18
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Raw CDP passthrough \u2014 the escape hatch for domains no other capture
3
+ * command wraps (`Browser.grantPermissions`, `ServiceWorker.*`, etc.).
4
+ *
5
+ * `--browser` routes through the HELD connection (see `session start
6
+ * --hold` / `src/cdp/bridge/`) instead of a fresh page/tab websocket.
7
+ * Browser-scoped state (permission grants) and target-scoped state that was
8
+ * enabled on that same connection (`ServiceWorker.enable`, ...) is
9
+ * per-CLIENT and reverts the instant its websocket disconnects, so a plain
10
+ * one-shot call here only lasts for that single command \u2014 running inside a
11
+ * held session is what keeps it alive across commands.
12
+ */
13
+
14
+ import * as fs from 'fs';
15
+ import { getActiveSession } from '../../session-context.js';
16
+ import { connectForCommand } from '../connection.js';
17
+ import { getBrowserClient } from '../targets.js';
18
+ import { detectCdpPort } from '../detect.js';
19
+ import { sendBridgeRequest } from '../bridge/client.js';
20
+ import { type CDPClient } from '../client.js';
21
+ import { type ParsedArgs } from '../types.js';
22
+
23
+ const DEFAULT_TIMEOUT_MS = 10000;
24
+
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.`,
62
+ );
63
+ }
64
+
65
+ export async function cmdCdp(parsed: ParsedArgs, _args: string[]): Promise<void> {
66
+ if (parsed.help) {
67
+ printHelp();
68
+ process.exit(0);
69
+ }
70
+
71
+ const method = parsed.positional[0];
72
+ 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);
76
+ }
77
+
78
+ let params: Record<string, unknown> | undefined;
79
+ if (parsed.params) {
80
+ try {
81
+ params = JSON.parse(parsed.params);
82
+ } catch (err) {
83
+ console.error(`ERROR: --params is not valid JSON: ${err instanceof Error ? err.message : err}`);
84
+ process.exit(1);
85
+ }
86
+ }
87
+
88
+ const timeoutMs = parsed.timeoutMs ?? DEFAULT_TIMEOUT_MS;
89
+
90
+ if (parsed.browser) {
91
+ await runBrowserScope(method, params, parsed, timeoutMs);
92
+ return;
93
+ }
94
+
95
+ await runPageScope(method, params, parsed, timeoutMs);
96
+ }
97
+
98
+ async function runBrowserScope(
99
+ method: string | undefined,
100
+ params: Record<string, unknown> | undefined,
101
+ parsed: ParsedArgs,
102
+ timeoutMs: number,
103
+ ): Promise<void> {
104
+ const session = getActiveSession();
105
+ if (session?.bridgeSocket && fs.existsSync(session.bridgeSocket)) {
106
+ const resp = await sendBridgeRequest(session.bridgeSocket, {
107
+ method,
108
+ params,
109
+ targetId: parsed.target,
110
+ waitEvent: parsed.waitEvent,
111
+ timeoutMs,
112
+ });
113
+ if (!resp.ok) {
114
+ console.error(`ERROR: ${resp.error}`);
115
+ process.exit(1);
116
+ }
117
+ console.log(JSON.stringify({ result: resp.result, event: resp.event }, null, 2));
118
+ return;
119
+ }
120
+
121
+ console.error(
122
+ 'No held session bridge active \u2014 this connection closes when the command exits, ' +
123
+ 'so any browser-level grant or target enablement made here reverts immediately after.\n' +
124
+ 'For state that must survive multiple commands: capture session start --hold\n',
125
+ );
126
+
127
+ const port = parsed.port ?? (await detectCdpPort());
128
+ const { client } = await getBrowserClient(port);
129
+ try {
130
+ let sessionId: string | undefined;
131
+ if (parsed.target) {
132
+ const attached = (await client.send('Target.attachToTarget', {
133
+ targetId: parsed.target,
134
+ flatten: true,
135
+ })) as { sessionId: string };
136
+ sessionId = attached.sessionId;
137
+ }
138
+ const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : undefined;
139
+ const result = method ? await client.send(method, params ?? {}, 60000, sessionId) : undefined;
140
+ const event = eventPromise ? await eventPromise : undefined;
141
+ console.log(JSON.stringify({ result, event }, null, 2));
142
+ } finally {
143
+ client.close();
144
+ }
145
+ }
146
+
147
+ async function runPageScope(
148
+ method: string | undefined,
149
+ params: Record<string, unknown> | undefined,
150
+ parsed: ParsedArgs,
151
+ timeoutMs: number,
152
+ ): Promise<void> {
153
+ const { client } = await connectForCommand(parsed);
154
+ try {
155
+ const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : undefined;
156
+ const result = method ? await client.send(method, params ?? {}) : undefined;
157
+ const event = eventPromise ? await eventPromise : undefined;
158
+ console.log(JSON.stringify({ result, event }, null, 2));
159
+ } finally {
160
+ client.close();
161
+ }
162
+ }
163
+
164
+ function waitForEventOnce(client: CDPClient, eventName: string, timeoutMs: number): Promise<unknown> {
165
+ return new Promise((resolve, reject) => {
166
+ const timer = setTimeout(
167
+ () => reject(new Error(`Timed out after ${timeoutMs}ms waiting for event "${eventName}"`)),
168
+ timeoutMs,
169
+ );
170
+ client.on(eventName, (params) => {
171
+ clearTimeout(timer);
172
+ resolve(params);
173
+ });
174
+ });
175
+ }
@@ -6,6 +6,8 @@ import { cmdScreenshot, cmdClick, cmdType, cmdA11y } from './commands/ui.js';
6
6
  import { cmdRecord, cmdNavigate, cmdNetwork } from './commands/traffic.js';
7
7
  import { cmdHar } from './commands/har.js';
8
8
  import { cmdLib } from './commands/lib.js';
9
+ import { cmdCdp } from './commands/cdp.js';
10
+ import { cmdBridgeServe } from './commands/bridge-serve.js';
9
11
 
10
12
  export async function cdpMain(): Promise<void> {
11
13
  const args = process.argv.slice(2);
@@ -26,6 +28,8 @@ export async function cdpMain(): Promise<void> {
26
28
  case 'network': return cmdNetwork(parsed, args);
27
29
  case 'har': return cmdHar(parsed, args);
28
30
  case 'lib': return cmdLib(parsed, args);
31
+ case 'cdp': return cmdCdp(parsed, args);
32
+ case '__bridge-serve': return cmdBridgeServe(parsed, args);
29
33
  default:
30
34
  console.log(CDP_USAGE);
31
35
  }
@@ -2,7 +2,14 @@ import { CDPClient } from './client.js';
2
2
  import { detectCdpPortsAsync } from './detect.js';
3
3
  import { type CDPTarget } from './types.js';
4
4
 
5
- async function getBrowserClient(
5
+ /**
6
+ * Connects to the BROWSER-level CDP websocket (not a page/tab target).
7
+ * Browser-scoped domains (`Browser.grantPermissions`, `ServiceWorker.*`,
8
+ * `Target.*`) only take effect on this connection — and only for as long as
9
+ * it stays open, which is why `capture cdp --browser` routes through the
10
+ * session bridge (`src/cdp/bridge/`) instead of opening a fresh one per call.
11
+ */
12
+ export async function getBrowserClient(
6
13
  port: number,
7
14
  ): Promise<{ client: CDPClient; browserWsUrl: string }> {
8
15
  const versionResp = await fetch(`http://localhost:${port}/json/version`);
package/src/cdp/types.ts CHANGED
@@ -34,4 +34,9 @@ export interface ParsedArgs {
34
34
  filterStatus?: string;
35
35
  filterMethod?: string;
36
36
  limit?: number;
37
+ browser?: boolean;
38
+ params?: string;
39
+ waitEvent?: string;
40
+ timeoutMs?: number;
41
+ socket?: string;
37
42
  }