@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.
- package/bin/capture +2807 -1312
- package/package.json +4 -1
- package/src/capture.ts +10 -0
- package/src/cdp/args.ts +99 -10
- package/src/cdp/bridge/protocol.ts +99 -0
- package/src/cdp/bridge/server.ts +105 -43
- package/src/cdp/bridge/spawn.ts +62 -0
- package/src/cdp/commands/bridge-serve.ts +28 -3
- package/src/cdp/commands/cdp.ts +30 -2
- 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 +4 -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/timing.ts +82 -0
- package/src/cdp/types.ts +66 -0
- package/src/cdp/usage.ts +2 -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 +119 -15
- 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.
|
|
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
|
|
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;
|
package/src/cdp/bridge/server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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> {
|
package/src/cdp/bridge/spawn.ts
CHANGED
|
@@ -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`)
|
|
7
|
-
*
|
|
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(
|
|
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`
|
package/src/cdp/commands/cdp.ts
CHANGED
|
@@ -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:
|
|
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
|
}
|