@crouton-kit/capture 1.4.2 → 1.4.10

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 +178 -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
@@ -5,12 +5,12 @@
5
5
  * reads). Each scope is an enable→work→restore sequence whose intermediate
6
6
  * state lives on the connection. A DIRECT connection needs no serialization —
7
7
  * its emulation/domain state is scoped to its own websocket session, which
8
- * closes with the command. The recorder, however, holds ONE persistent tab
9
- * connection whose state outlives any single command, so two concurrent
8
+ * closes with the command. The motion collector, however, holds ONE persistent
9
+ * tab connection whose state outlives any single command, so two concurrent
10
10
  * routed callers could otherwise clear/disable one another's live state
11
- * (the recorder bridge's `handleCdp` is strictly per-request — it provides
12
- * no serialization of its own). When the client is the recorder-held
13
- * adapter, the whole scope therefore runs under the owning session's private
11
+ * (its dispatch is strictly per-request and provides no serialization).
12
+ * When the client is the motion-held adapter, the whole scope therefore runs
13
+ * under the owning session's private
14
14
  * cross-process lock (`.<scope>-scope.lock`, the shared `acquirePrivateLock`
15
15
  * authority every session lock uses).
16
16
  */
@@ -18,14 +18,14 @@ import * as path from 'path';
18
18
  import { acquirePrivateLock } from '../session/artifacts.js';
19
19
  import { getActiveSession } from '../session-context.js';
20
20
  import { captureError } from '../errors.js';
21
- import { isRecorderHeldClient } from './recorder-client.js';
21
+ import { isMotionHeldClient } from './host/held-client.js';
22
22
 
23
23
  export interface ScopeSerializationDeps {
24
- isRecorderHeldClient: (client: unknown) => boolean;
24
+ isMotionHeldClient: (client: unknown) => boolean;
25
25
  getActiveSession: typeof getActiveSession;
26
26
  }
27
27
 
28
- let deps: ScopeSerializationDeps = { isRecorderHeldClient, getActiveSession };
28
+ let deps: ScopeSerializationDeps = { isMotionHeldClient, getActiveSession };
29
29
 
30
30
  /** Swap the held-client/session seams for the CDP-stub tests. */
31
31
  export function __setScopeSerializationDepsForTest(overrides: Partial<ScopeSerializationDeps>): () => void {
@@ -50,7 +50,7 @@ export async function withScopeSerialization<T>(
50
50
  overrides?: ScopeSerializationDeps,
51
51
  ): Promise<T> {
52
52
  const d = overrides ?? deps;
53
- if (!d.isRecorderHeldClient(client)) {
53
+ if (!d.isMotionHeldClient(client)) {
54
54
  return fn();
55
55
  }
56
56
  const session = d.getActiveSession();
@@ -1,7 +1,8 @@
1
+ import * as fs from 'fs';
1
2
  import { PNG } from 'pngjs';
2
3
  import { CDPClient } from './client.js';
3
4
  import { nextStepPath } from '../session-context.js';
4
- import { writeBinaryPrivate } from '../session/artifacts.js';
5
+ import { assertUnderCaptureRoot, writeBinaryPrivate } from '../session/artifacts.js';
5
6
  import { withScopeSerialization } from './scope-lock.js';
6
7
 
7
8
  /**
@@ -16,6 +17,12 @@ export interface CapturedRegion {
16
17
  readonly height: number;
17
18
  }
18
19
 
20
+ /** The visual viewport's page-coordinate origin at capture time. */
21
+ export interface ScrollOffset {
22
+ readonly x: number;
23
+ readonly y: number;
24
+ }
25
+
19
26
  /** A CSS-pixel crop request. The capture is intersected with the live visual viewport before it reaches CDP. */
20
27
  export interface ScreenshotCrop {
21
28
  readonly x: number;
@@ -33,6 +40,10 @@ function validRegion(region: CapturedRegion): CapturedRegion | undefined {
33
40
  return region;
34
41
  }
35
42
 
43
+ function validScrollOffset(offset: ScrollOffset): ScrollOffset | undefined {
44
+ return [offset.x, offset.y].every(Number.isFinite) ? offset : undefined;
45
+ }
46
+
36
47
  /** Intersects page-coordinate CSS regions without rounding their geometry. */
37
48
  function intersectRegions(a: CapturedRegion, b: CapturedRegion): CapturedRegion | undefined {
38
49
  const x = Math.max(a.x, b.x);
@@ -120,7 +131,7 @@ export async function captureScreenshotWithCssViewport(
120
131
  client: CDPClient,
121
132
  viewport?: { width: number; height: number },
122
133
  options?: ScreenshotOptions,
123
- ): Promise<{ png: Buffer; cssViewport?: CapturedRegion }> {
134
+ ): Promise<{ png: Buffer; cssViewport?: CapturedRegion; scrollOffset?: ScrollOffset }> {
124
135
  return withScopeSerialization(client, 'viewport', 'screenshot capture', () =>
125
136
  viewportScopedCapture(client, viewport, options, true),
126
137
  );
@@ -138,8 +149,9 @@ async function viewportScopedCapture(
138
149
  viewport?: { width: number; height: number },
139
150
  options?: ScreenshotOptions,
140
151
  includeCssViewport = false,
141
- ): Promise<{ png: Buffer; cssViewport?: CapturedRegion }> {
152
+ ): Promise<{ png: Buffer; cssViewport?: CapturedRegion; scrollOffset?: ScrollOffset }> {
142
153
  const MAX_DIM = 1600; // headroom below Anthropic's 2000px many-image limit
154
+ const SCREENSHOT_REQUEST_TIMEOUT_MS = 30_000;
143
155
  let ownsDeviceMetricsOverride = false;
144
156
  let primaryFailed = false;
145
157
  let primaryError: unknown;
@@ -184,6 +196,7 @@ async function viewportScopedCapture(
184
196
  // actually sent. An unclipped capture clears it again and it is read from the
185
197
  // live visual viewport below instead.
186
198
  let capturedRegion: CapturedRegion | undefined;
199
+ let scrollOffset: ScrollOffset | undefined;
187
200
 
188
201
  try {
189
202
  const metrics = (await client.send('Page.getLayoutMetrics', {}, 5000)) as {
@@ -195,8 +208,11 @@ async function viewportScopedCapture(
195
208
  // are fractional with a SUCCESSFUL response carrying empty `data`.
196
209
  const vw = Math.round(metrics.cssVisualViewport?.clientWidth ?? 0);
197
210
  const vh = Math.round(metrics.cssVisualViewport?.clientHeight ?? 0);
198
- const sx = Math.floor(metrics.cssVisualViewport?.pageX ?? 0);
199
- const sy = Math.floor(metrics.cssVisualViewport?.pageY ?? 0);
211
+ const pageX = metrics.cssVisualViewport?.pageX ?? 0;
212
+ const pageY = metrics.cssVisualViewport?.pageY ?? 0;
213
+ const sx = Math.floor(pageX);
214
+ const sy = Math.floor(pageY);
215
+ scrollOffset = validScrollOffset({ x: pageX, y: pageY });
200
216
 
201
217
  const dprResult = (await client.send('Runtime.evaluate', {
202
218
  expression: 'window.devicePixelRatio',
@@ -233,7 +249,7 @@ async function viewportScopedCapture(
233
249
  const result = (await client.send(
234
250
  'Page.captureScreenshot',
235
251
  screenshotOpts,
236
- 15000,
252
+ SCREENSHOT_REQUEST_TIMEOUT_MS,
237
253
  )) as { data?: string };
238
254
  let png = Buffer.from(result.data ?? '', 'base64');
239
255
 
@@ -246,7 +262,7 @@ async function viewportScopedCapture(
246
262
  const retry = (await client.send('Page.captureScreenshot', {
247
263
  ...screenshotOpts,
248
264
  clip: { ...clip, scale: 1 },
249
- }, 15000)) as { data?: string };
265
+ }, SCREENSHOT_REQUEST_TIMEOUT_MS)) as { data?: string };
250
266
  png = Buffer.from(retry.data ?? '', 'base64');
251
267
  if (png.length > 0) png = downscalePngToFit(png, MAX_DIM);
252
268
  }
@@ -257,13 +273,16 @@ async function viewportScopedCapture(
257
273
  const retry = (await client.send('Page.captureScreenshot', {
258
274
  format: 'png',
259
275
  captureBeyondViewport: false,
260
- }, 15000)) as { data?: string };
276
+ }, SCREENSHOT_REQUEST_TIMEOUT_MS)) as { data?: string };
261
277
  png = Buffer.from(retry.data ?? '', 'base64');
262
278
  if (png.length > 0) png = downscalePngToFit(png, MAX_DIM);
263
279
  // An unclipped capture covers the visual viewport itself, not the integer
264
280
  // clip that was refused — and the two failed attempts above can have taken
265
- // 30 seconds, so the viewport sampled before them is no longer evidence.
266
- if (png.length > 0) capturedRegion = undefined;
281
+ // 30 seconds, so the viewport facts sampled before them are no longer evidence.
282
+ if (png.length > 0) {
283
+ capturedRegion = undefined;
284
+ scrollOffset = undefined;
285
+ }
267
286
  }
268
287
 
269
288
  // The image was captured unclipped (no clip was ever sent, or the clipped
@@ -278,12 +297,10 @@ async function viewportScopedCapture(
278
297
  }, 5000)) as { result?: { value?: Record<string, unknown> | null } };
279
298
  const value = response.result?.value;
280
299
  if (value) {
281
- capturedRegion = validRegion({
282
- x: Number(value.x),
283
- y: Number(value.y),
284
- width: Number(value.width),
285
- height: Number(value.height),
286
- });
300
+ const x = Number(value.x);
301
+ const y = Number(value.y);
302
+ capturedRegion = validRegion({ x, y, width: Number(value.width), height: Number(value.height) });
303
+ scrollOffset = validScrollOffset({ x, y });
287
304
  }
288
305
  } catch {
289
306
  // The image remains usable; only its CSS-coordinate mapping is unavailable.
@@ -296,7 +313,11 @@ async function viewportScopedCapture(
296
313
  );
297
314
  }
298
315
 
299
- return { png, ...(includeCssViewport && capturedRegion ? { cssViewport: capturedRegion } : {}) };
316
+ return {
317
+ png,
318
+ ...(includeCssViewport && capturedRegion ? { cssViewport: capturedRegion } : {}),
319
+ ...(includeCssViewport && scrollOffset ? { scrollOffset } : {}),
320
+ };
300
321
  } catch (error) {
301
322
  primaryFailed = true;
302
323
  primaryError = error;
@@ -319,22 +340,31 @@ async function viewportScopedCapture(
319
340
  }
320
341
  }
321
342
 
343
+ export function writeScreenshot(outPath: string, png: Buffer): void {
344
+ try {
345
+ assertUnderCaptureRoot(outPath);
346
+ } catch {
347
+ fs.writeFileSync(outPath, png);
348
+ return;
349
+ }
350
+ writeBinaryPrivate(outPath, png);
351
+ }
352
+
322
353
  export async function autoScreenshot(
323
354
  client: CDPClient,
324
355
  action: string,
325
356
  label: string,
326
357
  noScreenshot?: boolean,
358
+ out?: string,
327
359
  ): Promise<string | null> {
328
360
  if (noScreenshot) return null;
329
- const shotPath = await nextStepPath(action, label);
361
+ const shotPath = out || await nextStepPath(action, label);
330
362
  if (!shotPath) return null;
331
363
 
332
364
  // Brief settle for UI to update
333
365
  await new Promise((r) => setTimeout(r, 300));
334
366
  const png = await captureScreenshot(client);
335
- // Shot path always resolves under the session dir (CAPTURE_ROOT); the
336
- // private writer creates the file 0600 and re-ensures shots/ is 0700.
337
- writeBinaryPrivate(shotPath, png);
367
+ writeScreenshot(shotPath, png);
338
368
  console.error(` [screenshot] ${shotPath}`);
339
369
  return shotPath;
340
370
  }
package/src/cdp/timing.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Shared timing helpers for `measure`/`motion`. See the design's "Recorder timing model":
3
3
  * the authoritative clock for every reported timestamp is `performance.now()`, relative to
4
4
  * the recorder-armed marker (`t=0`). Screencast frames carry a wall-clock `metadata.timestamp`
5
- * and Tracing events carry a monotonic `ts` in their own domains; the recorder bridge (U13/U14)
5
+ * and Tracing events carry a monotonic `ts` in their own domains; the motion collector (U13/U14)
6
6
  * converts both into the `performance.now()` domain by baseline-offset subtraction using the
7
7
  * baseline this module reads.
8
8
  */
@@ -22,7 +22,7 @@ export interface BracketedTiming<T> {
22
22
  * context. Used to attribute a discrete action (e.g. an `Input.dispatch*` call) to a window in
23
23
  * the recorder's authoritative `performance.now()` clock domain. The action's landmark label
24
24
  * (`mark`, when the caller supplied one) is recorded host-side only — appended straight to
25
- * `events.jsonl` alongside these two bracket numbers (see `../recorder-bridge.ts`'s `handleCdp`)
25
+ * `events.jsonl` alongside these two bracket numbers (see `host/collectors/motion.ts`'s `handleCdp`)
26
26
  * — never written into the page's own PerformanceTimeline via `performance.mark()`: that page-
27
27
  * visible side channel was removed (a predictable, page-observable channel the injected
28
28
  * PerformanceObserver would otherwise re-observe as its own event, in violation of the
@@ -59,10 +59,10 @@ export interface TraceClockBaseline {
59
59
 
60
60
  /**
61
61
  * Reads one synchronized (`performance.now()`, wall-clock) pair from the page in a single
62
- * round trip. The recorder bridge reads this once when it arms (`rec-start`) as the anchor half
63
- * of the three-way `markers.json` baseline; the other two members — the first screencast frame's
62
+ * round trip. The motion collector reads this once when it arms as the anchor half of the
63
+ * three-way `markers.json` baseline; the other two members — the first screencast frame's
64
64
  * `metadata.timestamp` (wall-clock seconds) and the first Tracing batch's earliest event `ts`
65
- * (trace-clock microseconds) — are captured separately by the recorder bridge itself as those
65
+ * (trace-clock microseconds) — are captured separately by the motion collector as those
66
66
  * events arrive (not by this function, since neither exists yet at arm time). Post-processing
67
67
  * converts frame-time and trace-time into the `performance.now()` domain by subtracting this
68
68
  * baseline's wall-clock offset.
@@ -0,0 +1,320 @@
1
+ import * as TraceEngine from '@paulirish/trace_engine';
2
+
3
+ export type RawChromeTrace = readonly object[] | { readonly traceEvents: readonly object[] };
4
+
5
+ export interface MetricProvenance {
6
+ /** Trace-derived measurements are lab data, never field percentiles. */
7
+ readonly origin: 'lab';
8
+ readonly producer: 'Chrome performance trace analyzed by DevTools Trace Engine';
9
+ readonly scope: 'recording-window';
10
+ readonly limitations: readonly string[];
11
+ }
12
+
13
+ export interface ObservedMetric<Value, Attribution = never> {
14
+ readonly status: 'observed';
15
+ readonly value: Value;
16
+ readonly provenance: MetricProvenance;
17
+ readonly attribution?: Attribution;
18
+ }
19
+
20
+ export interface UnobservedMetric {
21
+ readonly status: 'not-observed';
22
+ readonly reason: 'not-present-in-recording';
23
+ readonly provenance: MetricProvenance;
24
+ }
25
+
26
+ export interface LargestContentfulPaint {
27
+ readonly durationMs: number;
28
+ readonly subpartsMs: Readonly<Partial<Record<'ttfb' | 'loadDelay' | 'loadDuration' | 'renderDelay', number>>>;
29
+ }
30
+
31
+ export interface LargestContentfulPaintAttribution {
32
+ /** The node description emitted by Chrome, such as `IMG id='hero'`; it is not a selector. */
33
+ readonly element?: string;
34
+ readonly nodeId?: number;
35
+ readonly resourceUrl?: string;
36
+ }
37
+
38
+ export interface InteractionToNextPaint {
39
+ readonly durationMs: number;
40
+ readonly interactionType: string;
41
+ readonly interactionId: number;
42
+ readonly inputDelayMs: number;
43
+ /** Time the trace engine attributed to main-thread handling for this interaction. */
44
+ readonly mainThreadHandlingMs: number;
45
+ readonly presentationDelayMs: number;
46
+ }
47
+
48
+ export interface InteractionAttribution {
49
+ readonly targetNodeId?: number;
50
+ }
51
+
52
+ export type LayoutShiftCulpritKind = 'web-font' | 'iframe' | 'non-composited-animation' | 'unsized-image' | 'engine-reported';
53
+
54
+ export interface LayoutShiftCulprit {
55
+ readonly kind: LayoutShiftCulpritKind;
56
+ readonly url?: string;
57
+ readonly nodeId?: number;
58
+ /** Engine-provided culprit description. */
59
+ readonly description?: string;
60
+ }
61
+
62
+ export interface LayoutShiftCluster {
63
+ readonly value: number;
64
+ readonly shiftCount: number;
65
+ readonly culprits: readonly LayoutShiftCulprit[];
66
+ }
67
+
68
+ export interface CumulativeLayoutShift {
69
+ readonly value: number;
70
+ readonly clusters: readonly LayoutShiftCluster[];
71
+ }
72
+
73
+ export type TraceInsightField = string | number | boolean | null | readonly TraceInsightField[] | Readonly<Record<string, TraceInsightField>>;
74
+
75
+ export interface TraceInsight {
76
+ /** The engine's insight key. */
77
+ readonly name: string;
78
+ /** Factual engine fields, made JSON-safe without adding capture judgments. */
79
+ readonly fields: Readonly<Record<string, TraceInsightField>>;
80
+ }
81
+
82
+ export interface TraceInsightSet {
83
+ /** DevTools' navigation or no-navigation insight-set identifier. */
84
+ readonly id: string;
85
+ readonly url: string;
86
+ /** Engine-computed records. The adapter deliberately does not turn insight states into judgments. */
87
+ readonly insights: readonly TraceInsight[];
88
+ readonly metrics: {
89
+ readonly lcp: ObservedMetric<LargestContentfulPaint, LargestContentfulPaintAttribution> | UnobservedMetric;
90
+ readonly inp: ObservedMetric<InteractionToNextPaint, InteractionAttribution> | UnobservedMetric;
91
+ readonly cls: ObservedMetric<CumulativeLayoutShift> | UnobservedMetric;
92
+ };
93
+ }
94
+
95
+ export interface TraceAnalysis {
96
+ readonly source: {
97
+ readonly format: 'chrome-performance-trace';
98
+ readonly engine: '@paulirish/trace_engine@0.0.65';
99
+ };
100
+ readonly insightSets: readonly TraceInsightSet[];
101
+ }
102
+
103
+ type UnknownRecord = Record<string, unknown>;
104
+
105
+ function record(value: unknown): UnknownRecord | null {
106
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as UnknownRecord : null;
107
+ }
108
+
109
+ function finite(value: unknown): number | undefined {
110
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
111
+ }
112
+
113
+ function string(value: unknown): string | undefined {
114
+ return typeof value === 'string' ? value : undefined;
115
+ }
116
+
117
+ function traceEvents(trace: RawChromeTrace): readonly object[] {
118
+ if (!('traceEvents' in trace)) return trace;
119
+ if (!Array.isArray(trace.traceEvents)) throw new Error('Chrome trace must be an event array or an object with a traceEvents array.');
120
+ return trace.traceEvents;
121
+ }
122
+
123
+ function lcpProvenance(): MetricProvenance {
124
+ return {
125
+ origin: 'lab',
126
+ producer: 'Chrome performance trace analyzed by DevTools Trace Engine',
127
+ scope: 'recording-window',
128
+ limitations: ['Records the largest contentful paint observed during this trace, not a real-user field percentile.'],
129
+ };
130
+ }
131
+
132
+ function inpProvenance(): MetricProvenance {
133
+ return {
134
+ origin: 'lab',
135
+ producer: 'Chrome performance trace analyzed by DevTools Trace Engine',
136
+ scope: 'recording-window',
137
+ limitations: ['Only interactions that occurred during this trace can be measured.', 'No recorded interaction is not a zero-millisecond interaction.', 'This is not a real-user field percentile.'],
138
+ };
139
+ }
140
+
141
+ function clsProvenance(): MetricProvenance {
142
+ return {
143
+ origin: 'lab',
144
+ producer: 'Chrome performance trace analyzed by DevTools Trace Engine',
145
+ scope: 'recording-window',
146
+ limitations: ['Only layout shifts observed during this trace contribute to this value.', 'This is not a real-user field percentile.'],
147
+ };
148
+ }
149
+
150
+ function unobserved(provenance: MetricProvenance): UnobservedMetric {
151
+ return { status: 'not-observed', reason: 'not-present-in-recording', provenance };
152
+ }
153
+
154
+ function lcpFrom(model: UnknownRecord): TraceInsightSet['metrics']['lcp'] {
155
+ const insight = record(model.LCPBreakdown);
156
+ const durationMs = finite(insight?.lcpMs);
157
+ if (durationMs === undefined) return unobserved(lcpProvenance());
158
+
159
+ const event = record(insight?.lcpEvent);
160
+ const eventData = record(record(event?.args)?.data);
161
+ const requestData = record(record(record(insight?.lcpRequest)?.args)?.data);
162
+ const subparts = record(insight?.subparts);
163
+ const subpartsMs: Partial<Record<'ttfb' | 'loadDelay' | 'loadDuration' | 'renderDelay', number>> = {};
164
+ for (const name of ['ttfb', 'loadDelay', 'loadDuration', 'renderDelay'] as const) {
165
+ const rangeUs = finite(record(subparts?.[name])?.range);
166
+ if (rangeUs !== undefined) subpartsMs[name] = rangeUs / 1000;
167
+ }
168
+ const attribution: LargestContentfulPaintAttribution = {
169
+ ...(string(eventData?.nodeName) === undefined ? {} : { element: string(eventData?.nodeName) }),
170
+ ...(finite(eventData?.nodeId) === undefined ? {} : { nodeId: finite(eventData?.nodeId) }),
171
+ ...(string(requestData?.url) === undefined ? {} : { resourceUrl: string(requestData?.url) }),
172
+ };
173
+ return {
174
+ status: 'observed',
175
+ value: { durationMs, subpartsMs },
176
+ provenance: lcpProvenance(),
177
+ ...(Object.keys(attribution).length === 0 ? {} : { attribution }),
178
+ };
179
+ }
180
+
181
+ function inpFrom(model: UnknownRecord): TraceInsightSet['metrics']['inp'] {
182
+ const interaction = record(record(model.INPBreakdown)?.highPercentileInteractionEvent);
183
+ const durationUs = finite(interaction?.dur);
184
+ const interactionId = finite(interaction?.interactionId);
185
+ const interactionType = string(interaction?.type);
186
+ if (durationUs === undefined || interactionId === undefined || interactionType === undefined) return unobserved(inpProvenance());
187
+ const sourceData = record(record(record(interaction.rawSourceEvent)?.args)?.data);
188
+ const targetNodeId = finite(sourceData?.nodeId);
189
+ return {
190
+ status: 'observed',
191
+ value: {
192
+ durationMs: durationUs / 1000,
193
+ interactionType,
194
+ interactionId,
195
+ inputDelayMs: (finite(interaction.inputDelay) ?? 0) / 1000,
196
+ mainThreadHandlingMs: (finite(interaction.mainThreadHandling) ?? 0) / 1000,
197
+ presentationDelayMs: (finite(interaction.presentationDelay) ?? 0) / 1000,
198
+ },
199
+ provenance: inpProvenance(),
200
+ ...(targetNodeId === undefined ? {} : { attribution: { targetNodeId } }),
201
+ };
202
+ }
203
+
204
+ function culpritFrom(value: unknown): LayoutShiftCulprit {
205
+ const culprit = record(value);
206
+ const type = finite(culprit?.type);
207
+ const kind: LayoutShiftCulpritKind = type === 0 ? 'web-font'
208
+ : type === 1 ? 'iframe'
209
+ : type === 2 ? 'non-composited-animation'
210
+ : type === 3 ? 'unsized-image'
211
+ : 'engine-reported';
212
+ const url = string(culprit?.url);
213
+ const nodeId = finite(culprit?.backendNodeId);
214
+ const description = string(record(culprit?.description)?.formattedDefault);
215
+ return { kind, ...(url === undefined ? {} : { url }), ...(nodeId === undefined ? {} : { nodeId }), ...(description === undefined ? {} : { description }) };
216
+ }
217
+
218
+ function clsFrom(model: UnknownRecord): TraceInsightSet['metrics']['cls'] {
219
+ const insight = record(model.CLSCulprits);
220
+ const clusters = Array.isArray(insight?.clusters) ? insight.clusters : [];
221
+ if (clusters.length === 0) return unobserved(clsProvenance());
222
+ const culpritsByCluster = insight?.topCulpritsByCluster instanceof Map ? insight.topCulpritsByCluster : new Map();
223
+ const values: LayoutShiftCluster[] = clusters.map((cluster) => {
224
+ const item = record(cluster);
225
+ const rawCulprits = culpritsByCluster.get(cluster);
226
+ return {
227
+ value: finite(item?.clusterCumulativeScore) ?? 0,
228
+ shiftCount: Array.isArray(item?.events) ? item.events.length : 0,
229
+ culprits: Array.isArray(rawCulprits) ? rawCulprits.map(culpritFrom) : [],
230
+ };
231
+ });
232
+ return {
233
+ status: 'observed',
234
+ value: { value: Math.max(...values.map((cluster) => cluster.value)), clusters: values },
235
+ provenance: clsProvenance(),
236
+ };
237
+ }
238
+
239
+ const PRESENTATION_FIELDS = new Set([
240
+ 'insightKey', 'strings', 'title', 'description', 'docs', 'category', 'state', 'fail', 'warnings', 'createOverlays', 'label', 'preconnectCandidates', 'checklist',
241
+ ]);
242
+
243
+ function presentationField(name: string): boolean {
244
+ return PRESENTATION_FIELDS.has(name) || /saving|wasted|score/i.test(name);
245
+ }
246
+
247
+ function reportableField(value: unknown, ancestors = new Set<object>()): TraceInsightField | undefined {
248
+ const text = string(value);
249
+ if (text !== undefined) return text;
250
+ const number = finite(value);
251
+ if (number !== undefined) return number;
252
+ if (typeof value === 'boolean' || value === null) return value;
253
+ if (Array.isArray(value)) return value.flatMap((item) => {
254
+ const field = reportableField(item, ancestors);
255
+ return field === undefined ? [] : [field];
256
+ });
257
+ if (value instanceof Map) {
258
+ if (ancestors.has(value)) return undefined;
259
+ ancestors.add(value);
260
+ const entries = [...value].flatMap(([key, item]) => {
261
+ const reportableKey = reportableField(key, ancestors);
262
+ const reportableValue = reportableField(item, ancestors);
263
+ return reportableKey === undefined || reportableValue === undefined ? [] : [{ key: reportableKey, value: reportableValue }];
264
+ });
265
+ ancestors.delete(value);
266
+ return { entries };
267
+ }
268
+ if (value instanceof Set) {
269
+ if (ancestors.has(value)) return undefined;
270
+ ancestors.add(value);
271
+ const values = [...value].flatMap((item) => {
272
+ const field = reportableField(item, ancestors);
273
+ return field === undefined ? [] : [field];
274
+ });
275
+ ancestors.delete(value);
276
+ return { values };
277
+ }
278
+ const fields = record(value);
279
+ if (fields === null || ancestors.has(fields)) return undefined;
280
+ ancestors.add(fields);
281
+ const result: Record<string, TraceInsightField> = {};
282
+ for (const [key, item] of Object.entries(fields)) {
283
+ if (presentationField(key)) continue;
284
+ const field = reportableField(item, ancestors);
285
+ if (field !== undefined) result[key] = field;
286
+ }
287
+ ancestors.delete(fields);
288
+ return result;
289
+ }
290
+
291
+ function insightsFrom(model: UnknownRecord): readonly TraceInsight[] {
292
+ return Object.values(model).flatMap((value) => {
293
+ const fields = record(value);
294
+ const name = string(fields?.insightKey);
295
+ if (name === undefined) return [];
296
+ const details = reportableField(fields);
297
+ return details === undefined || Array.isArray(details) ? [] : [{ name, fields: details }];
298
+ });
299
+ }
300
+
301
+ /** Parses an in-memory Chrome performance trace without reading files, controlling Chrome, or assigning quality verdicts. */
302
+ export async function analyzeChromeTrace(trace: RawChromeTrace): Promise<TraceAnalysis> {
303
+ const processor = TraceEngine.Processor.TraceProcessor.createWithAllHandlers();
304
+ await processor.parse(traceEvents(trace) as readonly TraceEngine.Types.Events.Event[], { isFreshRecording: true });
305
+ const insightSets = processor.insights;
306
+ if (!insightSets) throw new Error('DevTools Trace Engine produced no insight sets for this trace.');
307
+
308
+ return {
309
+ source: { format: 'chrome-performance-trace', engine: '@paulirish/trace_engine@0.0.65' },
310
+ insightSets: [...insightSets.values()].map((set) => {
311
+ const model = set.model as unknown as UnknownRecord;
312
+ return {
313
+ id: String(set.id),
314
+ url: set.url.toString(),
315
+ insights: insightsFrom(model),
316
+ metrics: { lcp: lcpFrom(model), inp: inpFrom(model), cls: clsFrom(model) },
317
+ };
318
+ }),
319
+ };
320
+ }
package/src/cdp/types.ts CHANGED
@@ -16,6 +16,8 @@ export interface ParsedArgs {
16
16
  * different browser endpoint. */
17
17
  portSource?: 'flag' | 'session' | 'env';
18
18
  out?: string;
19
+ /** Root directory for a session bundle or one-shot artifact bundle. */
20
+ artifactDir?: string;
19
21
  json?: boolean;
20
22
  duration?: number;
21
23
  settle?: number;
@@ -40,6 +42,8 @@ export interface ParsedArgs {
40
42
  viewport?: string;
41
43
  /** `page shot|measure snap --color-scheme dark|light` — transient media override. */
42
44
  colorScheme?: string;
45
+ /** `page scroll --behavior instant|smooth` — dispatch behavior. */
46
+ behavior?: 'instant' | 'smooth';
43
47
  fullPage?: boolean;
44
48
  help?: boolean;
45
49
  /** `--all` — boolean scope-widener (per-leaf semantics). */
@@ -53,6 +57,8 @@ export interface ParsedArgs {
53
57
  /** `session log --name <label>` */
54
58
  name?: string;
55
59
  filterUrl?: string;
60
+ /** `session har --body <url>` — exact captured response URL to export. */
61
+ body?: string;
56
62
  filterStatus?: string;
57
63
  filterMethod?: string;
58
64
  limit?: number;
@@ -86,6 +92,8 @@ export interface ParsedArgs {
86
92
  /** `shot --crop <x,y,w,h>` — crop region in CSS px; composite grammar is
87
93
  * validated by the owning leaf, which documents it. */
88
94
  crop?: string;
95
+ /** `measure text --crop` — write the selected target's measured PNG crop. */
96
+ textCrop?: boolean;
89
97
  /** `shot --crop-selector <selector>` — crop to this element's live box. */
90
98
  cropSelector?: string;
91
99
  /** `shot --pad <px>` — CSS-px padding added around a resolved crop rect. */
@@ -99,7 +107,7 @@ export interface ParsedArgs {
99
107
  before?: string;
100
108
  /** `diff --after <snap>` */
101
109
  after?: string;
102
- /** `diff --full` — expand to the complete state-matrix/per-element diff. */
110
+ /** `diff --full` expands the complete state-matrix/per-element diff; `perf insights --name <insight> --full` renders that insight's related events. */
103
111
  full?: boolean;
104
112
  /** `check|diff --gate` — exit 2 on findings/changes instead of 0. */
105
113
  gate?: boolean;
@@ -146,4 +154,26 @@ export interface ParsedArgs {
146
154
  action?: string;
147
155
  /** `motion response --occurrence <n>` — 1-based occurrence of a repeated action label. */
148
156
  occurrence?: number;
157
+ /** `heap objects --constructor <name>` */
158
+ constructor?: string;
159
+ /** `heap retainers --node <object-id>` */
160
+ node?: string;
161
+ /** `heap retainers --paths <N>` */
162
+ paths?: number;
163
+ /** `heap objects --sort retained|self` */
164
+ sort?: string;
165
+ /** `tab mock start --rules <path>` */
166
+ rules?: string;
167
+ /** `lighthouse --categories <list>` */
168
+ categories?: string;
169
+ /** `lighthouse --preset mobile|desktop` */
170
+ preset?: string;
171
+ /** `page inspect listeners --scope window|document|target` */
172
+ scope?: 'window' | 'document' | 'target';
173
+ /** `page inspect listeners --event <type>` */
174
+ event?: string;
175
+ /** `page repeat --steps <json>` */
176
+ steps?: string;
177
+ /** `page repeat --iterations <n>` */
178
+ iterations?: number;
149
179
  }
package/src/cdp.ts CHANGED
@@ -2,11 +2,10 @@
2
2
  * CDP (Chrome DevTools Protocol) — the library barrel over the CDP substrate.
3
3
  *
4
4
  * Re-exports the client, target discovery, screenshot, a11y, console-recording,
5
- * and tab-lock building blocks, plus `cdpMain` (the routed command dispatch).
6
- * HAR ownership is NOT exported here: the session HAR manager
7
- * (`src/har-manager.ts`) fed by the internal streaming recorder
8
- * (`src/cdp/har-recorder.ts`, a direct import of the recorder bridge) is the
9
- * sole HAR lane.
5
+ * and `cdpMain` (the routed command dispatch). HAR ownership is NOT exported
6
+ * here: the session HAR manager (`src/har-manager.ts`) fed by the internal
7
+ * streaming motion collector (`src/cdp/host/collectors/motion.ts`, which
8
+ * directly imports `src/cdp/har-recorder.ts`) is the sole HAR lane.
10
9
  * The command surface itself lives under `src/cdp/commands/` and is routed by
11
10
  * `src/capture.ts` → `src/cdp/dispatch.ts`. This module is a pure barrel:
12
11
  * `src/capture.ts` is the ONLY entrypoint and the ONLY error/termination
@@ -23,5 +22,4 @@ export { getAccessibilityTree } from './cdp/a11y.js';
23
22
  export { navigateAndWait } from './cdp/record.js';
24
23
  export { ConsoleRecorder } from './cdp/console-recorder.js';
25
24
  export type { ConsoleEntry } from './cdp/console-recorder.js';
26
- export { acquireTabLock, isTabLocked, releaseTabLock, withTabLock } from './cdp/tab-lock.js';
27
25
  export { cdpMain } from './cdp/dispatch.js';