@crouton-kit/capture 1.4.2 → 1.4.11
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/README.md +187 -0
- package/bin/capture +31278 -7312
- package/package.json +3 -1
- package/src/capture.ts +21 -18
- package/src/cdp/args.ts +269 -49
- package/src/cdp/bridge/server.ts +6 -5
- package/src/cdp/bridge/spawn.ts +41 -97
- package/src/cdp/commands/bridge-serve.ts +11 -21
- package/src/cdp/commands/cdp.ts +44 -22
- package/src/cdp/commands/heap/census.ts +61 -0
- package/src/cdp/commands/heap/common.ts +77 -0
- package/src/cdp/commands/heap/diff.ts +92 -0
- package/src/cdp/commands/heap/index.ts +38 -0
- package/src/cdp/commands/heap/objects.ts +42 -0
- package/src/cdp/commands/heap/retainers.ts +52 -0
- package/src/cdp/commands/heap/snapshot.ts +94 -0
- package/src/cdp/commands/lib.ts +9 -17
- package/src/cdp/commands/lighthouse.ts +414 -0
- package/src/cdp/commands/measure/census.ts +2 -1
- package/src/cdp/commands/measure/check.ts +70 -16
- package/src/cdp/commands/measure/diff.ts +1 -2
- package/src/cdp/commands/measure/explain.ts +44 -7
- package/src/cdp/commands/measure/index.ts +24 -32
- package/src/cdp/commands/measure/map-ax.ts +2 -1
- package/src/cdp/commands/measure/map-focus.ts +2 -1
- package/src/cdp/commands/measure/map-layers.ts +5 -3
- package/src/cdp/commands/measure/map-scroll.ts +2 -1
- package/src/cdp/commands/measure/snap.ts +5 -4
- package/src/cdp/commands/measure/sweep.ts +16 -11
- package/src/cdp/commands/measure/text.ts +147 -0
- package/src/cdp/commands/motion/index.ts +10 -18
- package/src/cdp/commands/motion/jank.ts +13 -22
- package/src/cdp/commands/motion/mask.ts +10 -9
- package/src/cdp/commands/motion/rec.ts +228 -26
- package/src/cdp/commands/motion/timeline.ts +1 -1
- package/src/cdp/commands/page/click.ts +15 -5
- package/src/cdp/commands/page/elements.ts +43 -11
- package/src/cdp/commands/page/exec.ts +53 -18
- package/src/cdp/commands/page/index.ts +20 -20
- package/src/cdp/commands/page/inspect.ts +338 -0
- package/src/cdp/commands/page/navigate.ts +83 -38
- package/src/cdp/commands/page/repeat.ts +176 -0
- package/src/cdp/commands/page/scroll.ts +10 -5
- package/src/cdp/commands/page/shot.ts +66 -31
- package/src/cdp/commands/page/type.ts +6 -2
- package/src/cdp/commands/perf/index.ts +32 -0
- package/src/cdp/commands/perf/insights.ts +214 -0
- package/src/cdp/commands/perf/trace.ts +235 -0
- package/src/cdp/commands/perf/vitals.ts +76 -0
- package/src/cdp/commands/session/collectors.ts +54 -0
- package/src/cdp/commands/stub.ts +10 -0
- package/src/cdp/commands/tab/close.ts +2 -2
- package/src/cdp/commands/tab/index.ts +16 -23
- package/src/cdp/commands/tab/launch.ts +1 -1
- package/src/cdp/commands/tab/mock/index.ts +24 -0
- package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
- package/src/cdp/commands/tab/mock/start.ts +65 -0
- package/src/cdp/commands/tab/mock/stop.ts +39 -0
- package/src/cdp/commands/tab/network.ts +22 -13
- package/src/cdp/commands/tab/reset.ts +11 -12
- package/src/cdp/connection.ts +19 -39
- package/src/cdp/detect.ts +27 -7
- package/src/cdp/dispatch.ts +25 -4
- package/src/cdp/domains.ts +2 -2
- package/src/cdp/har-recorder.ts +0 -1
- package/src/cdp/heap-snapshot.ts +647 -0
- package/src/cdp/host/client.ts +22 -0
- package/src/cdp/host/collector.ts +74 -0
- package/src/cdp/host/collectors/heap.ts +79 -0
- package/src/cdp/host/collectors/intercept.ts +247 -0
- package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
- package/src/cdp/host/collectors/trace.ts +108 -0
- package/src/cdp/host/core.ts +217 -0
- package/src/cdp/host/handle.ts +47 -0
- package/src/cdp/host/held-client.ts +91 -0
- package/src/cdp/host/kinds.ts +97 -0
- package/src/cdp/host/lifecycle.ts +123 -0
- package/src/cdp/host/server.ts +88 -0
- package/src/cdp/measure/check.ts +181 -16
- package/src/cdp/measure/collectors/hittest.ts +16 -10
- package/src/cdp/measure/collectors/scroll.ts +15 -7
- package/src/cdp/measure/collectors/style-provenance.ts +11 -0
- package/src/cdp/measure/collectors/styles.ts +4 -4
- package/src/cdp/measure/explain.ts +14 -2
- package/src/cdp/measure/map-layers.ts +52 -8
- package/src/cdp/measure/snapshot.ts +5 -3
- package/src/cdp/measure/sweep.ts +29 -6
- package/src/cdp/measure/text.ts +227 -0
- package/src/cdp/motion/jank.ts +29 -83
- package/src/cdp/motion/mask.ts +18 -9
- package/src/cdp/motion/recorder.ts +86 -731
- package/src/cdp/scope-lock.ts +9 -9
- package/src/cdp/screenshot.ts +51 -21
- package/src/cdp/timing.ts +5 -5
- package/src/cdp/trace-analysis.ts +320 -0
- package/src/cdp/types.ts +31 -1
- package/src/cdp.ts +4 -6
- package/src/har-manager.ts +2 -4
- package/src/interact.ts +50 -23
- package/src/output/artifact.ts +49 -5
- package/src/output/render.ts +60 -9
- package/src/output/selection.ts +6 -0
- package/src/session/artifacts.ts +122 -12
- package/src/session/commands.ts +348 -113
- package/src/session/coordinator.ts +0 -93
- package/src/session/har-redact.ts +29 -23
- package/src/session-context.ts +13 -9
- package/src/cdp/bridge/protocol.ts +0 -224
- package/src/cdp/recorder-client.ts +0 -326
- package/src/cdp/tab-lock.ts +0 -132
|
@@ -1,48 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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.
|
|
2
|
+
* The motion collector runs inside the collector host's single tab-level CDP
|
|
3
|
+
* connection. Its lifecycle enables the motion domains, starts screencast and
|
|
4
|
+
* tracing, injects observers, records marked dispatches, flushes the session
|
|
5
|
+
* HAR, and drains the recording on host stop.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
* - `har-flush` — the non-attributing action-return HAR health barrier: a
|
|
23
|
-
* recorder-routed page action awaits it before claiming success, so every
|
|
24
|
-
* HAR entry/body/append the streaming collector had already completed is
|
|
25
|
-
* durably in the owning session's live HAR first (health only, no counts
|
|
26
|
-
* — see `./bridge/protocol.ts`'s `RecHarFlushRequest`). Served directly in
|
|
27
|
-
* the wire lane (`runRecorderBridge`'s `handleLine`), because the collector
|
|
28
|
-
* is owned by this process body, not by `RecorderSession`.
|
|
7
|
+
* Frames land as PNGs under `{recDir}/frames/`; per-frame element rects append
|
|
8
|
+
* to `{recDir}/rects.jsonl`; trace batches, observer entries, input landmarks,
|
|
9
|
+
* and best-effort errors append to `{recDir}/events.jsonl` through the private
|
|
10
|
+
* artifact helpers.
|
|
29
11
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* `Page.frameNavigated` on the main frame and re-creates the isolated world
|
|
41
|
-
* + re-injects the observer script best-effort afterward (the binding, scoped
|
|
42
|
-
* to the world name, auto-reattaches to the recreated world without being
|
|
43
|
-
* re-issued), recording a `navigation-gap` marker in `events.jsonl` so
|
|
44
|
-
* downstream consumers know the Mutation/Resize/PerformanceObserver stream
|
|
45
|
-
* has a gap around that point.
|
|
12
|
+
* Navigation destroys the page's JS world and injected observers. `Tracing`
|
|
13
|
+
* continues across it because it is CDP-session-scoped, while the collector
|
|
14
|
+
* listens for `Page.frameNavigated` on the main frame and re-creates the
|
|
15
|
+
* isolated world + re-injects the observer script best-effort afterward (the
|
|
16
|
+
* binding, scoped to the world name, auto-reattaches to the recreated world
|
|
17
|
+
* without being re-issued). It also restarts the screencast because its
|
|
18
|
+
* damage-driven stream does not guarantee a destination-document frame,
|
|
19
|
+
* recording a `navigation-gap` marker in `events.jsonl` so downstream
|
|
20
|
+
* consumers know the Mutation/Resize/PerformanceObserver stream has a gap
|
|
21
|
+
* around that point.
|
|
46
22
|
*
|
|
47
23
|
* Binding channel: the page→host `Runtime.addBinding` channel is untrusted
|
|
48
24
|
* input — every payload must carry a per-recording unguessable nonce
|
|
@@ -82,30 +58,27 @@
|
|
|
82
58
|
*/
|
|
83
59
|
|
|
84
60
|
import * as crypto from 'crypto';
|
|
85
|
-
import * as
|
|
61
|
+
import * as fs from 'fs';
|
|
86
62
|
import * as path from 'path';
|
|
87
|
-
import { CDPClient } from '
|
|
88
|
-
import {
|
|
89
|
-
import {
|
|
90
|
-
import {
|
|
91
|
-
import {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
import { resolveIndexedObjectIds, describeBackendNodeId } from './measure/collectors/geometry.js';
|
|
107
|
-
import { HARRecorder } from './har-recorder.js';
|
|
108
|
-
import { appendToHarRecording } from '../har-manager.js';
|
|
63
|
+
import { CDPClient } from '../../client.js';
|
|
64
|
+
import { enableDomainsForMotionRec } from '../../domains.js';
|
|
65
|
+
import { readTraceClockBaseline, withDocumentPerformanceNow } from '../../timing.js';
|
|
66
|
+
import { EventBroker } from '../../bridge/server.js';
|
|
67
|
+
import { ensurePrivateDir, appendNdjsonPrivate, removeArtifactTree, writeBinaryPrivate, writeJsonPrivate } from '../../../session/artifacts.js';
|
|
68
|
+
import { resolveIndexedObjectIds, describeBackendNodeId } from '../../measure/collectors/geometry.js';
|
|
69
|
+
import { HARRecorder } from '../../har-recorder.js';
|
|
70
|
+
import { appendToHarRecording } from '../../../har-manager.js';
|
|
71
|
+
import { RetainedCollectorStartFailure, type Collector, type CollectorContext, type DrainCause, type DrainOutcome, type DispatchNotice, type DispatchOutcome } from '../collector.js';
|
|
72
|
+
|
|
73
|
+
export interface RecorderClockBaselines {
|
|
74
|
+
performanceNowMs: number;
|
|
75
|
+
wallClockMs: number;
|
|
76
|
+
firstScreencastTimestampSec: number | null;
|
|
77
|
+
firstTraceEventTsUs: number | null;
|
|
78
|
+
baselinesPending: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface RecCdpRequest extends RecCdpCall { reqId: number; }
|
|
109
82
|
|
|
110
83
|
/**
|
|
111
84
|
* The subset of `RecCdpRequest` that `RecorderSession.handleCdp` actually reads. The one-shot
|
|
@@ -122,6 +95,8 @@ export interface RecCdpCall {
|
|
|
122
95
|
mark?: string;
|
|
123
96
|
waitEvent?: string;
|
|
124
97
|
timeoutMs?: number;
|
|
98
|
+
/** Retains Document `Network.responseReceived` facts until this request's paired wait settles. */
|
|
99
|
+
observeDocumentResponse?: boolean;
|
|
125
100
|
}
|
|
126
101
|
|
|
127
102
|
// ---------------------------------------------------------------------------
|
|
@@ -180,6 +155,10 @@ export interface FrameRectsRecord {
|
|
|
180
155
|
screencastTimestampPrecision: 'frame-metadata';
|
|
181
156
|
recordedAtWallClockMs: number;
|
|
182
157
|
elements: SampledRect[];
|
|
158
|
+
/** The sampler reached its element cap before examining every remaining DOM candidate. */
|
|
159
|
+
elementSampleTruncated?: true;
|
|
160
|
+
/** The sampler exhausted its geometry-read budget while DOM candidates remained. */
|
|
161
|
+
candidateSampleTruncated?: true;
|
|
183
162
|
}
|
|
184
163
|
|
|
185
164
|
/**
|
|
@@ -200,7 +179,7 @@ export interface RecorderEventRecord {
|
|
|
200
179
|
// tests (accepts anything shaped like the public surface of `CDPClient`).
|
|
201
180
|
// ---------------------------------------------------------------------------
|
|
202
181
|
|
|
203
|
-
export type RecorderCdpClient = Pick<CDPClient, 'send' | 'on' | 'onDisconnect' | 'close'>;
|
|
182
|
+
export type RecorderCdpClient = Pick<CDPClient, 'send' | 'on' | 'off' | 'onDisconnect' | 'close'>;
|
|
204
183
|
|
|
205
184
|
/**
|
|
206
185
|
* `'starting'` covers `start()`'s initialization window — from the top of `start()` until the
|
|
@@ -240,6 +219,7 @@ export const OBSERVER_INSTALLED_SENTINEL = '__captureRecorderInstalled__';
|
|
|
240
219
|
|
|
241
220
|
const TRACE_CATEGORIES =
|
|
242
221
|
'devtools.timeline,disabled-by-default-devtools.timeline,disabled-by-default-devtools.timeline.frame,loading,blink.user_timing';
|
|
222
|
+
const SCREENCAST_OPTIONS = { format: 'png', everyNthFrame: 1 };
|
|
243
223
|
|
|
244
224
|
interface ScreencastFrameParams {
|
|
245
225
|
data: string;
|
|
@@ -266,18 +246,20 @@ const BINDING_RATE_LIMIT_PER_SECOND = 200;
|
|
|
266
246
|
// page-controlled DOM data (element tag/id/className strings, and an array length) read in the
|
|
267
247
|
// isolated world and returned once per screencast frame, so it must be re-validated host-side
|
|
268
248
|
// rather than trusted because the injected script capped it. The in-page cap
|
|
269
|
-
// (`buildSampleRectsExpression`'s `max =
|
|
249
|
+
// (`buildSampleRectsExpression`'s `max = 4000`) is an optimization only — a hostile page can
|
|
270
250
|
// corrupt or bypass in-page JS (e.g. clobbering `Array.prototype` before the script runs),
|
|
271
251
|
// so every one of these limits is re-enforced here, on the host, before a sample is ever
|
|
272
252
|
// appended to `rects.jsonl`.
|
|
273
253
|
// ---------------------------------------------------------------------------
|
|
274
254
|
|
|
275
255
|
/** Mirrors the in-page cap, re-enforced host-side regardless of what the page script actually returned. */
|
|
276
|
-
const MAX_RECT_ELEMENTS =
|
|
256
|
+
const MAX_RECT_ELEMENTS = 4000;
|
|
257
|
+
/** Bounds per-frame `getBoundingClientRect()` calls; one TreeWalker sentinel read establishes whether unvisited candidates remain. */
|
|
258
|
+
const MAX_RECT_GEOMETRY_CANDIDATES = 8000;
|
|
277
259
|
const MAX_RECT_TAG_LENGTH = 32;
|
|
278
260
|
const MAX_RECT_STRING_LENGTH = 256;
|
|
279
261
|
/** Total serialized-byte budget for one frame's sanitized rect array, independent of the element-count cap. */
|
|
280
|
-
const MAX_RECTS_SERIALIZED_BYTES =
|
|
262
|
+
const MAX_RECTS_SERIALIZED_BYTES = 1024 * 1024;
|
|
281
263
|
/** Bounds per-frame `DOM.describeNode` round-trip cost for the rect sampler's identity bridge —
|
|
282
264
|
* mirrors `hittest.ts`'s `MAX_BRIDGE_ELEMENTS` cap, tuned far lower because this bridge runs on
|
|
283
265
|
* EVERY screencast frame instead of once per snapshot. Elements past this cap are left
|
|
@@ -349,6 +331,10 @@ function sanitizeBoolean(value: unknown): boolean | undefined {
|
|
|
349
331
|
return typeof value === 'boolean' ? value : undefined;
|
|
350
332
|
}
|
|
351
333
|
|
|
334
|
+
function sanitizeElementSampleTruncated(value: unknown): true | undefined {
|
|
335
|
+
return value === true ? true : undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
352
338
|
/** Preserves an admitted observer string verbatim in its source artifact. */
|
|
353
339
|
function preserveObserverString(value: unknown): string | undefined {
|
|
354
340
|
return typeof value === 'string' ? value : undefined;
|
|
@@ -415,6 +401,10 @@ function asCDPClient(client: RecorderCdpClient): CDPClient {
|
|
|
415
401
|
return client as unknown as CDPClient;
|
|
416
402
|
}
|
|
417
403
|
|
|
404
|
+
function structuralMarkLabel(action: string): string {
|
|
405
|
+
return `mark-${crypto.createHash('sha256').update(action).digest('hex')}`;
|
|
406
|
+
}
|
|
407
|
+
|
|
418
408
|
export class RecorderSession {
|
|
419
409
|
readonly recDir: string;
|
|
420
410
|
readonly framesDir: string;
|
|
@@ -480,6 +470,7 @@ export class RecorderSession {
|
|
|
480
470
|
* captured throughout the rest of `'stopping'`; only the async resize path is a real
|
|
481
471
|
* post-stop-append race target. */
|
|
482
472
|
private resizeResolutionClosed = false;
|
|
473
|
+
private screencastRefresh = Promise.resolve();
|
|
483
474
|
|
|
484
475
|
constructor(opts: RecorderSessionOptions) {
|
|
485
476
|
this.client = opts.client;
|
|
@@ -498,67 +489,99 @@ export class RecorderSession {
|
|
|
498
489
|
// treats a navigation during initialization as live — recreating the isolated world and
|
|
499
490
|
// reinstalling the observer in the newest context rather than ignoring it.
|
|
500
491
|
this.state = 'starting';
|
|
492
|
+
let screencastMayBeStarted = false;
|
|
493
|
+
let tracingMayBeStarted = false;
|
|
501
494
|
|
|
502
|
-
|
|
495
|
+
try {
|
|
496
|
+
await enableDomainsForMotionRec(asCDPClient(this.client));
|
|
503
497
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
498
|
+
this.client.on('Page.screencastFrame', (params) => {
|
|
499
|
+
this.onScreencastFrameEvent(params as ScreencastFrameParams);
|
|
500
|
+
});
|
|
501
|
+
this.client.on('Tracing.dataCollected', (params) => {
|
|
502
|
+
this.handleTraceData(params as { value: unknown[] });
|
|
503
|
+
});
|
|
504
|
+
this.client.on('Runtime.bindingCalled', (params) => {
|
|
505
|
+
this.handleBindingCalled(params as { name: string; payload: string; executionContextId?: number });
|
|
506
|
+
});
|
|
507
|
+
this.client.on('Page.frameNavigated', (params) => {
|
|
508
|
+
const rearm = this.handleFrameNavigated(params as { frame?: { id?: string; parentId?: string; url?: string } });
|
|
509
|
+
this.pendingRearm.add(rearm);
|
|
510
|
+
void rearm.finally(() => {
|
|
511
|
+
this.pendingRearm.delete(rearm);
|
|
512
|
+
});
|
|
518
513
|
});
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
await this.ensureBinding();
|
|
522
|
-
await this.injectObserverScript(this.navGeneration);
|
|
523
|
-
|
|
524
|
-
// The first-frame/first-trace latch storage MUST exist before either stream is started:
|
|
525
|
-
// `Page.screencastFrame`/`Tracing.dataCollected` can only fire after `Page.startScreencast`/
|
|
526
|
-
// `Tracing.start` are issued, so reading the (performanceNowMs, wallClockMs) anchor and
|
|
527
|
-
// creating `this.baselines` here — before those two sends — closes the window where a
|
|
528
|
-
// frame/trace arriving immediately after the stream starts would otherwise hit
|
|
529
|
-
// `this.baselines === null` in the handler and have its real first timestamp discarded.
|
|
530
|
-
const clock = await readTraceClockBaseline(asCDPClient(this.client));
|
|
531
|
-
this.baselines = {
|
|
532
|
-
performanceNowMs: clock.performanceNowMs,
|
|
533
|
-
wallClockMs: clock.wallClockMs,
|
|
534
|
-
firstScreencastTimestampSec: null,
|
|
535
|
-
firstTraceEventTsUs: null,
|
|
536
|
-
baselinesPending: true,
|
|
537
|
-
};
|
|
538
514
|
|
|
539
|
-
|
|
540
|
-
|
|
515
|
+
await this.ensureBinding();
|
|
516
|
+
await this.injectObserverScript(this.navGeneration);
|
|
517
|
+
|
|
518
|
+
// The first-frame/first-trace latch storage MUST exist before either stream is started:
|
|
519
|
+
// `Page.screencastFrame`/`Tracing.dataCollected` can only fire after `Page.startScreencast`/
|
|
520
|
+
// `Tracing.start` are issued, so reading the (performanceNowMs, wallClockMs) anchor and
|
|
521
|
+
// creating `this.baselines` here — before those two sends — closes the window where a
|
|
522
|
+
// frame/trace arriving immediately after the stream starts would otherwise hit
|
|
523
|
+
// `this.baselines === null` in the handler and have its real first timestamp discarded.
|
|
524
|
+
const clock = await readTraceClockBaseline(asCDPClient(this.client));
|
|
525
|
+
this.baselines = {
|
|
526
|
+
performanceNowMs: clock.performanceNowMs,
|
|
527
|
+
wallClockMs: clock.wallClockMs,
|
|
528
|
+
firstScreencastTimestampSec: null,
|
|
529
|
+
firstTraceEventTsUs: null,
|
|
530
|
+
baselinesPending: true,
|
|
531
|
+
};
|
|
541
532
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
533
|
+
// Capture the generation before issuing the initial request: a navigation that starts while
|
|
534
|
+
// this request is in flight needs one refresh after the initial stream is owned.
|
|
535
|
+
const initialScreencastGeneration = this.navGeneration;
|
|
536
|
+
screencastMayBeStarted = true;
|
|
537
|
+
await this.client.send('Page.startScreencast', SCREENCAST_OPTIONS);
|
|
538
|
+
tracingMayBeStarted = true;
|
|
539
|
+
await this.client.send('Tracing.start', { transferMode: 'ReportEvents', categories: TRACE_CATEGORIES });
|
|
540
|
+
|
|
541
|
+
// A main-frame navigation anywhere in the initialization window above spawned a rearm that
|
|
542
|
+
// recreated the isolated world in the newest context; wait for every one to settle so start()
|
|
543
|
+
// never returns with the bridge bound to a world a startup navigation already destroyed.
|
|
544
|
+
await this.drainPendingRearms();
|
|
545
|
+
|
|
546
|
+
// Coalesce every startup navigation after the initial stream into its latest generation. A
|
|
547
|
+
// navigation can land while the refresh's stop request is in flight, so drain and retry the
|
|
548
|
+
// latest generation until one refresh completes without being superseded.
|
|
549
|
+
while (this.navGeneration > initialScreencastGeneration) {
|
|
550
|
+
const refreshGeneration = this.navGeneration;
|
|
551
|
+
this.requireLatestObserverGeneration();
|
|
552
|
+
await this.refreshScreencast(refreshGeneration);
|
|
553
|
+
await this.drainPendingRearms();
|
|
554
|
+
if (this.navGeneration === refreshGeneration) break;
|
|
555
|
+
}
|
|
556
|
+
this.requireLatestObserverGeneration();
|
|
546
557
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
558
|
+
this.startedAtWallClockMs = Date.now();
|
|
559
|
+
this.state = 'recording';
|
|
560
|
+
return { ...this.baselines };
|
|
561
|
+
} catch (err) {
|
|
562
|
+
this.acceptingFrames = false;
|
|
563
|
+
const cleanupErrors: unknown[] = [];
|
|
564
|
+
if (screencastMayBeStarted) {
|
|
565
|
+
try {
|
|
566
|
+
await this.client.send('Page.stopScreencast');
|
|
567
|
+
} catch (cleanupErr) {
|
|
568
|
+
cleanupErrors.push(cleanupErr);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (tracingMayBeStarted) {
|
|
572
|
+
try {
|
|
573
|
+
await this.client.send('Tracing.end');
|
|
574
|
+
} catch (cleanupErr) {
|
|
575
|
+
cleanupErrors.push(cleanupErr);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
this.state = 'stopped';
|
|
579
|
+
if (cleanupErrors.length > 0) {
|
|
580
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
581
|
+
throw new AggregateError([err, ...cleanupErrors], `recorder start failed: ${message}; stream cleanup failed`);
|
|
582
|
+
}
|
|
583
|
+
throw err;
|
|
557
584
|
}
|
|
558
|
-
|
|
559
|
-
this.startedAtWallClockMs = Date.now();
|
|
560
|
-
this.state = 'recording';
|
|
561
|
-
return { ...this.baselines };
|
|
562
585
|
}
|
|
563
586
|
|
|
564
587
|
/**
|
|
@@ -595,12 +618,15 @@ export class RecorderSession {
|
|
|
595
618
|
* FAILURE cancels the wait and throws. A wait-event-ONLY request (no method)
|
|
596
619
|
* that times out still rejects — there is no method result to preserve.
|
|
597
620
|
*/
|
|
598
|
-
async handleCdp(req: RecCdpRequest | RecCdpCall): Promise<{ result?: unknown; event?: unknown; waitOutcome?: 'observed' | 'bounded-timeout' }> {
|
|
621
|
+
async handleCdp(req: RecCdpRequest | RecCdpCall): Promise<{ result?: unknown; event?: unknown; waitOutcome?: 'observed' | 'bounded-timeout'; documentResponse?: { status: number; url: string } }> {
|
|
599
622
|
if (this.state === 'stopping' || this.state === 'stopped') {
|
|
600
623
|
throw new Error(`cannot dispatch cdp request in state "${this.state}"`);
|
|
601
624
|
}
|
|
602
625
|
const hasMethod = typeof req.method === 'string' && req.method.length > 0;
|
|
603
626
|
const hasWaitEvent = typeof req.waitEvent === 'string' && req.waitEvent.length > 0;
|
|
627
|
+
if (req.method === 'Capture.collectStyleSheetHeaders') {
|
|
628
|
+
return { result: await this.collectStyleSheetHeaders() };
|
|
629
|
+
}
|
|
604
630
|
if (!hasMethod && !hasWaitEvent) {
|
|
605
631
|
throw new Error(
|
|
606
632
|
'Invalid cdp request: requires a nonempty string "method" (to dispatch) or "waitEvent" (to wait only) — got neither.',
|
|
@@ -610,6 +636,16 @@ export class RecorderSession {
|
|
|
610
636
|
// This recorder owns a direct tab websocket, so its actual CDP event
|
|
611
637
|
// envelope scope is `undefined` (there is no flattened attach session).
|
|
612
638
|
// Arm synchronously before any triggering send below.
|
|
639
|
+
const documentResponses: Array<{ loaderId: string; status: number; url: string }> = [];
|
|
640
|
+
const onDocumentResponse = req.observeDocumentResponse ? (params: unknown) => {
|
|
641
|
+
if (!params || typeof params !== 'object' || Array.isArray(params)) return;
|
|
642
|
+
const event = params as { type?: unknown; loaderId?: unknown; response?: unknown };
|
|
643
|
+
if (event.type !== 'Document' || typeof event.loaderId !== 'string' || !event.response || typeof event.response !== 'object' || Array.isArray(event.response)) return;
|
|
644
|
+
const response = event.response as { status?: unknown; url?: unknown };
|
|
645
|
+
if (typeof response.status !== 'number' || !Number.isFinite(response.status) || typeof response.url !== 'string') return;
|
|
646
|
+
documentResponses.push({ loaderId: event.loaderId, status: response.status, url: response.url });
|
|
647
|
+
} : undefined;
|
|
648
|
+
if (onDocumentResponse) this.client.on('Network.responseReceived', onDocumentResponse);
|
|
613
649
|
const eventWait = hasWaitEvent
|
|
614
650
|
? this.events.wait(req.waitEvent as string, undefined, req.timeoutMs ?? 10000)
|
|
615
651
|
: undefined;
|
|
@@ -653,23 +689,71 @@ export class RecorderSession {
|
|
|
653
689
|
// its own timeout destroy the method result: an observed event tags
|
|
654
690
|
// `'observed'`; the wait's bounded timer elapsing tags `'bounded-timeout'`
|
|
655
691
|
// and preserves `result` with no `event`.
|
|
656
|
-
|
|
692
|
+
const loaderId = (result as { loaderId?: unknown } | undefined)?.loaderId;
|
|
693
|
+
const documentResponse = typeof loaderId === 'string'
|
|
694
|
+
? documentResponses.filter((response) => response.loaderId === loaderId).at(-1)
|
|
695
|
+
: undefined;
|
|
696
|
+
if (!eventWait) return { result, ...(documentResponse ? { documentResponse } : {}) };
|
|
657
697
|
const settled = await eventWait.result().then(
|
|
658
698
|
(event) => ({ event, waitOutcome: 'observed' as const }),
|
|
659
699
|
() => ({ waitOutcome: 'bounded-timeout' as const }),
|
|
660
700
|
);
|
|
661
|
-
|
|
701
|
+
const settledDocumentResponse = typeof loaderId === 'string'
|
|
702
|
+
? documentResponses.filter((response) => response.loaderId === loaderId).at(-1)
|
|
703
|
+
: undefined;
|
|
704
|
+
return { result, ...settled, ...(settledDocumentResponse ? { documentResponse: settledDocumentResponse } : {}) };
|
|
662
705
|
} catch (err) {
|
|
663
706
|
eventWait?.cancel();
|
|
664
707
|
throw err;
|
|
708
|
+
} finally {
|
|
709
|
+
if (onDocumentResponse) this.client.off('Network.responseReceived', onDocumentResponse);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Records a host-routed marked dispatch without issuing a second CDP call. */
|
|
714
|
+
recordRoutedDispatch(notice: DispatchNotice, outcome: DispatchOutcome): void {
|
|
715
|
+
this.appendEvent({
|
|
716
|
+
kind: 'input',
|
|
717
|
+
action: notice.annotation,
|
|
718
|
+
mark: structuralMarkLabel(notice.annotation ?? ''),
|
|
719
|
+
method: notice.method,
|
|
720
|
+
startPerformanceNow: notice.atPerformanceNowMs,
|
|
721
|
+
endPerformanceNow: outcome.atPerformanceNowMs,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Runs CSS header redelivery on the one connection that can receive its events while recording. */
|
|
726
|
+
private async collectStyleSheetHeaders(): Promise<{ headers: Array<{ styleSheetId: string; sourceURL: string }> }> {
|
|
727
|
+
const headers = new Map<string, string>();
|
|
728
|
+
const handler = (params: unknown): void => {
|
|
729
|
+
const header = (params as { header?: { styleSheetId?: unknown; sourceURL?: unknown } } | undefined)?.header;
|
|
730
|
+
if (typeof header?.styleSheetId === 'string' && typeof header.sourceURL === 'string' && header.sourceURL.length > 0) headers.set(header.styleSheetId, header.sourceURL);
|
|
731
|
+
};
|
|
732
|
+
this.client.on('CSS.styleSheetAdded', handler);
|
|
733
|
+
try {
|
|
734
|
+
await this.client.send('CSS.disable');
|
|
735
|
+
await this.client.send('CSS.enable');
|
|
736
|
+
return { headers: [...headers].map(([styleSheetId, sourceURL]) => ({ styleSheetId, sourceURL })) };
|
|
737
|
+
} finally {
|
|
738
|
+
this.client.off('CSS.styleSheetAdded', handler);
|
|
665
739
|
}
|
|
666
740
|
}
|
|
667
741
|
|
|
742
|
+
/** Synchronous admission cutoff used by the collector host before any drain awaits. */
|
|
743
|
+
closeAdmission(): void {
|
|
744
|
+
if (this.state === 'recording') this.state = 'stopping';
|
|
745
|
+
}
|
|
746
|
+
|
|
668
747
|
async stop(): Promise<RecorderStopSummary> {
|
|
669
|
-
|
|
670
|
-
|
|
748
|
+
this.closeAdmission();
|
|
749
|
+
return this.drain();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
async drain(): Promise<RecorderStopSummary> {
|
|
753
|
+
if (this.state !== 'stopping') {
|
|
754
|
+
throw new Error(`cannot drain recorder in state "${this.state}"`);
|
|
671
755
|
}
|
|
672
|
-
//
|
|
756
|
+
// `closeAdmission()` flips state before any drain await, so every
|
|
673
757
|
// state-sensitive guard (`handleFrameNavigated`, `handleCdp`) already sees the recorder
|
|
674
758
|
// as no longer recording for the rest of this teardown.
|
|
675
759
|
this.state = 'stopping';
|
|
@@ -731,13 +815,14 @@ export class RecorderSession {
|
|
|
731
815
|
this.flushTraceDropSummary();
|
|
732
816
|
|
|
733
817
|
this.state = 'stopped';
|
|
818
|
+
const stoppedAtWallClockMs = Date.now();
|
|
734
819
|
return {
|
|
735
820
|
frameCount: this.frameCount,
|
|
736
821
|
eventCount: this.eventCount,
|
|
737
|
-
durationMs:
|
|
822
|
+
durationMs: stoppedAtWallClockMs - this.startedAtWallClockMs,
|
|
738
823
|
// this.baselines is always set by the time stop() is reachable — start() sets it before
|
|
739
824
|
// flipping state to 'recording', and stop() only runs from state 'recording'.
|
|
740
|
-
markers: { ...this.baselines
|
|
825
|
+
markers: { ...this.baselines!, stoppedAtWallClockMs },
|
|
741
826
|
};
|
|
742
827
|
}
|
|
743
828
|
|
|
@@ -769,7 +854,7 @@ export class RecorderSession {
|
|
|
769
854
|
* `Runtime.evaluate` with no contextId runs in the page main world). */
|
|
770
855
|
private requireIsolatedContextId(): number {
|
|
771
856
|
if (this.isolatedWorldContextId === undefined) {
|
|
772
|
-
throw new Error('
|
|
857
|
+
throw new Error('motion collector has no active isolated world context');
|
|
773
858
|
}
|
|
774
859
|
return this.isolatedWorldContextId;
|
|
775
860
|
}
|
|
@@ -783,7 +868,7 @@ export class RecorderSession {
|
|
|
783
868
|
if (this.mainFrameId) return this.mainFrameId;
|
|
784
869
|
const tree = (await this.client.send('Page.getFrameTree', {})) as { frameTree?: { frame?: { id?: string } } };
|
|
785
870
|
const frameId = tree.frameTree?.frame?.id;
|
|
786
|
-
if (!frameId) throw new Error('
|
|
871
|
+
if (!frameId) throw new Error('motion collector could not resolve the main frame id via Page.getFrameTree');
|
|
787
872
|
this.mainFrameId = frameId;
|
|
788
873
|
return frameId;
|
|
789
874
|
}
|
|
@@ -817,7 +902,7 @@ export class RecorderSession {
|
|
|
817
902
|
})) as { executionContextId?: number };
|
|
818
903
|
const contextId = created.executionContextId;
|
|
819
904
|
if (typeof contextId !== 'number') {
|
|
820
|
-
throw new Error('
|
|
905
|
+
throw new Error('motion collector isolated world creation returned no execution context id');
|
|
821
906
|
}
|
|
822
907
|
const evaluation = (await this.client.send('Runtime.evaluate', {
|
|
823
908
|
expression: buildObserverScript(RECORDER_BINDING_NAME, this.bindingNonce),
|
|
@@ -849,7 +934,9 @@ export class RecorderSession {
|
|
|
849
934
|
* the recreated world — see `ensureBinding()`). Every main-frame navigation bumps `navGeneration`,
|
|
850
935
|
* so a rearm publishes its context id only if no newer navigation has overtaken it. Runs both
|
|
851
936
|
* while `'recording'` and while `'starting'` (a navigation during `start()` initialization must
|
|
852
|
-
* still land the observer in the newest context)
|
|
937
|
+
* still land the observer in the newest context), but only a handler that began while recording
|
|
938
|
+
* refreshes the screencast; start() owns the one deferred refresh for all startup navigations.
|
|
939
|
+
* Sub-frame navigations (`frame.parentId` set)
|
|
853
940
|
* don't affect the top document's world and are ignored. Bails once the recorder has left those
|
|
854
941
|
* phases (`'stopping'`/`'stopped'`) so a navigation arriving mid-teardown doesn't start new CDP
|
|
855
942
|
* work concurrent with it — `stop()` awaits every rearm still in flight at that boundary (tracked
|
|
@@ -859,6 +946,7 @@ export class RecorderSession {
|
|
|
859
946
|
if (this.state !== 'recording' && this.state !== 'starting') return;
|
|
860
947
|
const frame = params.frame;
|
|
861
948
|
if (!frame || frame.parentId) return;
|
|
949
|
+
const refreshScreencast = this.state === 'recording';
|
|
862
950
|
const generation = ++this.navGeneration;
|
|
863
951
|
// The navigation destroyed the current isolated world; drop the now-dead context id
|
|
864
952
|
// immediately so no bridge evaluate targets it while the rearm is in flight — or if the
|
|
@@ -867,14 +955,47 @@ export class RecorderSession {
|
|
|
867
955
|
this.activeWorldGeneration = undefined;
|
|
868
956
|
if (frame.id) this.mainFrameId = frame.id;
|
|
869
957
|
this.appendEvent({ kind: 'navigation-gap', url: preserveObserverString(frame.url) ?? null });
|
|
958
|
+
let observerRearmed = false;
|
|
870
959
|
try {
|
|
871
960
|
await this.injectObserverScript(generation);
|
|
961
|
+
observerRearmed = this.activeWorldGeneration === generation;
|
|
872
962
|
} catch (err) {
|
|
873
963
|
this.appendEvent({
|
|
874
964
|
kind: 'error',
|
|
875
965
|
message: `observer re-arm after navigation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
876
966
|
});
|
|
877
967
|
}
|
|
968
|
+
if (refreshScreencast && observerRearmed) {
|
|
969
|
+
try {
|
|
970
|
+
await this.refreshScreencast(generation);
|
|
971
|
+
} catch (err) {
|
|
972
|
+
this.appendEvent({
|
|
973
|
+
kind: 'error',
|
|
974
|
+
message: `screencast refresh after navigation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** Rejects a refresh/start when the latest navigation did not publish a live observer world. */
|
|
981
|
+
private requireLatestObserverGeneration(): void {
|
|
982
|
+
if (this.isolatedWorldContextId === undefined || this.activeWorldGeneration !== this.navGeneration) {
|
|
983
|
+
throw new Error('recorder start aborted: observer script was not installed in the latest main-frame context');
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
private refreshScreencast(generation?: number): Promise<void> {
|
|
988
|
+
const refresh = this.screencastRefresh.catch(() => {}).then(async () => {
|
|
989
|
+
if (generation !== undefined && this.activeWorldGeneration !== generation) return;
|
|
990
|
+
await this.client.send('Page.stopScreencast');
|
|
991
|
+
// A second navigation may have destroyed the destination world while the stop was in
|
|
992
|
+
// flight. Leave the stream stopped rather than starting it against a bridgeless/stale page;
|
|
993
|
+
// its current generation owns the next serialized refresh after observer injection.
|
|
994
|
+
if (generation !== undefined && this.activeWorldGeneration !== generation) return;
|
|
995
|
+
await this.client.send('Page.startScreencast', SCREENCAST_OPTIONS);
|
|
996
|
+
});
|
|
997
|
+
this.screencastRefresh = refresh;
|
|
998
|
+
return refresh;
|
|
878
999
|
}
|
|
879
1000
|
|
|
880
1001
|
private appendEvent(record: Omit<RecorderEventRecord, 'recordedAtWallClockMs'>): void {
|
|
@@ -939,6 +1060,8 @@ export class RecorderSession {
|
|
|
939
1060
|
try {
|
|
940
1061
|
const sample = await this.sampleRects(frameIndex);
|
|
941
1062
|
const elements = this.sanitizeRectSample(sample.facts, sample.backendNodeIds);
|
|
1063
|
+
const elementSampleTruncated = sanitizeElementSampleTruncated(sample.elementSampleTruncated);
|
|
1064
|
+
const candidateSampleTruncated = sanitizeElementSampleTruncated(sample.candidateSampleTruncated);
|
|
942
1065
|
appendNdjsonPrivate(this.rectsPath, {
|
|
943
1066
|
frame: frameIndex,
|
|
944
1067
|
file: frameName,
|
|
@@ -947,6 +1070,8 @@ export class RecorderSession {
|
|
|
947
1070
|
screencastTimestampPrecision: 'frame-metadata',
|
|
948
1071
|
recordedAtWallClockMs: Date.now(),
|
|
949
1072
|
elements,
|
|
1073
|
+
...(elementSampleTruncated === undefined ? {} : { elementSampleTruncated }),
|
|
1074
|
+
...(candidateSampleTruncated === undefined ? {} : { candidateSampleTruncated }),
|
|
950
1075
|
} satisfies FrameRectsRecord);
|
|
951
1076
|
} catch (err) {
|
|
952
1077
|
// A failed rect sample for one frame shouldn't take the recorder down;
|
|
@@ -1217,7 +1342,7 @@ export class RecorderSession {
|
|
|
1217
1342
|
* method must never hand back that data typed as if it were already safe) alongside this frame's
|
|
1218
1343
|
* resolved `backendNodeId`s, one per fact in the same order.
|
|
1219
1344
|
*/
|
|
1220
|
-
private async sampleRects(frameIndex: number): Promise<{ facts: unknown; viewport: unknown; backendNodeIds: Array<number | undefined> }> {
|
|
1345
|
+
private async sampleRects(frameIndex: number): Promise<{ facts: unknown; viewport: unknown; elementSampleTruncated: unknown; candidateSampleTruncated: unknown; backendNodeIds: Array<number | undefined> }> {
|
|
1221
1346
|
const evaluation = (await this.client.send('Runtime.evaluate', {
|
|
1222
1347
|
expression: buildSampleRectsExpression(this.bindingNonce, frameIndex),
|
|
1223
1348
|
returnByValue: true,
|
|
@@ -1232,7 +1357,13 @@ export class RecorderSession {
|
|
|
1232
1357
|
const facts = Array.isArray(value) ? value : value?.elements;
|
|
1233
1358
|
const count = Array.isArray(facts) ? facts.length : 0;
|
|
1234
1359
|
const backendNodeIds = await this.resolveRectIdentity(frameIndex, count);
|
|
1235
|
-
return {
|
|
1360
|
+
return {
|
|
1361
|
+
facts,
|
|
1362
|
+
viewport: Array.isArray(value) ? undefined : value?.viewport,
|
|
1363
|
+
elementSampleTruncated: Array.isArray(value) ? undefined : value?.elementSampleTruncated,
|
|
1364
|
+
candidateSampleTruncated: Array.isArray(value) ? undefined : value?.candidateSampleTruncated,
|
|
1365
|
+
backendNodeIds,
|
|
1366
|
+
};
|
|
1236
1367
|
}
|
|
1237
1368
|
|
|
1238
1369
|
/**
|
|
@@ -1361,333 +1492,6 @@ export class RecorderSession {
|
|
|
1361
1492
|
|
|
1362
1493
|
/** A structural-safe implementation mark is deliberately distinct from the
|
|
1363
1494
|
* verbatim action identity retained in the adjacent `action` field. */
|
|
1364
|
-
function structuralMarkLabel(action: string): string {
|
|
1365
|
-
return `mark-${crypto.createHash('sha256').update(action).digest('hex')}`;
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
// ---------------------------------------------------------------------------
|
|
1369
|
-
// Wire dispatch — the same "one request per connection, one response"
|
|
1370
|
-
// convention the plain bridge uses (see ./bridge/server.ts).
|
|
1371
|
-
// ---------------------------------------------------------------------------
|
|
1372
|
-
|
|
1373
|
-
/**
|
|
1374
|
-
* Session-scoped request dispatch. `har-flush` is deliberately absent from
|
|
1375
|
-
* the accepted union: the streaming HAR collector lives in `runRecorderBridge`'s
|
|
1376
|
-
* process body, so `handleLine` serves that request there and never forwards
|
|
1377
|
-
* it here.
|
|
1378
|
-
*/
|
|
1379
|
-
export async function handleRecorderRequest(
|
|
1380
|
-
session: RecorderSession,
|
|
1381
|
-
req: Exclude<RecorderRequest, RecHarFlushRequest>,
|
|
1382
|
-
): Promise<RecorderResponse> {
|
|
1383
|
-
try {
|
|
1384
|
-
switch (req.type) {
|
|
1385
|
-
case 'rec-start': {
|
|
1386
|
-
const markers = await session.start();
|
|
1387
|
-
return { reqId: req.reqId, ok: true, type: 'rec-start', markers };
|
|
1388
|
-
}
|
|
1389
|
-
case 'rec-stop': {
|
|
1390
|
-
const summary = await session.stop();
|
|
1391
|
-
return { reqId: req.reqId, ok: true, type: 'rec-stop', ...summary };
|
|
1392
|
-
}
|
|
1393
|
-
case 'cdp': {
|
|
1394
|
-
const { result, event, waitOutcome } = await session.handleCdp(req);
|
|
1395
|
-
return { reqId: req.reqId, ok: true, type: 'cdp', result, event, waitOutcome };
|
|
1396
|
-
}
|
|
1397
|
-
default: {
|
|
1398
|
-
const exhaustive: never = req;
|
|
1399
|
-
throw new Error(`Unknown recorder request: ${JSON.stringify(exhaustive)}`);
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
} catch (err) {
|
|
1403
|
-
return {
|
|
1404
|
-
reqId: req.reqId,
|
|
1405
|
-
ok: false,
|
|
1406
|
-
type: req.type,
|
|
1407
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1408
|
-
};
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
// ---------------------------------------------------------------------------
|
|
1413
|
-
// Process entrypoint — spawned detached by `./bridge/spawn.ts`'s
|
|
1414
|
-
// `startRecorderBridge()`, invoked via `capture __bridge-serve ... recorder
|
|
1415
|
-
// <recDir>` (see ./commands/bridge-serve.ts).
|
|
1416
|
-
// ---------------------------------------------------------------------------
|
|
1417
|
-
|
|
1418
|
-
export interface RunRecorderBridgeOptions {
|
|
1419
|
-
socketPath: string;
|
|
1420
|
-
targetId: string;
|
|
1421
|
-
recDir: string;
|
|
1422
|
-
/** The owning session's live HAR recording id — the streaming `HARRecorder`
|
|
1423
|
-
* installed on the held tab connection appends its evidence there (via
|
|
1424
|
-
* `har-manager.ts`'s `appendToHarRecording`; each batch is one `O_APPEND`
|
|
1425
|
-
* no-follow write of one NDJSON record, so cross-process writers interleave
|
|
1426
|
-
* whole records without a lock — nothing else is needed here). */
|
|
1427
|
-
harId: string;
|
|
1428
|
-
port?: number;
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
/** Filename of the nonce boot file the recorder writes into `recDir` BEFORE
|
|
1432
|
-
* binding its control socket — the private back-channel that hands the
|
|
1433
|
-
* server-generated admission nonce to the starter. The starter reads,
|
|
1434
|
-
* validates, persists (into `recorder.json`), and DELETES it; `recorder.json`
|
|
1435
|
-
* is the one persistent authority for the nonce afterward. */
|
|
1436
|
-
export const RECORDER_NONCE_BOOT_FILE = 'recorder-nonce.json';
|
|
1437
|
-
|
|
1438
|
-
/** Constant-time nonce comparison (same pattern as `session/log-tailer.ts`'s
|
|
1439
|
-
* gate): length check + `crypto.timingSafeEqual`, so a probing client learns
|
|
1440
|
-
* nothing about the expected token from response timing. */
|
|
1441
|
-
function nonceMatches(expected: string, candidate: unknown): boolean {
|
|
1442
|
-
if (typeof candidate !== 'string') return false;
|
|
1443
|
-
const left = Buffer.from(expected);
|
|
1444
|
-
const right = Buffer.from(candidate);
|
|
1445
|
-
return left.length === right.length && crypto.timingSafeEqual(left, right);
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
interface RecorderBridgeDeps {
|
|
1449
|
-
createClient: (webSocketDebuggerUrl: string) => CDPClient;
|
|
1450
|
-
findTab: typeof findTabByIdAcrossEndpoints;
|
|
1451
|
-
/** Process-exit seam — injectable so in-proc tests of the authenticated
|
|
1452
|
-
* `rec-stop` self-exit path don't kill the test runner. */
|
|
1453
|
-
exit: (code: number) => void;
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
const defaultRecorderBridgeDeps: RecorderBridgeDeps = {
|
|
1457
|
-
createClient: (url) => new CDPClient(url),
|
|
1458
|
-
findTab: findTabByIdAcrossEndpoints,
|
|
1459
|
-
exit: (code) => process.exit(code),
|
|
1460
|
-
};
|
|
1461
|
-
|
|
1462
|
-
let recorderBridgeDeps = defaultRecorderBridgeDeps;
|
|
1463
|
-
|
|
1464
|
-
/** Focused tests inject the CDP/tab/exit boundary; production always uses the
|
|
1465
|
-
* real client, resolver, and `process.exit`. Returns a restore function. */
|
|
1466
|
-
export function __setRecorderBridgeDepsForTest(overrides: Partial<RecorderBridgeDeps>): () => void {
|
|
1467
|
-
const previous = recorderBridgeDeps;
|
|
1468
|
-
recorderBridgeDeps = { ...recorderBridgeDeps, ...overrides };
|
|
1469
|
-
return () => { recorderBridgeDeps = previous; };
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
/**
|
|
1473
|
-
* Joins the terminal rec-stop's two possible failures into the one wire
|
|
1474
|
-
* error message the caller sees. A drain-only failure surfaces the exact
|
|
1475
|
-
* drain error unchanged; when `RecorderSession.stop()` ALSO failed, its
|
|
1476
|
-
* failure is never replaced wholesale — both messages are joined so both
|
|
1477
|
-
* stay distinguishable. Module-level (not a bridge closure) so the focused
|
|
1478
|
-
* settle-join regression test exercises exactly the production join.
|
|
1479
|
-
*/
|
|
1480
|
-
export function joinTerminalStopFailure(stopResp: RecorderResponse, drainError: unknown): string {
|
|
1481
|
-
const drainMessage = drainError instanceof Error ? drainError.message : String(drainError);
|
|
1482
|
-
if (stopResp.ok) return drainMessage;
|
|
1483
|
-
return `rec-stop teardown failed: ${stopResp.error}; HAR drain failed: ${drainMessage}`;
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
export async function runRecorderBridge(opts: RunRecorderBridgeOptions): Promise<void> {
|
|
1487
|
-
const port = opts.port ?? (await resolvePort());
|
|
1488
|
-
const resolved = await recorderBridgeDeps.findTab(opts.targetId, port);
|
|
1489
|
-
if (!resolved?.tab.webSocketDebuggerUrl) {
|
|
1490
|
-
throw new Error(`No tab found for target "${opts.targetId}" on port ${port}.`);
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
const client = recorderBridgeDeps.createClient(resolved.tab.webSocketDebuggerUrl);
|
|
1494
|
-
await client.waitReady();
|
|
1495
|
-
|
|
1496
|
-
// HAR evidence streams from the moment the held connection is ready —
|
|
1497
|
-
// BEFORE the control socket exists, so network activity between recorder
|
|
1498
|
-
// boot and the caller's `rec-start` is already captured. Exactly-once
|
|
1499
|
-
// delivery is the HARRecorder's own contract; cross-process file safety is
|
|
1500
|
-
// har-manager's append-only store (one O_APPEND write per batch, no lock).
|
|
1501
|
-
const har = new HARRecorder(client, (batch) => appendToHarRecording(opts.harId, batch));
|
|
1502
|
-
await har.start();
|
|
1503
|
-
|
|
1504
|
-
const session = new RecorderSession({ client, recDir: opts.recDir });
|
|
1505
|
-
|
|
1506
|
-
// Server-generated control-socket admission token. Written to the private
|
|
1507
|
-
// boot file BEFORE the socket is bound: spawn.ts's socket-file readiness
|
|
1508
|
-
// poll therefore guarantees the starter can read it without racing us.
|
|
1509
|
-
const nonce = crypto.randomBytes(32).toString('hex');
|
|
1510
|
-
writeJsonPrivate(path.join(opts.recDir, RECORDER_NONCE_BOOT_FILE), { nonce });
|
|
1511
|
-
|
|
1512
|
-
let server: net.Server | null = null;
|
|
1513
|
-
const cleanup = (): void => {
|
|
1514
|
-
if (server) closeNdjsonSocket(server, opts.socketPath);
|
|
1515
|
-
try {
|
|
1516
|
-
client.close();
|
|
1517
|
-
} catch {
|
|
1518
|
-
// Already closed.
|
|
1519
|
-
}
|
|
1520
|
-
};
|
|
1521
|
-
|
|
1522
|
-
// One-owner terminal-rec-stop latch (U11c). `listenNdjsonSocket` fire-and-
|
|
1523
|
-
// forgets every line (`void handleLine(...)`) and does not serialize them,
|
|
1524
|
-
// so more than one authenticated `rec-stop` line can reach `handleLine`
|
|
1525
|
-
// before the first has finished. This flag is read and set in the SAME
|
|
1526
|
-
// synchronous prefix as the nonce gate and `session.state` check below (no
|
|
1527
|
-
// `await` between the read and the set), so exactly one authenticated,
|
|
1528
|
-
// stoppable-state `rec-stop` ever claims the HAR admission cut, drain
|
|
1529
|
-
// settlement, response, cleanup, and exit. Every other rec-stop line (a
|
|
1530
|
-
// repeat, a concurrent loser, or one that arrives while the session is
|
|
1531
|
-
// already `idle`/`stopping`) falls through to the ordinary
|
|
1532
|
-
// `handleRecorderRequest` path below, which resolves it through
|
|
1533
|
-
// `RecorderSession.stop()`'s own state guard into the existing
|
|
1534
|
-
// deterministic `ok:false` rejection — never a second drain/exit.
|
|
1535
|
-
let terminalStopClaimed = false;
|
|
1536
|
-
|
|
1537
|
-
/**
|
|
1538
|
-
* Settles the one authenticated, stoppable-state `rec-stop` that claimed
|
|
1539
|
-
* the terminal latch. `RecorderSession.stop()` (screencast/tracing/observer
|
|
1540
|
-
* teardown, best-effort and independent of the HAR store) runs to
|
|
1541
|
-
* completion first; only then is the already-initiated `drainSettlement`
|
|
1542
|
-
* awaited, so the response is withheld until every pre-cut HAR body fetch,
|
|
1543
|
-
* the exactly-once frozen-lifecycle finalization, and every U02 append have
|
|
1544
|
-
* settled (U11c's success ordering). A latched fatal HAR drain (sink/store
|
|
1545
|
-
* rejection, or any unexpected assembly/validation failure) is terminal and
|
|
1546
|
-
* authoritative: it overrides even a successful `RecorderSession.stop()`
|
|
1547
|
-
* with an explicit `ok:false` response carrying the exact primary error —
|
|
1548
|
-
* the recorder never claims success once its evidence store is fatally
|
|
1549
|
-
* broken, and the HAR store itself is never reset — and when the stop
|
|
1550
|
-
* teardown ALSO failed, the drain error never replaces it wholesale: both
|
|
1551
|
-
* messages are joined (`joinTerminalStopFailure`) so both failures stay
|
|
1552
|
-
* distinguishable in the one response. Response is flushed
|
|
1553
|
-
* (write completion callback) before the socket ends, cleanup runs, and the
|
|
1554
|
-
* injected exit seam fires non-zero on fatal / zero on success.
|
|
1555
|
-
*/
|
|
1556
|
-
async function settleTerminalRecStop(req: RecStopRequest, socket: net.Socket, drainSettlement: Promise<void>): Promise<void> {
|
|
1557
|
-
const stopResp = await handleRecorderRequest(session, req);
|
|
1558
|
-
let finalResp: RecorderResponse = stopResp;
|
|
1559
|
-
try {
|
|
1560
|
-
await drainSettlement;
|
|
1561
|
-
} catch (err) {
|
|
1562
|
-
finalResp = {
|
|
1563
|
-
reqId: req.reqId,
|
|
1564
|
-
ok: false,
|
|
1565
|
-
type: 'rec-stop',
|
|
1566
|
-
error: joinTerminalStopFailure(stopResp, err),
|
|
1567
|
-
};
|
|
1568
|
-
}
|
|
1569
|
-
await new Promise<void>((resolveWrite) => {
|
|
1570
|
-
socket.write(JSON.stringify(finalResp) + '\n', () => resolveWrite());
|
|
1571
|
-
});
|
|
1572
|
-
socket.end();
|
|
1573
|
-
cleanup();
|
|
1574
|
-
recorderBridgeDeps.exit(finalResp.ok ? 0 : 1);
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
async function handleLine(line: string, socket: net.Socket): Promise<void> {
|
|
1578
|
-
let req: RecorderRequest;
|
|
1579
|
-
try {
|
|
1580
|
-
req = JSON.parse(line);
|
|
1581
|
-
} catch {
|
|
1582
|
-
return;
|
|
1583
|
-
}
|
|
1584
|
-
// A syntactically valid JSON line can still parse to a non-object
|
|
1585
|
-
// (`null`, a number, a string, a boolean, an array) — `JSON.parse` alone
|
|
1586
|
-
// does not guarantee `RecorderRequest` shape. Drop those the same way as
|
|
1587
|
-
// a parse failure: any property read below (`raw.nonce`, `raw.reqId`,
|
|
1588
|
-
// `raw.type`) on `null`/a primitive would throw inside this async
|
|
1589
|
-
// handler, and `listenNdjsonSocket` invokes handlers as a fire-and-forget
|
|
1590
|
-
// `void handleLine(...)` (src/cdp/bridge/server.ts), so an uncaught throw
|
|
1591
|
-
// here becomes an unhandled rejection that kills the detached bridge
|
|
1592
|
-
// process and orphans the live recording.
|
|
1593
|
-
if (req === null || typeof req !== 'object' || Array.isArray(req)) return;
|
|
1594
|
-
// Admission gate — constant-time nonce check on EVERY request, before any
|
|
1595
|
-
// handler runs. No request type is exempt and there is no compat lane: a
|
|
1596
|
-
// missing/mismatched nonce is answered `unauthorized` with zero side
|
|
1597
|
-
// effects (reqId/type coerced defensively — the line is untrusted input).
|
|
1598
|
-
const raw = req as unknown as Record<string, unknown>;
|
|
1599
|
-
if (!nonceMatches(nonce, raw.nonce)) {
|
|
1600
|
-
const reqId = typeof raw.reqId === 'number' ? (raw.reqId as number) : 0;
|
|
1601
|
-
const type: RecorderRequest['type'] =
|
|
1602
|
-
raw.type === 'rec-start' || raw.type === 'rec-stop' || raw.type === 'cdp' || raw.type === 'har-flush'
|
|
1603
|
-
? raw.type
|
|
1604
|
-
: 'cdp';
|
|
1605
|
-
socket.write(JSON.stringify({ reqId, ok: false, type, error: 'unauthorized' }) + '\n');
|
|
1606
|
-
return;
|
|
1607
|
-
}
|
|
1608
|
-
// U12 har-flush health barrier — served here, in the wire lane, because
|
|
1609
|
-
// the streaming HAR collector (`har`) is owned by this process body, not
|
|
1610
|
-
// by RecorderSession. Non-attributing: `har.flush()` waits for every body
|
|
1611
|
-
// fetch in flight and every sink append queued at call time, then throws
|
|
1612
|
-
// the latched fatal store error if any of that work failed — so `ok:true`
|
|
1613
|
-
// is a health/completion fact, never an action entry count. It creates no
|
|
1614
|
-
// collector and takes no ownership: `har` stays the one collector for the
|
|
1615
|
-
// bridge's whole life regardless of how many flush sockets open and close.
|
|
1616
|
-
// A latched fatal answers `ok:false` with the exact primary error but does
|
|
1617
|
-
// NOT tear the bridge down — terminal authority (drain, final response,
|
|
1618
|
-
// cleanup, exit) belongs exclusively to the rec-stop latch below, and once
|
|
1619
|
-
// that latch is claimed a later flush gets a deterministic rejection
|
|
1620
|
-
// rather than sharing or re-driving the drain.
|
|
1621
|
-
if (req.type === 'har-flush') {
|
|
1622
|
-
let resp: RecorderResponse;
|
|
1623
|
-
if (terminalStopClaimed) {
|
|
1624
|
-
resp = {
|
|
1625
|
-
reqId: req.reqId,
|
|
1626
|
-
ok: false,
|
|
1627
|
-
type: 'har-flush',
|
|
1628
|
-
error: 'recorder is stopping — HAR finalization is owned by the terminal rec-stop',
|
|
1629
|
-
};
|
|
1630
|
-
} else {
|
|
1631
|
-
try {
|
|
1632
|
-
await har.flush();
|
|
1633
|
-
resp = { reqId: req.reqId, ok: true, type: 'har-flush' };
|
|
1634
|
-
} catch (err) {
|
|
1635
|
-
resp = {
|
|
1636
|
-
reqId: req.reqId,
|
|
1637
|
-
ok: false,
|
|
1638
|
-
type: 'har-flush',
|
|
1639
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1640
|
-
};
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
socket.write(JSON.stringify(resp) + '\n');
|
|
1644
|
-
return;
|
|
1645
|
-
}
|
|
1646
|
-
// Terminal admission cut (U11c). `har.drain()`'s own first synchronous
|
|
1647
|
-
// line closes Network-event admission (see har-recorder.ts's `drain()`),
|
|
1648
|
-
// so initiating it HERE — after the nonce gate and `rec-stop` recognition,
|
|
1649
|
-
// synchronously before `handleRecorderRequest`'s first internal await —
|
|
1650
|
-
// guarantees no Network event arriving after this authenticated rec-stop
|
|
1651
|
-
// can allocate, mutate, or append. The cut only fires for a rec-stop that
|
|
1652
|
-
// will actually proceed (`session.state === 'recording'`) and only for the
|
|
1653
|
-
// first such request (`terminalStopClaimed`); an idle/already-stopping
|
|
1654
|
-
// rec-stop and a losing concurrent/repeat rec-stop take zero side effects
|
|
1655
|
-
// here and fall through to the ordinary dispatch below. A rejection
|
|
1656
|
-
// handler is attached to `drainSettlement` in this same synchronous frame
|
|
1657
|
-
// so a fatal drain can never surface as an unhandled rejection through
|
|
1658
|
-
// `listenNdjsonSocket`'s fire-and-forget `void handleLine(...)`,
|
|
1659
|
-
// regardless of when the awaited settlement inside
|
|
1660
|
-
// `settleTerminalRecStop` actually runs.
|
|
1661
|
-
if (req.type === 'rec-stop' && session.state === 'recording' && !terminalStopClaimed) {
|
|
1662
|
-
terminalStopClaimed = true;
|
|
1663
|
-
const drainSettlement = har.drain();
|
|
1664
|
-
drainSettlement.catch(() => {
|
|
1665
|
-
// Handled below via settleTerminalRecStop's own await; this no-op
|
|
1666
|
-
// catch only prevents Node from ever treating this promise as an
|
|
1667
|
-
// unhandled rejection.
|
|
1668
|
-
});
|
|
1669
|
-
await settleTerminalRecStop(req, socket, drainSettlement);
|
|
1670
|
-
return;
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
const resp = await handleRecorderRequest(session, req);
|
|
1674
|
-
socket.write(JSON.stringify(resp) + '\n');
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
server = await listenNdjsonSocket(opts.socketPath, handleLine);
|
|
1678
|
-
installProcessCleanup(cleanup, client);
|
|
1679
|
-
// Intentionally does not resolve further: the open server and the live tab
|
|
1680
|
-
// websocket keep this detached process alive until an authenticated
|
|
1681
|
-
// `rec-stop` completes (the self-exit above) — the caller waits for that
|
|
1682
|
-
// verified exit and only escalates to an identity-checked SIGTERM
|
|
1683
|
-
// (`stopBridge()`) on timeout.
|
|
1684
|
-
}
|
|
1685
|
-
|
|
1686
|
-
async function resolvePort(): Promise<number> {
|
|
1687
|
-
const { detectCdpPort } = await import('./detect.js');
|
|
1688
|
-
return detectCdpPort();
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
1495
|
// ---------------------------------------------------------------------------
|
|
1692
1496
|
// Injected in-page instrumentation
|
|
1693
1497
|
// ---------------------------------------------------------------------------
|
|
@@ -1710,6 +1514,107 @@ async function resolvePort(): Promise<number> {
|
|
|
1710
1514
|
* payload before trusting it; that is a separate check from the global's key and stops a hostile
|
|
1711
1515
|
* page from forging binding-channel events by calling `window[BINDING]` directly.
|
|
1712
1516
|
*/
|
|
1517
|
+
export class MotionCollector implements Collector<{ frames: number; durationMs: number; eventCount: number; markers: RecorderClockBaselines; state: string }> {
|
|
1518
|
+
readonly kind = 'motion' as const;
|
|
1519
|
+
readonly claims = ['tracing', 'screencast'] as const;
|
|
1520
|
+
private session: RecorderSession | undefined;
|
|
1521
|
+
private context: CollectorContext | undefined;
|
|
1522
|
+
private har: HARRecorder | undefined;
|
|
1523
|
+
private harDrain: Promise<void> | undefined;
|
|
1524
|
+
private stopped: RecorderStopSummary | undefined;
|
|
1525
|
+
private viewportApplied = false;
|
|
1526
|
+
|
|
1527
|
+
async start(context: CollectorContext): Promise<void> {
|
|
1528
|
+
const config = context.config as { harId?: unknown; viewport?: { width?: unknown; height?: unknown } } | null;
|
|
1529
|
+
if (typeof config?.harId !== 'string' || config.harId.length === 0) throw new Error('motion collector requires a session HAR recording id');
|
|
1530
|
+
this.context = context;
|
|
1531
|
+
try {
|
|
1532
|
+
if (config.viewport) {
|
|
1533
|
+
const { width, height } = config.viewport;
|
|
1534
|
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) throw new Error('motion viewport must contain positive integer width and height');
|
|
1535
|
+
writeJsonPrivate(path.join(context.dir, 'viewport-override.json'), { phase: 'attempting', targetId: context.targetId, retainOnStop: true });
|
|
1536
|
+
await context.client.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
|
|
1537
|
+
this.viewportApplied = true;
|
|
1538
|
+
writeJsonPrivate(path.join(context.dir, 'viewport-override.json'), { phase: 'applied', targetId: context.targetId, retainOnStop: true });
|
|
1539
|
+
}
|
|
1540
|
+
this.har = new HARRecorder(context.client, batch => appendToHarRecording(config.harId as string, batch));
|
|
1541
|
+
await this.har.start();
|
|
1542
|
+
this.session = new RecorderSession({ client: context.client, recDir: context.dir });
|
|
1543
|
+
await this.session.start();
|
|
1544
|
+
} catch (startError) {
|
|
1545
|
+
this.closeAdmission();
|
|
1546
|
+
await this.harDrain?.catch(() => undefined);
|
|
1547
|
+
if (!this.viewportApplied) throw startError;
|
|
1548
|
+
try {
|
|
1549
|
+
await context.client.send('Emulation.clearDeviceMetricsOverride');
|
|
1550
|
+
this.viewportApplied = false;
|
|
1551
|
+
removeArtifactTree(path.join(context.dir, 'viewport-override.json'));
|
|
1552
|
+
} catch (cleanupError) {
|
|
1553
|
+
writeJsonPrivate(path.join(context.dir, 'viewport-override.json'), {
|
|
1554
|
+
phase: 'restore-failed',
|
|
1555
|
+
targetId: context.targetId,
|
|
1556
|
+
retainOnStop: true,
|
|
1557
|
+
startError: startError instanceof Error ? startError.message : String(startError),
|
|
1558
|
+
cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
|
|
1559
|
+
});
|
|
1560
|
+
throw new RetainedCollectorStartFailure('motion collector start failed and its viewport override could not be cleared', startError, cleanupError);
|
|
1561
|
+
}
|
|
1562
|
+
throw startError;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
closeAdmission(): void {
|
|
1567
|
+
this.session?.closeAdmission();
|
|
1568
|
+
if (this.har && !this.harDrain) {
|
|
1569
|
+
this.harDrain = this.har.drain();
|
|
1570
|
+
this.harDrain.catch(() => undefined);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
async control(message: unknown): Promise<unknown> {
|
|
1575
|
+
if (!this.session || !this.har) throw new Error('motion collector was not started');
|
|
1576
|
+
const request = message as { type?: unknown; method?: unknown; params?: unknown; annotation?: unknown; waitEvent?: unknown; timeoutMs?: unknown; observeDocumentResponse?: unknown } | null;
|
|
1577
|
+
if (request?.type === 'har-flush') return this.har.flush();
|
|
1578
|
+
if (request?.type !== 'cdp') throw new Error('unsupported motion collector control request');
|
|
1579
|
+
return this.session.handleCdp({
|
|
1580
|
+
reqId: 0,
|
|
1581
|
+
method: typeof request.method === 'string' ? request.method : undefined,
|
|
1582
|
+
params: request.params && typeof request.params === 'object' && !Array.isArray(request.params) ? request.params as Record<string, unknown> : {},
|
|
1583
|
+
mark: typeof request.annotation === 'string' ? request.annotation : undefined,
|
|
1584
|
+
waitEvent: typeof request.waitEvent === 'string' ? request.waitEvent : undefined,
|
|
1585
|
+
timeoutMs: typeof request.timeoutMs === 'number' ? request.timeoutMs : undefined,
|
|
1586
|
+
observeDocumentResponse: request.observeDocumentResponse === true,
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
async drain(cause: DrainCause): Promise<DrainOutcome<{ frames: number; durationMs: number; eventCount: number; markers: RecorderClockBaselines; state: string }>> {
|
|
1591
|
+
if (!this.session || !this.context) throw new Error('motion collector was not started');
|
|
1592
|
+
if (!cause.clientUsable) this.context.noteLoss('transport_lost');
|
|
1593
|
+
this.closeAdmission();
|
|
1594
|
+
const [stopped] = await Promise.all([this.stopped ??= this.session.drain(), this.harDrain]);
|
|
1595
|
+
this.stopped = stopped;
|
|
1596
|
+
let viewportRestored: boolean | null = null;
|
|
1597
|
+
if (this.viewportApplied && cause.trigger !== 'explicit') {
|
|
1598
|
+
try {
|
|
1599
|
+
await this.context.client.send('Emulation.clearDeviceMetricsOverride');
|
|
1600
|
+
removeArtifactTree(path.join(this.context.dir, 'viewport-override.json'));
|
|
1601
|
+
viewportRestored = true;
|
|
1602
|
+
} catch {
|
|
1603
|
+
viewportRestored = false;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
writeJsonPrivate(path.join(this.context.dir, 'markers.json'), stopped.markers);
|
|
1607
|
+
return { summary: { frames: stopped.frameCount, durationMs: stopped.durationMs, eventCount: stopped.eventCount, markers: stopped.markers, state: stopped.frameCount > 0 ? 'finalized' : 'partial', viewportRestored, viewportRetained: this.viewportApplied && cause.trigger === 'explicit' }, files: [] };
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
onDispatch(notice: DispatchNotice): ((outcome: DispatchOutcome) => void) | void {
|
|
1611
|
+
if (!this.session || !notice.annotation) return;
|
|
1612
|
+
return outcome => { if (outcome.ok) this.session!.recordRoutedDispatch(notice, outcome); };
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
abandon(): void {}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1713
1618
|
function buildObserverScript(bindingName: string, nonce: string): string {
|
|
1714
1619
|
return `(function() {
|
|
1715
1620
|
var BINDING = ${JSON.stringify(bindingName)};
|
|
@@ -1733,10 +1638,14 @@ function buildObserverScript(bindingName: string, nonce: string): string {
|
|
|
1733
1638
|
}
|
|
1734
1639
|
|
|
1735
1640
|
var mutationObserver = new MutationObserver(function(records) {
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1641
|
+
var maxRecordsPerEmission = 256;
|
|
1642
|
+
for (var start = 0; start < records.length; start += maxRecordsPerEmission) {
|
|
1643
|
+
var batch = records.slice(start, start + maxRecordsPerEmission);
|
|
1644
|
+
emit('mutation', {
|
|
1645
|
+
count: batch.length,
|
|
1646
|
+
types: batch.map(function(r) { return r.type; }),
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1740
1649
|
});
|
|
1741
1650
|
mutationObserver.observe(document.documentElement, {
|
|
1742
1651
|
childList: true, subtree: true, attributes: true, characterData: true,
|
|
@@ -1811,7 +1720,7 @@ function buildObserverScript(bindingName: string, nonce: string): string {
|
|
|
1811
1720
|
|
|
1812
1721
|
/**
|
|
1813
1722
|
* Single round-trip element-rect sample, evaluated once per screencast frame. Uses
|
|
1814
|
-
* `getBoundingClientRect()` over
|
|
1723
|
+
* `getBoundingClientRect()` over a bounded TreeWalker prefix and retains its visible elements rather than one CDP `DOM.getBoxModel`
|
|
1815
1724
|
* round trip per element — the cheap approximation this mechanism unit ships with; quad-accurate
|
|
1816
1725
|
* geometry (transforms, clipping, frame/shadow stitching) is the `measure snap` substrate's job
|
|
1817
1726
|
* (`geometry.json`), not the motion recorder's. Also stashes the live element handles it walked
|
|
@@ -1828,11 +1737,14 @@ function buildSampleRectsExpression(nonce: string, frameIndex: number): string {
|
|
|
1828
1737
|
return `(function() {
|
|
1829
1738
|
var out = [];
|
|
1830
1739
|
var els = [];
|
|
1831
|
-
var
|
|
1832
|
-
var
|
|
1740
|
+
var max = ${JSON.stringify(MAX_RECT_ELEMENTS)};
|
|
1741
|
+
var candidateGeometryReadMax = ${JSON.stringify(MAX_RECT_GEOMETRY_CANDIDATES)};
|
|
1833
1742
|
var identityCap = ${JSON.stringify(MAX_RECT_IDENTITY_RESOLUTIONS)};
|
|
1834
|
-
|
|
1835
|
-
|
|
1743
|
+
var walker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT);
|
|
1744
|
+
var candidateGeometryReads = 0;
|
|
1745
|
+
var el;
|
|
1746
|
+
while (candidateGeometryReads < candidateGeometryReadMax && out.length < max && (el = walker.nextNode())) {
|
|
1747
|
+
candidateGeometryReads++;
|
|
1836
1748
|
var r = el.getBoundingClientRect();
|
|
1837
1749
|
if (r.width === 0 && r.height === 0) continue;
|
|
1838
1750
|
out.push({
|
|
@@ -1843,12 +1755,15 @@ function buildSampleRectsExpression(nonce: string, frameIndex: number): string {
|
|
|
1843
1755
|
});
|
|
1844
1756
|
if (els.length < identityCap) els.push(el);
|
|
1845
1757
|
}
|
|
1758
|
+
var unexaminedCandidate = walker.nextNode() !== null;
|
|
1846
1759
|
var k = '__captureRecorder_' + ${JSON.stringify(nonce)};
|
|
1847
1760
|
var host = window[k];
|
|
1848
1761
|
if (host && host.stashRectElements) host.stashRectElements(${JSON.stringify(frameIndex)}, els);
|
|
1849
1762
|
var viewport = window.visualViewport;
|
|
1850
1763
|
return {
|
|
1851
1764
|
elements: out,
|
|
1765
|
+
elementSampleTruncated: out.length === max && unexaminedCandidate,
|
|
1766
|
+
candidateSampleTruncated: candidateGeometryReads === candidateGeometryReadMax && unexaminedCandidate,
|
|
1852
1767
|
viewport: {
|
|
1853
1768
|
width: viewport ? viewport.width : window.innerWidth,
|
|
1854
1769
|
height: viewport ? viewport.height : window.innerHeight,
|