@crouton-kit/capture 1.3.6 → 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.
- package/bin/capture +3269 -1281
- package/package.json +4 -1
- package/src/capture.ts +30 -0
- package/src/cdp/args.ts +113 -10
- package/src/cdp/bridge/client.ts +49 -0
- package/src/cdp/bridge/protocol.ts +137 -0
- package/src/cdp/bridge/server.ts +241 -0
- package/src/cdp/bridge/spawn.ts +127 -0
- package/src/cdp/client.ts +7 -1
- package/src/cdp/commands/bridge-serve.ts +43 -0
- package/src/cdp/commands/cdp.ts +203 -0
- package/src/cdp/commands/measure/census.ts +31 -0
- package/src/cdp/commands/measure/check.ts +35 -0
- package/src/cdp/commands/measure/diff.ts +32 -0
- package/src/cdp/commands/measure/explain.ts +31 -0
- package/src/cdp/commands/measure/index.ts +114 -0
- package/src/cdp/commands/measure/map-focus.ts +24 -0
- package/src/cdp/commands/measure/map-layers.ts +25 -0
- package/src/cdp/commands/measure/map-scroll.ts +24 -0
- package/src/cdp/commands/measure/snap.ts +36 -0
- package/src/cdp/commands/measure/sweep.ts +30 -0
- package/src/cdp/commands/motion/index.ts +60 -0
- package/src/cdp/commands/motion/jank.ts +22 -0
- package/src/cdp/commands/motion/mask.ts +23 -0
- package/src/cdp/commands/motion/rec.ts +33 -0
- package/src/cdp/commands/motion/response.ts +26 -0
- package/src/cdp/commands/motion/timeline.ts +27 -0
- package/src/cdp/coordinates.ts +158 -0
- package/src/cdp/dispatch.ts +8 -0
- package/src/cdp/domains.ts +75 -0
- package/src/cdp/recorder-bridge.ts +521 -0
- package/src/cdp/source-map.ts +309 -0
- package/src/cdp/targets.ts +8 -1
- package/src/cdp/timing.ts +82 -0
- package/src/cdp/types.ts +71 -0
- package/src/cdp/usage.ts +9 -0
- package/src/output/artifact.ts +520 -0
- package/src/output/diff.ts +111 -0
- package/src/output/render.ts +593 -0
- package/src/output/selector.ts +185 -0
- package/src/session/artifacts.ts +229 -0
- package/src/session/commands.ts +183 -20
- package/src/session-context.ts +50 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crouton-kit/capture",
|
|
3
|
-
"version": "1.3.
|
|
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
|
@@ -72,6 +72,10 @@ async function main(): Promise<void> {
|
|
|
72
72
|
case "lib":
|
|
73
73
|
case "network":
|
|
74
74
|
case "list":
|
|
75
|
+
case "cdp":
|
|
76
|
+
case "measure":
|
|
77
|
+
case "motion":
|
|
78
|
+
case "__bridge-serve":
|
|
75
79
|
return cdpMain();
|
|
76
80
|
|
|
77
81
|
default:
|
|
@@ -116,6 +120,14 @@ INTERACTION COMMANDS (work inside or outside a session)
|
|
|
116
120
|
exec <code> | exec --file <path> Evaluate JS; expressions, return, and await are supported
|
|
117
121
|
navigate <url> [--settle <ms>] Navigate the current tab + record HAR
|
|
118
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
|
+
|
|
119
131
|
DIAGNOSTICS & ONE-OFFS (no session needed)
|
|
120
132
|
|
|
121
133
|
detect Find running CDP endpoints
|
|
@@ -126,6 +138,24 @@ DIAGNOSTICS & ONE-OFFS (no session needed)
|
|
|
126
138
|
network <offline|online> Toggle connectivity for a tab
|
|
127
139
|
har create | read [id] | delete <id> Manage standalone HAR recordings
|
|
128
140
|
|
|
141
|
+
RAW CDP ESCAPE HATCH
|
|
142
|
+
|
|
143
|
+
cdp <Domain.method> [--params json] Send any CDP command capture doesn't wrap
|
|
144
|
+
[--browser] Target the browser connection (Browser.*, ServiceWorker.*)
|
|
145
|
+
[--wait-event name] Wait for a CDP event, e.g. after ServiceWorker.enable
|
|
146
|
+
|
|
147
|
+
Browser-level state (permission grants, ServiceWorker enablement) reverts
|
|
148
|
+
the instant its connection closes. session start --hold keeps ONE browser
|
|
149
|
+
connection open across commands so that state survives between them:
|
|
150
|
+
|
|
151
|
+
capture session start --url http://localhost:3000 --hold
|
|
152
|
+
capture cdp Browser.grantPermissions --browser --params '{"origin":"...","permissions":["notifications"]}'
|
|
153
|
+
capture cdp ServiceWorker.enable --browser --target <pageTabId>
|
|
154
|
+
capture cdp ServiceWorker.deliverPushMessage --browser --target <pageTabId> --params '{...}'
|
|
155
|
+
capture session stop <session-id>
|
|
156
|
+
|
|
157
|
+
capture cdp --help Full reference
|
|
158
|
+
|
|
129
159
|
LIBRARY (vault libs — dev checkout only)
|
|
130
160
|
|
|
131
161
|
lib list List available libs
|
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;
|
|
@@ -94,6 +103,93 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
|
94
103
|
} else if (arg === '--limit' && next) {
|
|
95
104
|
parsed.limit = parseInt(next, 10);
|
|
96
105
|
i++;
|
|
106
|
+
} else if (arg === '--browser') {
|
|
107
|
+
parsed.browser = true;
|
|
108
|
+
} else if (arg === '--params' && next) {
|
|
109
|
+
parsed.params = next;
|
|
110
|
+
i++;
|
|
111
|
+
} else if (arg === '--wait-event' && next) {
|
|
112
|
+
parsed.waitEvent = next;
|
|
113
|
+
i++;
|
|
114
|
+
} else if (arg === '--timeout' && next) {
|
|
115
|
+
parsed.timeoutMs = parseInt(next, 10);
|
|
116
|
+
i++;
|
|
117
|
+
} else if (arg === '--socket' && next) {
|
|
118
|
+
parsed.socket = next;
|
|
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++;
|
|
97
193
|
} else if (arg === '--help' || arg === '-h') {
|
|
98
194
|
parsed.help = true;
|
|
99
195
|
} else if (arg.startsWith('--')) {
|
|
@@ -104,7 +200,23 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
|
104
200
|
}
|
|
105
201
|
}
|
|
106
202
|
|
|
107
|
-
// Fill gaps from
|
|
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).
|
|
108
220
|
if (!parsed.port && process.env.CDP_PORT) {
|
|
109
221
|
const envPort = Number.parseInt(process.env.CDP_PORT, 10);
|
|
110
222
|
if (Number.isNaN(envPort)) {
|
|
@@ -120,14 +232,5 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
|
|
|
120
232
|
parsed.har = process.env.CDP_HAR_ID;
|
|
121
233
|
}
|
|
122
234
|
|
|
123
|
-
// Fill remaining gaps from active session context (explicit flags always win)
|
|
124
|
-
const session = getActiveSession();
|
|
125
|
-
if (session) {
|
|
126
|
-
if (!parsed.har && session.harId) parsed.har = session.harId;
|
|
127
|
-
// An explicit --url picks a different (parallel) tab than the session's
|
|
128
|
-
// own — don't let the session's targetId clobber that choice.
|
|
129
|
-
if (!parsed.target && !parsed.url && session.targetId) parsed.target = session.targetId;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
235
|
return parsed as ParsedArgs;
|
|
133
236
|
}
|
|
@@ -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,137 @@
|
|
|
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;
|
|
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;
|
|
@@ -0,0 +1,241 @@
|
|
|
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, findTabById } 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
|
+
/**
|
|
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 {
|
|
34
|
+
private queues = new Map<string, unknown[]>();
|
|
35
|
+
private waiters = new Map<string, EventWaiter[]>();
|
|
36
|
+
private listening = new Set<string>();
|
|
37
|
+
|
|
38
|
+
constructor(private client: Pick<CDPClient, 'on'>) {}
|
|
39
|
+
|
|
40
|
+
private ensureListening(eventName: string): void {
|
|
41
|
+
if (this.listening.has(eventName)) return;
|
|
42
|
+
this.listening.add(eventName);
|
|
43
|
+
this.client.on(eventName, (params) => {
|
|
44
|
+
const waiters = this.waiters.get(eventName);
|
|
45
|
+
if (waiters && waiters.length > 0) {
|
|
46
|
+
waiters.shift()!.resolve(params);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const q = this.queues.get(eventName) ?? [];
|
|
50
|
+
q.push(params);
|
|
51
|
+
// Cap the buffer so an event nobody ever asks for can't leak memory.
|
|
52
|
+
if (q.length > 50) q.shift();
|
|
53
|
+
this.queues.set(eventName, q);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
wait(eventName: string, timeoutMs: number): Promise<unknown> {
|
|
58
|
+
this.ensureListening(eventName);
|
|
59
|
+
const q = this.queues.get(eventName);
|
|
60
|
+
if (q && q.length > 0) {
|
|
61
|
+
return Promise.resolve(q.shift());
|
|
62
|
+
}
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const list = this.waiters.get(eventName) ?? [];
|
|
65
|
+
const entry: EventWaiter = {
|
|
66
|
+
resolve: (v) => {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
resolve(v);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
const current = this.waiters.get(eventName);
|
|
73
|
+
if (current) {
|
|
74
|
+
const idx = current.indexOf(entry);
|
|
75
|
+
if (idx >= 0) current.splice(idx, 1);
|
|
76
|
+
}
|
|
77
|
+
reject(new Error(`Timed out after ${timeoutMs}ms waiting for event "${eventName}"`));
|
|
78
|
+
}, timeoutMs);
|
|
79
|
+
list.push(entry);
|
|
80
|
+
this.waiters.set(eventName, list);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function runBridgeServer(socketPath: string, port?: number): Promise<void> {
|
|
86
|
+
const resolvedPort = await resolvePort(port);
|
|
87
|
+
const { client } = await getBrowserClient(resolvedPort);
|
|
88
|
+
const events = new EventBroker(client);
|
|
89
|
+
|
|
90
|
+
// Cache flattened Target.attachToTarget sessions so repeated requests
|
|
91
|
+
// against the same target (e.g. ServiceWorker.enable then
|
|
92
|
+
// ServiceWorker.deliverPushMessage) reuse one attach instead of piling up.
|
|
93
|
+
const targetSessions = new Map<string, string>();
|
|
94
|
+
async function attach(targetId: string): Promise<string> {
|
|
95
|
+
const cached = targetSessions.get(targetId);
|
|
96
|
+
if (cached) return cached;
|
|
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 {
|
|
105
|
+
sessionId: string;
|
|
106
|
+
};
|
|
107
|
+
targetSessions.set(targetId, result.sessionId);
|
|
108
|
+
return result.sessionId;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function handleLine(line: string, socket: net.Socket): Promise<void> {
|
|
112
|
+
let req: BridgeRequest;
|
|
113
|
+
try {
|
|
114
|
+
req = JSON.parse(line);
|
|
115
|
+
} catch {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
let resp: BridgeResponse;
|
|
119
|
+
try {
|
|
120
|
+
const sessionId = req.targetId ? await attach(req.targetId) : undefined;
|
|
121
|
+
const eventPromise = req.waitEvent ? events.wait(req.waitEvent, req.timeoutMs ?? 10000) : undefined;
|
|
122
|
+
const result = req.method ? await client.send(req.method, req.params ?? {}, 60000, sessionId) : undefined;
|
|
123
|
+
const event = eventPromise ? await eventPromise : undefined;
|
|
124
|
+
resp = { reqId: req.reqId, ok: true, result, event };
|
|
125
|
+
} catch (err) {
|
|
126
|
+
resp = { reqId: req.reqId, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
127
|
+
}
|
|
128
|
+
socket.write(JSON.stringify(resp) + '\n');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const server = await listenNdjsonSocket(socketPath, handleLine);
|
|
132
|
+
|
|
133
|
+
const cleanup = (): void => {
|
|
134
|
+
closeNdjsonSocket(server, socketPath);
|
|
135
|
+
try {
|
|
136
|
+
client.close();
|
|
137
|
+
} catch {
|
|
138
|
+
// Already closed.
|
|
139
|
+
}
|
|
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 {
|
|
222
|
+
process.on('SIGTERM', () => {
|
|
223
|
+
cleanup();
|
|
224
|
+
process.exit(0);
|
|
225
|
+
});
|
|
226
|
+
process.on('SIGINT', () => {
|
|
227
|
+
cleanup();
|
|
228
|
+
process.exit(0);
|
|
229
|
+
});
|
|
230
|
+
client.onDisconnect(() => {
|
|
231
|
+
// The browser (or its CDP endpoint) went away — nothing left to bridge.
|
|
232
|
+
cleanup();
|
|
233
|
+
process.exit(0);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function resolvePort(port?: number): Promise<number> {
|
|
238
|
+
if (port) return port;
|
|
239
|
+
const { detectCdpPort } = await import('../detect.js');
|
|
240
|
+
return detectCdpPort();
|
|
241
|
+
}
|