@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
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recorder bridge: a specialized instance of capture's held bridge
|
|
3
|
+
* (`./bridge/server.ts`) that owns ONE tab-level CDP connection for the
|
|
4
|
+
* lifetime of a `capture motion rec` recording instead of a browser-level
|
|
5
|
+
* one. Same NDJSON-over-unix-socket wire style, same detached-process
|
|
6
|
+
* shape as `session start --hold` — this file adds no new IPC or process
|
|
7
|
+
* model, only the recorder-specific request handling and CDP driving.
|
|
8
|
+
*
|
|
9
|
+
* Lifecycle (see `./bridge/protocol.ts` for the wire messages):
|
|
10
|
+
* - `rec-start` — enables the motion-rec CDP domains, starts
|
|
11
|
+
* `Page.startScreencast` + `Tracing`, injects the Mutation/Resize/
|
|
12
|
+
* PerformanceObserver script, captures the clock baseline, and returns
|
|
13
|
+
* it for the caller's `markers.json` (this module does not write that
|
|
14
|
+
* file — see U14's lifecycle routing).
|
|
15
|
+
* - `cdp` — a CDP request routed through the held tab connection, used by
|
|
16
|
+
* intervening session commands during a composed recording. An optional
|
|
17
|
+
* `mark` brackets the dispatch with two performance.now() reads and
|
|
18
|
+
* appends a labeled input-landmark record to `events.jsonl`.
|
|
19
|
+
* - `rec-stop` — stops screencast + tracing, flushes/tears down the
|
|
20
|
+
* injected observers, and returns frame/event counts + duration for the
|
|
21
|
+
* caller's `meta.json` (this module does not write that file either).
|
|
22
|
+
*
|
|
23
|
+
* Frames land as PNGs under `{recDir}/frames/`; per-frame element rects
|
|
24
|
+
* append to `{recDir}/rects.jsonl`; trace batches, observer entries, input
|
|
25
|
+
* landmarks, and best-effort errors append to `{recDir}/events.jsonl` — all
|
|
26
|
+
* writes go through the secure artifact helpers (`../session/artifacts.js`,
|
|
27
|
+
* U03), never ad-hoc `fs.writeFile`.
|
|
28
|
+
*
|
|
29
|
+
* Known limitation: the injected observer script and `Runtime.addBinding`
|
|
30
|
+
* are only armed once, at `rec-start`. A `navigate` mid-recording wipes the
|
|
31
|
+
* page's JS world (and therefore the observers) same as any full
|
|
32
|
+
* navigation would; re-arming after `Page.frameNavigated` is not built
|
|
33
|
+
* here. `Page.startScreencast`/`Tracing` continue across navigation (they
|
|
34
|
+
* are CDP-session-scoped, not page-scoped), so frames/trace events are
|
|
35
|
+
* unaffected — only the Mutation/Resize/PerformanceObserver stream would
|
|
36
|
+
* gap across a navigate. Flagged for whichever unit drives `motion rec`
|
|
37
|
+
* across a `navigate` command.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import * as net from 'net';
|
|
41
|
+
import * as path from 'path';
|
|
42
|
+
import { CDPClient } from './client.js';
|
|
43
|
+
import { findTabByIdAcrossEndpoints } from './targets.js';
|
|
44
|
+
import { enableDomainsForMotionRec } from './domains.js';
|
|
45
|
+
import { readTraceClockBaseline, withDocumentPerformanceNow } from './timing.js';
|
|
46
|
+
import {
|
|
47
|
+
EventBroker,
|
|
48
|
+
listenNdjsonSocket,
|
|
49
|
+
closeNdjsonSocket,
|
|
50
|
+
installProcessCleanup,
|
|
51
|
+
} from './bridge/server.js';
|
|
52
|
+
import {
|
|
53
|
+
type RecorderRequest,
|
|
54
|
+
type RecorderResponse,
|
|
55
|
+
type RecorderClockBaselines,
|
|
56
|
+
type RecCdpRequest,
|
|
57
|
+
} from './bridge/protocol.js';
|
|
58
|
+
import { ensurePrivateDir, appendNdjsonPrivate, writeBinaryPrivate } from '../session/artifacts.js';
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Artifact record shapes
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export interface SampledRect {
|
|
65
|
+
tag: string;
|
|
66
|
+
id: string | null;
|
|
67
|
+
classes: string | null;
|
|
68
|
+
x: number;
|
|
69
|
+
y: number;
|
|
70
|
+
width: number;
|
|
71
|
+
height: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One line of `rects.jsonl` — element quads sampled at one screencast frame. */
|
|
75
|
+
export interface FrameRectsRecord {
|
|
76
|
+
frame: number;
|
|
77
|
+
file: string;
|
|
78
|
+
/** `Page.screencastFrame`'s own `metadata.timestamp` (wall-clock seconds), raw — not baseline-converted. */
|
|
79
|
+
screencastTimestamp: number | null;
|
|
80
|
+
recordedAtWallClockMs: number;
|
|
81
|
+
elements: SampledRect[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One line of `events.jsonl`. `kind` is `'input'` for a marked CDP
|
|
86
|
+
* dispatch, `'trace'` for a `Tracing.dataCollected` batch, `'error'` for a
|
|
87
|
+
* best-effort recorder failure, or one of the injected observer's own kinds
|
|
88
|
+
* (`'mutation'` / `'resize'` / `'performance'`) — those carry whatever
|
|
89
|
+
* fields the in-page emitter sent, hence the index signature.
|
|
90
|
+
*/
|
|
91
|
+
export interface RecorderEventRecord {
|
|
92
|
+
kind: string;
|
|
93
|
+
recordedAtWallClockMs: number;
|
|
94
|
+
[key: string]: unknown;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// RecorderSession — the CDP driving + artifact-writing core, injectable for
|
|
99
|
+
// tests (accepts anything shaped like the public surface of `CDPClient`).
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
export type RecorderCdpClient = Pick<CDPClient, 'send' | 'on' | 'onDisconnect' | 'close'>;
|
|
103
|
+
|
|
104
|
+
export type RecorderState = 'idle' | 'recording' | 'stopped';
|
|
105
|
+
|
|
106
|
+
export interface RecorderSessionOptions {
|
|
107
|
+
client: RecorderCdpClient;
|
|
108
|
+
/** Absolute path to `motion/recs/{recId}` — must resolve under `CAPTURE_ROOT`. */
|
|
109
|
+
recDir: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface RecorderStopSummary {
|
|
113
|
+
frameCount: number;
|
|
114
|
+
eventCount: number;
|
|
115
|
+
durationMs: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const RECORDER_BINDING_NAME = 'captureRecorderEmit';
|
|
119
|
+
|
|
120
|
+
const TRACE_CATEGORIES =
|
|
121
|
+
'devtools.timeline,disabled-by-default-devtools.timeline,disabled-by-default-devtools.timeline.frame,loading,blink.user_timing';
|
|
122
|
+
|
|
123
|
+
interface ScreencastFrameParams {
|
|
124
|
+
data: string;
|
|
125
|
+
metadata?: { timestamp?: number; [key: string]: unknown };
|
|
126
|
+
sessionId?: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `domains.ts`/`timing.ts` declare their parameter as the concrete
|
|
131
|
+
* `CDPClient` class (private-field members, so TS won't structurally
|
|
132
|
+
* accept a plain stub there); both only ever call `.send()` on it (see
|
|
133
|
+
* their own doc comments), so this cast is the deliberate, documented seam
|
|
134
|
+
* that keeps `RecorderSession` testable with a stub client while still
|
|
135
|
+
* reusing those shared helpers unchanged.
|
|
136
|
+
*/
|
|
137
|
+
function asCDPClient(client: RecorderCdpClient): CDPClient {
|
|
138
|
+
return client as unknown as CDPClient;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export class RecorderSession {
|
|
142
|
+
readonly recDir: string;
|
|
143
|
+
readonly framesDir: string;
|
|
144
|
+
readonly eventsPath: string;
|
|
145
|
+
readonly rectsPath: string;
|
|
146
|
+
state: RecorderState = 'idle';
|
|
147
|
+
|
|
148
|
+
private client: RecorderCdpClient;
|
|
149
|
+
private events: EventBroker;
|
|
150
|
+
private frameCount = 0;
|
|
151
|
+
private eventCount = 0;
|
|
152
|
+
private startedAtWallClockMs = 0;
|
|
153
|
+
|
|
154
|
+
constructor(opts: RecorderSessionOptions) {
|
|
155
|
+
this.client = opts.client;
|
|
156
|
+
this.recDir = ensurePrivateDir(opts.recDir);
|
|
157
|
+
this.framesDir = ensurePrivateDir(path.join(this.recDir, 'frames'));
|
|
158
|
+
this.eventsPath = path.join(this.recDir, 'events.jsonl');
|
|
159
|
+
this.rectsPath = path.join(this.recDir, 'rects.jsonl');
|
|
160
|
+
this.events = new EventBroker(this.client);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async start(): Promise<RecorderClockBaselines> {
|
|
164
|
+
if (this.state !== 'idle') {
|
|
165
|
+
throw new Error(`recorder cannot start from state "${this.state}"`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await enableDomainsForMotionRec(asCDPClient(this.client));
|
|
169
|
+
|
|
170
|
+
this.client.on('Page.screencastFrame', (params) => {
|
|
171
|
+
void this.handleScreencastFrame(params as ScreencastFrameParams);
|
|
172
|
+
});
|
|
173
|
+
this.client.on('Tracing.dataCollected', (params) => {
|
|
174
|
+
this.handleTraceData(params as { value: unknown[] });
|
|
175
|
+
});
|
|
176
|
+
this.client.on('Runtime.bindingCalled', (params) => {
|
|
177
|
+
this.handleBindingCalled(params as { name: string; payload: string });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await this.client.send('Runtime.addBinding', { name: RECORDER_BINDING_NAME });
|
|
181
|
+
await this.client.send('Runtime.evaluate', { expression: buildObserverScript(RECORDER_BINDING_NAME) });
|
|
182
|
+
|
|
183
|
+
await this.client.send('Page.startScreencast', { format: 'png', everyNthFrame: 1 });
|
|
184
|
+
await this.client.send('Tracing.start', { transferMode: 'ReportEvents', categories: TRACE_CATEGORIES });
|
|
185
|
+
|
|
186
|
+
const baseline = await readTraceClockBaseline(asCDPClient(this.client));
|
|
187
|
+
this.startedAtWallClockMs = Date.now();
|
|
188
|
+
this.state = 'recording';
|
|
189
|
+
return baseline;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Handles a `type: 'cdp'` request — plain passthrough, or bracketed + logged when `mark` is set. */
|
|
193
|
+
async handleCdp(req: RecCdpRequest): Promise<{ result?: unknown; event?: unknown }> {
|
|
194
|
+
const eventPromise = req.waitEvent ? this.events.wait(req.waitEvent, req.timeoutMs ?? 10000) : undefined;
|
|
195
|
+
|
|
196
|
+
if (req.mark) {
|
|
197
|
+
const bracket = await withDocumentPerformanceNow(asCDPClient(this.client), () =>
|
|
198
|
+
this.client.send(req.method, req.params ?? {}, req.timeoutMs ?? 60000),
|
|
199
|
+
);
|
|
200
|
+
this.appendEvent({
|
|
201
|
+
kind: 'input',
|
|
202
|
+
mark: req.mark,
|
|
203
|
+
method: req.method,
|
|
204
|
+
startPerformanceNow: bracket.startPerformanceNow,
|
|
205
|
+
endPerformanceNow: bracket.endPerformanceNow,
|
|
206
|
+
});
|
|
207
|
+
const event = eventPromise ? await eventPromise : undefined;
|
|
208
|
+
return { result: bracket.result, event };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const result = await this.client.send(req.method, req.params ?? {}, req.timeoutMs ?? 60000);
|
|
212
|
+
const event = eventPromise ? await eventPromise : undefined;
|
|
213
|
+
return { result, event };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async stop(): Promise<RecorderStopSummary> {
|
|
217
|
+
if (this.state !== 'recording') {
|
|
218
|
+
throw new Error(`cannot stop recorder in state "${this.state}"`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
await this.client.send('Page.stopScreencast');
|
|
223
|
+
} catch {
|
|
224
|
+
// Best-effort — the tab/browser may already be gone.
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const tracingComplete = this.events.wait('Tracing.tracingComplete', 5000).catch(() => undefined);
|
|
228
|
+
try {
|
|
229
|
+
await this.client.send('Tracing.end');
|
|
230
|
+
} catch {
|
|
231
|
+
// Best-effort.
|
|
232
|
+
}
|
|
233
|
+
await tracingComplete;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
await this.client.send('Runtime.evaluate', {
|
|
237
|
+
expression: 'window.__captureRecorder && window.__captureRecorder.teardown()',
|
|
238
|
+
});
|
|
239
|
+
} catch {
|
|
240
|
+
// Best-effort — the page may already be gone.
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
await this.client.send('Runtime.removeBinding', { name: RECORDER_BINDING_NAME });
|
|
244
|
+
} catch {
|
|
245
|
+
// Best-effort.
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
this.state = 'stopped';
|
|
249
|
+
return {
|
|
250
|
+
frameCount: this.frameCount,
|
|
251
|
+
eventCount: this.eventCount,
|
|
252
|
+
durationMs: Date.now() - this.startedAtWallClockMs,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private appendEvent(record: Omit<RecorderEventRecord, 'recordedAtWallClockMs'>): void {
|
|
257
|
+
this.eventCount++;
|
|
258
|
+
appendNdjsonPrivate(this.eventsPath, {
|
|
259
|
+
...record,
|
|
260
|
+
recordedAtWallClockMs: Date.now(),
|
|
261
|
+
} satisfies RecorderEventRecord);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private async handleScreencastFrame(params: ScreencastFrameParams): Promise<void> {
|
|
265
|
+
const frameIndex = this.frameCount++;
|
|
266
|
+
try {
|
|
267
|
+
if (params.sessionId !== undefined) {
|
|
268
|
+
await this.client.send('Page.screencastFrameAck', { sessionId: params.sessionId });
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
// Best-effort ack — losing one ack just risks the browser pausing the stream.
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const frameName = `frame-${String(frameIndex).padStart(6, '0')}.png`;
|
|
275
|
+
writeBinaryPrivate(path.join(this.framesDir, frameName), Buffer.from(params.data, 'base64'));
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const elements = await this.sampleRects();
|
|
279
|
+
appendNdjsonPrivate(this.rectsPath, {
|
|
280
|
+
frame: frameIndex,
|
|
281
|
+
file: frameName,
|
|
282
|
+
screencastTimestamp: params.metadata?.timestamp ?? null,
|
|
283
|
+
recordedAtWallClockMs: Date.now(),
|
|
284
|
+
elements,
|
|
285
|
+
} satisfies FrameRectsRecord);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
// A failed rect sample for one frame shouldn't take the recorder down;
|
|
288
|
+
// the frame PNG above is already written regardless.
|
|
289
|
+
this.appendEvent({
|
|
290
|
+
kind: 'error',
|
|
291
|
+
message: `rect sample failed for frame ${frameIndex}: ${err instanceof Error ? err.message : String(err)}`,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private handleTraceData(params: { value: unknown[] }): void {
|
|
297
|
+
this.appendEvent({ kind: 'trace', events: params.value });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private handleBindingCalled(params: { name: string; payload: string }): void {
|
|
301
|
+
if (params.name !== RECORDER_BINDING_NAME) return;
|
|
302
|
+
let payload: unknown;
|
|
303
|
+
try {
|
|
304
|
+
payload = JSON.parse(params.payload);
|
|
305
|
+
} catch {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (!payload || typeof payload !== 'object') return;
|
|
309
|
+
this.appendEvent(payload as Omit<RecorderEventRecord, 'recordedAtWallClockMs'>);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async sampleRects(): Promise<SampledRect[]> {
|
|
313
|
+
const evaluation = (await this.client.send('Runtime.evaluate', {
|
|
314
|
+
expression: SAMPLE_RECTS_EXPRESSION,
|
|
315
|
+
returnByValue: true,
|
|
316
|
+
})) as { result: { value?: unknown }; exceptionDetails?: unknown };
|
|
317
|
+
if (evaluation.exceptionDetails) {
|
|
318
|
+
throw new Error(`rect sampling failed: ${JSON.stringify(evaluation.exceptionDetails)}`);
|
|
319
|
+
}
|
|
320
|
+
return Array.isArray(evaluation.result.value) ? (evaluation.result.value as SampledRect[]) : [];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// Wire dispatch — the same "one request per connection, one response"
|
|
326
|
+
// convention the plain bridge uses (see ./bridge/server.ts).
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
export async function handleRecorderRequest(
|
|
330
|
+
session: RecorderSession,
|
|
331
|
+
req: RecorderRequest,
|
|
332
|
+
): Promise<RecorderResponse> {
|
|
333
|
+
try {
|
|
334
|
+
switch (req.type) {
|
|
335
|
+
case 'rec-start': {
|
|
336
|
+
const markers = await session.start();
|
|
337
|
+
return { reqId: req.reqId, ok: true, type: 'rec-start', markers };
|
|
338
|
+
}
|
|
339
|
+
case 'rec-stop': {
|
|
340
|
+
const summary = await session.stop();
|
|
341
|
+
return { reqId: req.reqId, ok: true, type: 'rec-stop', ...summary };
|
|
342
|
+
}
|
|
343
|
+
case 'cdp': {
|
|
344
|
+
const { result, event } = await session.handleCdp(req);
|
|
345
|
+
return { reqId: req.reqId, ok: true, type: 'cdp', result, event };
|
|
346
|
+
}
|
|
347
|
+
default: {
|
|
348
|
+
const exhaustive: never = req;
|
|
349
|
+
throw new Error(`Unknown recorder request: ${JSON.stringify(exhaustive)}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
} catch (err) {
|
|
353
|
+
return {
|
|
354
|
+
reqId: req.reqId,
|
|
355
|
+
ok: false,
|
|
356
|
+
type: req.type,
|
|
357
|
+
error: err instanceof Error ? err.message : String(err),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Process entrypoint — spawned detached by `./bridge/spawn.ts`'s
|
|
364
|
+
// `startRecorderBridge()`, invoked via `capture __bridge-serve ... recorder
|
|
365
|
+
// <recDir>` (see ./commands/bridge-serve.ts).
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
export interface RunRecorderBridgeOptions {
|
|
369
|
+
socketPath: string;
|
|
370
|
+
targetId: string;
|
|
371
|
+
recDir: string;
|
|
372
|
+
port?: number;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function runRecorderBridge(opts: RunRecorderBridgeOptions): Promise<void> {
|
|
376
|
+
const port = opts.port ?? (await resolvePort());
|
|
377
|
+
const resolved = await findTabByIdAcrossEndpoints(opts.targetId, port);
|
|
378
|
+
if (!resolved?.tab.webSocketDebuggerUrl) {
|
|
379
|
+
throw new Error(`No tab found for target "${opts.targetId}" on port ${port}.`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const client = new CDPClient(resolved.tab.webSocketDebuggerUrl);
|
|
383
|
+
await client.waitReady();
|
|
384
|
+
|
|
385
|
+
const session = new RecorderSession({ client, recDir: opts.recDir });
|
|
386
|
+
|
|
387
|
+
async function handleLine(line: string, socket: net.Socket): Promise<void> {
|
|
388
|
+
let req: RecorderRequest;
|
|
389
|
+
try {
|
|
390
|
+
req = JSON.parse(line);
|
|
391
|
+
} catch {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const resp = await handleRecorderRequest(session, req);
|
|
395
|
+
socket.write(JSON.stringify(resp) + '\n');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const server = await listenNdjsonSocket(opts.socketPath, handleLine);
|
|
399
|
+
const cleanup = (): void => {
|
|
400
|
+
closeNdjsonSocket(server, opts.socketPath);
|
|
401
|
+
try {
|
|
402
|
+
client.close();
|
|
403
|
+
} catch {
|
|
404
|
+
// Already closed.
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
installProcessCleanup(cleanup, client);
|
|
408
|
+
// Intentionally does not resolve further: the open server and the live
|
|
409
|
+
// tab websocket keep this detached process alive until the caller (U14's
|
|
410
|
+
// lifecycle routing) sends SIGTERM via the same `stopBridge()` used for
|
|
411
|
+
// the plain held bridge.
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function resolvePort(): Promise<number> {
|
|
415
|
+
const { detectCdpPort } = await import('./detect.js');
|
|
416
|
+
return detectCdpPort();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Injected in-page instrumentation
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Builds the IIFE injected once at `rec-start` via `Runtime.evaluate`. Sets
|
|
425
|
+
* up MutationObserver/ResizeObserver/PerformanceObserver on the document,
|
|
426
|
+
* each emitting NDJSON-ready records to the host over the CDP binding
|
|
427
|
+
* (`Runtime.addBinding` + `Runtime.bindingCalled`) — the standard CDP
|
|
428
|
+
* page-to-host channel, not a new IPC mechanism. `window.__captureRecorder`
|
|
429
|
+
* exposes `teardown()`, called at `rec-stop` to disconnect the observers.
|
|
430
|
+
*/
|
|
431
|
+
function buildObserverScript(bindingName: string): string {
|
|
432
|
+
return `(function() {
|
|
433
|
+
if (window.__captureRecorder) return;
|
|
434
|
+
var BINDING = ${JSON.stringify(bindingName)};
|
|
435
|
+
function emit(kind, payload) {
|
|
436
|
+
try {
|
|
437
|
+
var record = Object.assign({ kind: kind, performanceNowMs: performance.now() }, payload);
|
|
438
|
+
window[BINDING](JSON.stringify(record));
|
|
439
|
+
} catch (e) {}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
var mutationObserver = new MutationObserver(function(records) {
|
|
443
|
+
emit('mutation', {
|
|
444
|
+
count: records.length,
|
|
445
|
+
types: records.map(function(r) { return r.type; }),
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
mutationObserver.observe(document.documentElement, {
|
|
449
|
+
childList: true, subtree: true, attributes: true, characterData: true,
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
var resizeObserver = new ResizeObserver(function(entries) {
|
|
453
|
+
emit('resize', {
|
|
454
|
+
count: entries.length,
|
|
455
|
+
targets: entries.slice(0, 20).map(function(e) {
|
|
456
|
+
var rect = e.contentRect;
|
|
457
|
+
return { tag: e.target && e.target.tagName, width: rect.width, height: rect.height };
|
|
458
|
+
}),
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
resizeObserver.observe(document.documentElement);
|
|
462
|
+
|
|
463
|
+
var perfObservers = [];
|
|
464
|
+
['longtask', 'layout-shift', 'paint', 'mark', 'measure'].forEach(function(type) {
|
|
465
|
+
try {
|
|
466
|
+
if (!window.PerformanceObserver || !PerformanceObserver.supportedEntryTypes ||
|
|
467
|
+
PerformanceObserver.supportedEntryTypes.indexOf(type) === -1) return;
|
|
468
|
+
var po = new PerformanceObserver(function(list) {
|
|
469
|
+
list.getEntries().forEach(function(entry) {
|
|
470
|
+
emit('performance', {
|
|
471
|
+
entryType: entry.entryType,
|
|
472
|
+
name: entry.name,
|
|
473
|
+
startTime: entry.startTime,
|
|
474
|
+
duration: entry.duration,
|
|
475
|
+
value: entry.entryType === 'layout-shift' ? entry.value : undefined,
|
|
476
|
+
hadRecentInput: entry.entryType === 'layout-shift' ? entry.hadRecentInput : undefined,
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
po.observe({ type: type, buffered: true });
|
|
481
|
+
perfObservers.push(po);
|
|
482
|
+
} catch (e) {}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
window.__captureRecorder = {
|
|
486
|
+
teardown: function() {
|
|
487
|
+
try { mutationObserver.disconnect(); } catch (e) {}
|
|
488
|
+
try { resizeObserver.disconnect(); } catch (e) {}
|
|
489
|
+
perfObservers.forEach(function(po) {
|
|
490
|
+
try { po.disconnect(); } catch (e) {}
|
|
491
|
+
});
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
})()`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Single round-trip element-rect sample, evaluated once per screencast
|
|
499
|
+
* frame. Uses `getBoundingClientRect()` over every element (capped) rather
|
|
500
|
+
* than one CDP `DOM.getBoxModel` round trip per element — the cheap
|
|
501
|
+
* approximation this mechanism unit ships with; quad-accurate geometry
|
|
502
|
+
* (transforms, clipping, frame/shadow stitching) is the `measure snap`
|
|
503
|
+
* substrate's job (`geometry.json`), not the motion recorder's.
|
|
504
|
+
*/
|
|
505
|
+
const SAMPLE_RECTS_EXPRESSION = `(function() {
|
|
506
|
+
var out = [];
|
|
507
|
+
var all = document.querySelectorAll('*');
|
|
508
|
+
var max = 2000;
|
|
509
|
+
for (var i = 0; i < all.length && out.length < max; i++) {
|
|
510
|
+
var el = all[i];
|
|
511
|
+
var r = el.getBoundingClientRect();
|
|
512
|
+
if (r.width === 0 && r.height === 0) continue;
|
|
513
|
+
out.push({
|
|
514
|
+
tag: el.tagName.toLowerCase(),
|
|
515
|
+
id: el.id || null,
|
|
516
|
+
classes: typeof el.className === 'string' ? el.className : null,
|
|
517
|
+
x: r.x, y: r.y, width: r.width, height: r.height,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
return out;
|
|
521
|
+
})()`;
|