@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.
Files changed (110) hide show
  1. package/README.md +187 -0
  2. package/bin/capture +31278 -7312
  3. package/package.json +3 -1
  4. package/src/capture.ts +21 -18
  5. package/src/cdp/args.ts +269 -49
  6. package/src/cdp/bridge/server.ts +6 -5
  7. package/src/cdp/bridge/spawn.ts +41 -97
  8. package/src/cdp/commands/bridge-serve.ts +11 -21
  9. package/src/cdp/commands/cdp.ts +44 -22
  10. package/src/cdp/commands/heap/census.ts +61 -0
  11. package/src/cdp/commands/heap/common.ts +77 -0
  12. package/src/cdp/commands/heap/diff.ts +92 -0
  13. package/src/cdp/commands/heap/index.ts +38 -0
  14. package/src/cdp/commands/heap/objects.ts +42 -0
  15. package/src/cdp/commands/heap/retainers.ts +52 -0
  16. package/src/cdp/commands/heap/snapshot.ts +94 -0
  17. package/src/cdp/commands/lib.ts +9 -17
  18. package/src/cdp/commands/lighthouse.ts +414 -0
  19. package/src/cdp/commands/measure/census.ts +2 -1
  20. package/src/cdp/commands/measure/check.ts +70 -16
  21. package/src/cdp/commands/measure/diff.ts +1 -2
  22. package/src/cdp/commands/measure/explain.ts +44 -7
  23. package/src/cdp/commands/measure/index.ts +24 -32
  24. package/src/cdp/commands/measure/map-ax.ts +2 -1
  25. package/src/cdp/commands/measure/map-focus.ts +2 -1
  26. package/src/cdp/commands/measure/map-layers.ts +5 -3
  27. package/src/cdp/commands/measure/map-scroll.ts +2 -1
  28. package/src/cdp/commands/measure/snap.ts +5 -4
  29. package/src/cdp/commands/measure/sweep.ts +16 -11
  30. package/src/cdp/commands/measure/text.ts +147 -0
  31. package/src/cdp/commands/motion/index.ts +10 -18
  32. package/src/cdp/commands/motion/jank.ts +13 -22
  33. package/src/cdp/commands/motion/mask.ts +10 -9
  34. package/src/cdp/commands/motion/rec.ts +228 -26
  35. package/src/cdp/commands/motion/timeline.ts +1 -1
  36. package/src/cdp/commands/page/click.ts +15 -5
  37. package/src/cdp/commands/page/elements.ts +43 -11
  38. package/src/cdp/commands/page/exec.ts +53 -18
  39. package/src/cdp/commands/page/index.ts +20 -20
  40. package/src/cdp/commands/page/inspect.ts +338 -0
  41. package/src/cdp/commands/page/navigate.ts +83 -38
  42. package/src/cdp/commands/page/repeat.ts +176 -0
  43. package/src/cdp/commands/page/scroll.ts +10 -5
  44. package/src/cdp/commands/page/shot.ts +66 -31
  45. package/src/cdp/commands/page/type.ts +6 -2
  46. package/src/cdp/commands/perf/index.ts +32 -0
  47. package/src/cdp/commands/perf/insights.ts +214 -0
  48. package/src/cdp/commands/perf/trace.ts +235 -0
  49. package/src/cdp/commands/perf/vitals.ts +76 -0
  50. package/src/cdp/commands/session/collectors.ts +54 -0
  51. package/src/cdp/commands/stub.ts +10 -0
  52. package/src/cdp/commands/tab/close.ts +2 -2
  53. package/src/cdp/commands/tab/index.ts +16 -23
  54. package/src/cdp/commands/tab/launch.ts +1 -1
  55. package/src/cdp/commands/tab/mock/index.ts +24 -0
  56. package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
  57. package/src/cdp/commands/tab/mock/start.ts +65 -0
  58. package/src/cdp/commands/tab/mock/stop.ts +39 -0
  59. package/src/cdp/commands/tab/network.ts +22 -13
  60. package/src/cdp/commands/tab/reset.ts +11 -12
  61. package/src/cdp/connection.ts +19 -39
  62. package/src/cdp/detect.ts +27 -7
  63. package/src/cdp/dispatch.ts +25 -4
  64. package/src/cdp/domains.ts +2 -2
  65. package/src/cdp/har-recorder.ts +0 -1
  66. package/src/cdp/heap-snapshot.ts +647 -0
  67. package/src/cdp/host/client.ts +22 -0
  68. package/src/cdp/host/collector.ts +74 -0
  69. package/src/cdp/host/collectors/heap.ts +79 -0
  70. package/src/cdp/host/collectors/intercept.ts +247 -0
  71. package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
  72. package/src/cdp/host/collectors/trace.ts +108 -0
  73. package/src/cdp/host/core.ts +217 -0
  74. package/src/cdp/host/handle.ts +47 -0
  75. package/src/cdp/host/held-client.ts +91 -0
  76. package/src/cdp/host/kinds.ts +97 -0
  77. package/src/cdp/host/lifecycle.ts +123 -0
  78. package/src/cdp/host/server.ts +88 -0
  79. package/src/cdp/measure/check.ts +181 -16
  80. package/src/cdp/measure/collectors/hittest.ts +16 -10
  81. package/src/cdp/measure/collectors/scroll.ts +15 -7
  82. package/src/cdp/measure/collectors/style-provenance.ts +11 -0
  83. package/src/cdp/measure/collectors/styles.ts +4 -4
  84. package/src/cdp/measure/explain.ts +14 -2
  85. package/src/cdp/measure/map-layers.ts +52 -8
  86. package/src/cdp/measure/snapshot.ts +5 -3
  87. package/src/cdp/measure/sweep.ts +29 -6
  88. package/src/cdp/measure/text.ts +227 -0
  89. package/src/cdp/motion/jank.ts +29 -83
  90. package/src/cdp/motion/mask.ts +18 -9
  91. package/src/cdp/motion/recorder.ts +86 -731
  92. package/src/cdp/scope-lock.ts +9 -9
  93. package/src/cdp/screenshot.ts +51 -21
  94. package/src/cdp/timing.ts +5 -5
  95. package/src/cdp/trace-analysis.ts +320 -0
  96. package/src/cdp/types.ts +31 -1
  97. package/src/cdp.ts +4 -6
  98. package/src/har-manager.ts +2 -4
  99. package/src/interact.ts +50 -23
  100. package/src/output/artifact.ts +49 -5
  101. package/src/output/render.ts +60 -9
  102. package/src/output/selection.ts +6 -0
  103. package/src/session/artifacts.ts +122 -12
  104. package/src/session/commands.ts +348 -113
  105. package/src/session/coordinator.ts +0 -93
  106. package/src/session/har-redact.ts +29 -23
  107. package/src/session-context.ts +13 -9
  108. package/src/cdp/bridge/protocol.ts +0 -224
  109. package/src/cdp/recorder-client.ts +0 -326
  110. package/src/cdp/tab-lock.ts +0 -132
@@ -1,48 +1,24 @@
1
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.
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
- * 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
- * - `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
- * Frames land as PNGs under `{recDir}/frames/`; per-frame element rects
31
- * append to `{recDir}/rects.jsonl`; trace batches, observer entries, input
32
- * landmarks, and best-effort errors append to `{recDir}/events.jsonl` — all
33
- * writes go through the secure artifact helpers (`../session/artifacts.js`,
34
- * U03), never ad-hoc `fs.writeFile`.
35
- *
36
- * Navigation: a `navigate` mid-recording destroys the page's JS world (and
37
- * therefore the injected observers) same as any full navigation would.
38
- * `Page.startScreencast`/`Tracing` continue across it unaffected (they are
39
- * CDP-session-scoped, not page-scoped), but the recorder listens for
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 net from 'net';
61
+ import * as fs from 'fs';
86
62
  import * as path from 'path';
87
- import { CDPClient } from './client.js';
88
- import { findTabByIdAcrossEndpoints } from './targets.js';
89
- import { enableDomainsForMotionRec } from './domains.js';
90
- import { readTraceClockBaseline, withDocumentPerformanceNow } from './timing.js';
91
- import {
92
- EventBroker,
93
- listenNdjsonSocket,
94
- closeNdjsonSocket,
95
- installProcessCleanup,
96
- } from './bridge/server.js';
97
- import {
98
- type RecorderRequest,
99
- type RecorderResponse,
100
- type RecorderClockBaselines,
101
- type RecCdpRequest,
102
- type RecStopRequest,
103
- type RecHarFlushRequest,
104
- } from './bridge/protocol.js';
105
- import { ensurePrivateDir, appendNdjsonPrivate, writeBinaryPrivate, writeJsonPrivate } from '../session/artifacts.js';
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 = 2000`) is an optimization only — a hostile page can
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 = 2000;
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 = 256 * 1024;
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
- await enableDomainsForMotionRec(asCDPClient(this.client));
495
+ try {
496
+ await enableDomainsForMotionRec(asCDPClient(this.client));
503
497
 
504
- this.client.on('Page.screencastFrame', (params) => {
505
- this.onScreencastFrameEvent(params as ScreencastFrameParams);
506
- });
507
- this.client.on('Tracing.dataCollected', (params) => {
508
- this.handleTraceData(params as { value: unknown[] });
509
- });
510
- this.client.on('Runtime.bindingCalled', (params) => {
511
- this.handleBindingCalled(params as { name: string; payload: string; executionContextId?: number });
512
- });
513
- this.client.on('Page.frameNavigated', (params) => {
514
- const rearm = this.handleFrameNavigated(params as { frame?: { id?: string; parentId?: string; url?: string } });
515
- this.pendingRearm.add(rearm);
516
- void rearm.finally(() => {
517
- this.pendingRearm.delete(rearm);
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
- await this.client.send('Page.startScreencast', { format: 'png', everyNthFrame: 1 });
540
- await this.client.send('Tracing.start', { transferMode: 'ReportEvents', categories: TRACE_CATEGORIES });
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
- // A main-frame navigation anywhere in the initialization window above spawned a rearm that
543
- // recreated the isolated world in the newest context; wait for every one to settle so start()
544
- // never returns with the bridge bound to a world a startup navigation already destroyed.
545
- await this.drainPendingRearms();
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
- // Refuse to enter recording unless the LATEST navigation generation actually installed and
548
- // published its isolated world. A startup navigation that overtook the gen-0 install and then
549
- // failed its own rearm leaves no live context — entering recording here would let bridge
550
- // evaluates run with an undefined contextId (i.e. in the page main world), breaking the
551
- // isolated-world boundary. Aborting propagates to `handleRecorderRequest` as an `ok:false`
552
- // rec-start response.
553
- if (this.isolatedWorldContextId === undefined || this.activeWorldGeneration !== this.navGeneration) {
554
- throw new Error(
555
- 'recorder start aborted: observer script was not installed in the latest main-frame context',
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
- if (!eventWait) return { result };
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
- return { result, ...settled };
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
- if (this.state !== 'recording') {
670
- throw new Error(`cannot stop recorder in state "${this.state}"`);
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
- // Flips away from 'recording' before any other await in this method, so every
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: Date.now() - this.startedAtWallClockMs,
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('recorder bridge has no active isolated world context');
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('recorder bridge could not resolve the main frame id via Page.getFrameTree');
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('recorder bridge isolated world creation returned no execution context id');
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). Sub-frame navigations (`frame.parentId` set)
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 { facts, viewport: Array.isArray(value) ? undefined : value?.viewport, backendNodeIds };
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
- emit('mutation', {
1737
- count: records.length,
1738
- types: records.map(function(r) { return r.type; }),
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 every element (capped) rather than one CDP `DOM.getBoxModel`
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 all = document.querySelectorAll('*');
1832
- var max = 2000;
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
- for (var i = 0; i < all.length && out.length < max; i++) {
1835
- var el = all[i];
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,