@crouton-kit/capture 1.4.1 → 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 (111) hide show
  1. package/README.md +178 -0
  2. package/bin/capture +31299 -7322
  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 +230 -28
  35. package/src/cdp/commands/motion/timeline.ts +1 -1
  36. package/src/cdp/commands/page/click.ts +18 -8
  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 +11 -6
  44. package/src/cdp/commands/page/shot.ts +68 -33
  45. package/src/cdp/commands/page/type.ts +7 -3
  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/leaf-grammar.ts +2 -2
  80. package/src/cdp/measure/check.ts +181 -16
  81. package/src/cdp/measure/collectors/hittest.ts +16 -10
  82. package/src/cdp/measure/collectors/scroll.ts +15 -7
  83. package/src/cdp/measure/collectors/style-provenance.ts +11 -0
  84. package/src/cdp/measure/collectors/styles.ts +4 -4
  85. package/src/cdp/measure/explain.ts +14 -2
  86. package/src/cdp/measure/map-layers.ts +52 -8
  87. package/src/cdp/measure/snapshot.ts +5 -3
  88. package/src/cdp/measure/sweep.ts +29 -6
  89. package/src/cdp/measure/text.ts +227 -0
  90. package/src/cdp/motion/jank.ts +29 -83
  91. package/src/cdp/motion/mask.ts +18 -9
  92. package/src/cdp/motion/recorder.ts +86 -731
  93. package/src/cdp/scope-lock.ts +9 -9
  94. package/src/cdp/screenshot.ts +51 -21
  95. package/src/cdp/timing.ts +5 -5
  96. package/src/cdp/trace-analysis.ts +320 -0
  97. package/src/cdp/types.ts +31 -1
  98. package/src/cdp.ts +4 -6
  99. package/src/har-manager.ts +2 -4
  100. package/src/interact.ts +78 -25
  101. package/src/output/artifact.ts +49 -5
  102. package/src/output/render.ts +60 -9
  103. package/src/output/selection.ts +6 -0
  104. package/src/session/artifacts.ts +122 -12
  105. package/src/session/commands.ts +348 -113
  106. package/src/session/coordinator.ts +0 -93
  107. package/src/session/har-redact.ts +29 -23
  108. package/src/session-context.ts +13 -9
  109. package/src/cdp/bridge/protocol.ts +0 -224
  110. package/src/cdp/recorder-client.ts +0 -326
  111. package/src/cdp/tab-lock.ts +0 -132
@@ -391,9 +391,6 @@ function vClocks(v: unknown, source: string, path: string): CaptureClocks {
391
391
  if (responseMonotonic < requestMonotonic) {
392
392
  fail(source, `${path}.responseMonotonic`, 'must be >= requestMonotonic');
393
393
  }
394
- if (responseMonotonic > terminalMonotonic) {
395
- fail(source, `${path}.responseMonotonic`, 'must be <= terminalMonotonic');
396
- }
397
394
  }
398
395
  return {
399
396
  requestWallTime: vFinite(o.requestWallTime, source, `${path}.requestWallTime`),
@@ -768,6 +765,7 @@ function vInvalidClockOrder(
768
765
  let violation: Extract<IncompleteLifecycle, { kind: 'invalid_clock_order' }>['violation'];
769
766
  if (o.violation === 'response_before_request') violation = 'response_before_request';
770
767
  else if (o.violation === 'terminal_before_request') violation = 'terminal_before_request';
768
+ // Schema-v1 accepts this persisted discriminator.
771
769
  else if (o.violation === 'terminal_before_response') violation = 'terminal_before_response';
772
770
  else return fail(source, `${path}.violation`, 'must be "response_before_request", "terminal_before_request", or "terminal_before_response"');
773
771
 
@@ -828,7 +826,7 @@ function vStoppedDuringBody(
828
826
  const requestWallTime = vFinite(cap.requestWallTime, source, `${path}._capture.requestWallTime`);
829
827
  const requestMonotonic = vFiniteMin(cap.requestMonotonic, 0, source, `${path}._capture.requestMonotonic`);
830
828
  const responseMonotonic = vFiniteMin(cap.responseMonotonic, requestMonotonic, source, `${path}._capture.responseMonotonic`);
831
- const terminalMonotonic = vFiniteMin(cap.terminalMonotonic, responseMonotonic, source, `${path}._capture.terminalMonotonic`);
829
+ const terminalMonotonic = vFiniteMin(cap.terminalMonotonic, requestMonotonic, source, `${path}._capture.terminalMonotonic`);
832
830
  const encodedDataLength = vFiniteMin(cap.encodedDataLength, 0, source, `${path}._capture.encodedDataLength`);
833
831
 
834
832
  assertStartedDateTime(startedDateTime, requestWallTime, source, `${path}._capture.requestWallTime`, `${path}.startedDateTime`);
package/src/interact.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * (`src/output/selector.ts`'s `parseSelectorInput`) and resolves it against
7
7
  * the LIVE page (not collector-stored records), per prefix:
8
8
  *
9
- * bare string live CSS query — `DOM.getDocument` + `DOM.querySelectorAll`
9
+ * bare string live CSS query, or an exact accessible-name match when CSS finds none
10
10
  * backend:<id> identity — the canonical retry key in every resolution failure
11
11
  * ax:<name> case-insensitive SUBSTRING over live `Accessibility.getFullAXTree` names
12
12
  * axid:<id> AX node id from the same live fetch
@@ -37,14 +37,14 @@ import { parseSelectorInput, type SelectorInputKind } from './output/selector.js
37
37
 
38
38
  /**
39
39
  * The minimal CDP surface this module drives. `CDPClient` and
40
- * `RecorderHeldClient` satisfy it structurally; `motion rec --do` adapts
40
+ * `MotionHeldClient` satisfy it structurally; `motion rec --do` adapts
41
41
  * `RecorderSession.handleCdp` onto it.
42
42
  */
43
43
  export interface LiveClient {
44
44
  send(method: string, params?: Record<string, unknown>): Promise<unknown>;
45
45
  /**
46
46
  * Optional marked dispatch — transports that record labeled input
47
- * landmarks (the recorder bridge's `mark` mechanism) expose it so a
47
+ * landmarks (the motion collector's `mark` mechanism) expose it so a
48
48
  * mutating call that is not auto-markable (`Runtime.callFunctionOn` in
49
49
  * {@link scrollResolved}) can carry its landmark. When absent, a marked
50
50
  * call degrades to a plain `send` (nothing is recording landmarks).
@@ -53,7 +53,7 @@ export interface LiveClient {
53
53
  /**
54
54
  * Optional recorder hook — suppresses the landmark on the next focus
55
55
  * click's initiating edge so a routed `type --into` lands one landmark on
56
- * its actual text insertion (see `RecorderHeldClient`).
56
+ * its actual text insertion (see `MotionHeldClient`).
57
57
  */
58
58
  suppressNextFocusClickMark?(): void;
59
59
  }
@@ -111,17 +111,23 @@ export interface ClickDispatch {
111
111
  readonly name: string | null;
112
112
  readonly x: number;
113
113
  readonly y: number;
114
+ /** Present when the browser's hit test resolves to another node, or when the caller requests the receiver identity even when it equals the target. */
115
+ readonly hitTestReceiverBackendNodeId?: number;
114
116
  }
115
117
 
116
- /** Facts of a dispatched scroll: resolved identity + where the container landed. */
118
+ /** Facts of a dispatched scroll: resolved identity + the observed range traversal. */
117
119
  export interface ScrollDispatch {
118
120
  readonly backendNodeId: number;
119
121
  readonly role: string | null;
120
122
  readonly name: string | null;
121
123
  /** The requested destination: `top`, `bottom`, or a pixel offset. */
122
124
  readonly to: string;
125
+ /** The container's `scrollTop` before the dispatch. */
126
+ readonly scrollTopBefore: number;
123
127
  /** The container's `scrollTop` after the dispatch. */
124
128
  readonly scrollTop: number;
129
+ /** The resolved container's maximum vertical scroll offset. */
130
+ readonly maxScrollTop: number;
125
131
  }
126
132
 
127
133
  // ---------------------------------------------------------------------------
@@ -172,7 +178,7 @@ export async function resolveLiveTarget(
172
178
  return settleAxMatches('axid', input, matches);
173
179
  }
174
180
  case 'css':
175
- return resolveLiveCss(client, input, parsed.value);
181
+ return resolveBareTarget(client, input, parsed.value);
176
182
  }
177
183
  }
178
184
 
@@ -220,6 +226,32 @@ function settleAxMatches(
220
226
  };
221
227
  }
222
228
 
229
+ /**
230
+ * Bare targets preserve CSS behavior when they select one or many elements;
231
+ * when CSS selects none (or rejects the string), an exact accessible-name
232
+ * lookup lets an element name emitted by `page elements` drive directly.
233
+ * `ax:` remains the explicit case-insensitive substring query.
234
+ */
235
+ async function resolveBareTarget(
236
+ client: LiveClient,
237
+ input: string,
238
+ selector: string,
239
+ ): Promise<ResolvedTarget | ResolutionFailure> {
240
+ const css = await resolveLiveCss(client, input, selector);
241
+ if (css.ok || css.code === 'ambiguous') return css;
242
+
243
+ const normalized = normalizeAccessibleName(input);
244
+ if (!normalized) return css;
245
+ const matches = (await readFullAXTree(client)).filter(
246
+ (node) =>
247
+ node.backendDOMNodeId !== undefined &&
248
+ node.name?.value !== undefined &&
249
+ normalizeAccessibleName(node.name.value) === normalized,
250
+ );
251
+ const ax = settleAxMatches('ax', input, matches);
252
+ return ax.ok || ax.code === 'ambiguous' ? ax : css;
253
+ }
254
+
223
255
  async function resolveLiveCss(
224
256
  client: LiveClient,
225
257
  input: string,
@@ -331,7 +363,7 @@ async function axIdentityFor(
331
363
  export async function clickResolved(
332
364
  client: LiveClient,
333
365
  resolved: ResolvedTarget,
334
- opts: { mark?: string } = {},
366
+ opts: { mark?: string; inspectHitTest?: boolean; includeHitTestReceiver?: boolean } = {},
335
367
  ): Promise<ClickDispatch> {
336
368
  const { backendNodeId } = resolved;
337
369
 
@@ -355,20 +387,37 @@ export async function clickResolved(
355
387
  const x = (quad[0] + quad[2] + quad[4] + quad[6]) / 4;
356
388
  const y = (quad[1] + quad[3] + quad[5] + quad[7]) / 4;
357
389
 
358
- const pressParams = { type: 'mousePressed', x, y, button: 'left', clickCount: 1 };
390
+ const dispatchX = Math.round(x);
391
+ const dispatchY = Math.round(y);
392
+ let hitTestReceiverBackendNodeId: number | undefined;
393
+ if (opts.inspectHitTest) {
394
+ const hitTest = (await client.send('DOM.getNodeForLocation', { x: dispatchX, y: dispatchY })) as { backendNodeId?: unknown };
395
+ if (typeof hitTest.backendNodeId !== 'number' || !Number.isSafeInteger(hitTest.backendNodeId) || hitTest.backendNodeId <= 0) {
396
+ throw captureError(
397
+ 'world',
398
+ 'malformed_protocol',
399
+ `DOM.getNodeForLocation returned no valid backend node id for the dispatch point of backend:${backendNodeId}.`,
400
+ { method: 'DOM.getNodeForLocation', response: hitTest },
401
+ );
402
+ }
403
+ if (hitTest.backendNodeId !== backendNodeId || opts.includeHitTestReceiver) hitTestReceiverBackendNodeId = hitTest.backendNodeId;
404
+ }
405
+
406
+ const pressParams = { type: 'mousePressed', x: dispatchX, y: dispatchY, button: 'left', clickCount: 1 };
359
407
  if (opts.mark !== undefined && client.sendMarked) {
360
408
  await client.sendMarked('Input.dispatchMouseEvent', pressParams, opts.mark);
361
409
  } else {
362
410
  await client.send('Input.dispatchMouseEvent', pressParams);
363
411
  }
364
- await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
412
+ await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: dispatchX, y: dispatchY, button: 'left', clickCount: 1 });
365
413
 
366
414
  return {
367
415
  backendNodeId,
368
416
  role: resolved.role,
369
417
  name: resolved.name,
370
- x: Math.round(x),
371
- y: Math.round(y),
418
+ x: dispatchX,
419
+ y: dispatchY,
420
+ ...(hitTestReceiverBackendNodeId === undefined ? {} : { hitTestReceiverBackendNodeId }),
372
421
  };
373
422
  }
374
423
 
@@ -387,9 +436,10 @@ export async function focusAndType(
387
436
  client: LiveClient,
388
437
  resolved: ResolvedTarget,
389
438
  text: string,
439
+ opts: { inspectHitTest?: boolean; includeHitTestReceiver?: boolean } = {},
390
440
  ): Promise<ClickDispatch> {
391
441
  client.suppressNextFocusClickMark?.();
392
- const dispatch = await clickResolved(client, resolved);
442
+ const dispatch = await clickResolved(client, resolved, opts);
393
443
  // Small delay for focus to settle
394
444
  await new Promise((r) => setTimeout(r, 100));
395
445
  await typeText(client, text);
@@ -397,18 +447,20 @@ export async function focusAndType(
397
447
  }
398
448
 
399
449
  /**
400
- * Scrolls a resolved container to `top`, `bottom`, or a pixel offset by
401
- * assigning its `scrollTop` — `DOM.resolveNode` + `Runtime.callFunctionOn`
402
- * with the destination passed as data, never concatenated as code. When the
403
- * caller supplies `opts.mark` and the transport records landmarks
404
- * ({@link LiveClient.sendMarked}), the one mutating call carries the label —
405
- * the recorder landmark behavior `motion rec --do scroll:` shares.
450
+ * Scrolls a resolved container to `top`, `bottom`, or a pixel offset through
451
+ * an instant or native smooth-scroll dispatch — `DOM.resolveNode` +
452
+ * `Runtime.callFunctionOn` with the destination passed as data, never
453
+ * concatenated as code. The call resolves only at `scrollend`, returning the
454
+ * actual final offset. When the caller supplies `opts.mark` and the transport
455
+ * records landmarks ({@link LiveClient.sendMarked}), the one mutating call
456
+ * carries the label — the recorder landmark behavior `motion rec --do scroll:`
457
+ * shares.
406
458
  */
407
459
  export async function scrollResolved(
408
460
  client: LiveClient,
409
461
  resolved: ResolvedTarget,
410
462
  to: string,
411
- opts: { mark?: string } = {},
463
+ opts: { mark?: string; behavior?: 'instant' | 'smooth' } = {},
412
464
  ): Promise<ScrollDispatch> {
413
465
  if (!isScrollDestination(to)) {
414
466
  throw captureError(
@@ -441,8 +493,9 @@ export async function scrollResolved(
441
493
  const params: Record<string, unknown> = {
442
494
  objectId,
443
495
  functionDeclaration:
444
- 'function(to) { const n = to === "top" ? 0 : to === "bottom" ? this.scrollHeight : Number(to); this.scrollTop = n; return this.scrollTop; }',
445
- arguments: [{ value: to }],
496
+ 'function(to, behavior) { const root = this === document.documentElement || this === document.body; const container = root ? document.scrollingElement : this; const before = container.scrollTop; const max = Math.max(0, container.scrollHeight - container.clientHeight); const n = to === "top" ? 0 : to === "bottom" ? container.scrollHeight : Number(to); const top = Math.max(0, Math.min(n, max)); const observed = () => ({ scrollTopBefore: before, scrollTopAfter: container.scrollTop, maxScrollTop: max }); if (before === top || behavior === "instant") { if (root) window.scrollTo({ top, behavior: "instant" }); else this.scrollTo({ top, behavior: "instant" }); return observed(); } return new Promise((resolve) => { const eventTarget = root ? document : this; let done = false; const finish = () => { if (done) return; done = true; clearTimeout(timeout); resolve(observed()); }; const timeout = setTimeout(finish, 5000); eventTarget.addEventListener("scrollend", finish, { once: true }); if (root) window.scrollTo({ top, behavior: "smooth" }); else this.scrollTo({ top, behavior: "smooth" }); }); }',
497
+ arguments: [{ value: to }, { value: opts.behavior ?? 'instant' }],
498
+ awaitPromise: true,
446
499
  returnByValue: true,
447
500
  };
448
501
  const result = (
@@ -458,15 +511,15 @@ export async function scrollResolved(
458
511
  `Scroll dispatch threw in-page: ${result.exceptionDetails.text ?? 'unknown error'}.`,
459
512
  );
460
513
  }
461
- const scrollTop = result?.result?.value;
462
- if (typeof scrollTop !== 'number') {
463
- throw captureError('world', 'malformed_protocol', 'Scroll did not return a valid scrollTop payload.', {
514
+ const observed = result?.result?.value as { scrollTopBefore?: unknown; scrollTopAfter?: unknown; maxScrollTop?: unknown } | undefined;
515
+ if (typeof observed?.scrollTopBefore !== 'number' || typeof observed.scrollTopAfter !== 'number' || typeof observed.maxScrollTop !== 'number') {
516
+ throw captureError('world', 'malformed_protocol', 'Scroll did not return a valid traversed-range payload.', {
464
517
  method: 'Runtime.callFunctionOn',
465
518
  response: result,
466
519
  });
467
520
  }
468
521
 
469
- return { backendNodeId, role: resolved.role, name: resolved.name, to, scrollTop };
522
+ return { backendNodeId, role: resolved.role, name: resolved.name, to, scrollTopBefore: observed.scrollTopBefore, scrollTop: observed.scrollTopAfter, maxScrollTop: observed.maxScrollTop };
470
523
  } catch (error) {
471
524
  primaryFailed = true;
472
525
  primaryError = error;
@@ -26,7 +26,7 @@
26
26
  import * as fs from 'fs';
27
27
  import * as path from 'path';
28
28
 
29
- import { assertUnderCaptureRoot, type SnapMeta, type RecMeta } from '../session/artifacts.js';
29
+ import { assertUnderCaptureRoot, readPrivateFile, type SnapMeta, type RecMeta } from '../session/artifacts.js';
30
30
  import { getActiveSession } from '../session-context.js';
31
31
 
32
32
  // ============================================================================
@@ -47,7 +47,16 @@ export interface RecRef {
47
47
  readonly dir: string;
48
48
  }
49
49
 
50
- export type ArtifactRef = SnapRef | RecRef;
50
+ export interface TraceRef {
51
+ readonly kind: 'trace';
52
+ readonly id: string;
53
+ /** Absolute path to `perf/traces/{id}` (or the oneshot equivalent). */
54
+ readonly dir: string;
55
+ readonly completion: 'complete' | 'partial' | 'orphaned' | 'truncated';
56
+ readonly reason?: string;
57
+ }
58
+
59
+ export type ArtifactRef = SnapRef | RecRef | TraceRef;
51
60
 
52
61
  /** What a pluggable URL-snap callback must return. */
53
62
  export interface SnapUrlResult {
@@ -107,12 +116,12 @@ export function isUrlRef(ref: string): boolean {
107
116
  }
108
117
 
109
118
  interface RefKindConfig {
110
- readonly kind: 'snap' | 'rec';
119
+ readonly kind: 'snap' | 'rec' | 'trace';
111
120
  /** Path segments under a session dir an id resolves through, e.g.
112
121
  * `['measure', 'snaps']`. */
113
122
  readonly idSegments: readonly [string, string];
114
123
  readonly creatingCommand: string;
115
- readonly label: 'snapshot' | 'recording';
124
+ readonly label: 'snapshot' | 'recording' | 'trace';
116
125
  }
117
126
 
118
127
  const SNAP_CONFIG: RefKindConfig = {
@@ -129,6 +138,13 @@ const REC_CONFIG: RefKindConfig = {
129
138
  label: 'recording',
130
139
  };
131
140
 
141
+ const TRACE_CONFIG: RefKindConfig = {
142
+ kind: 'trace',
143
+ idSegments: ['perf', 'traces'],
144
+ creatingCommand: 'capture perf trace',
145
+ label: 'trace',
146
+ };
147
+
132
148
  function stripTrailingSep(p: string): string {
133
149
  return p.length > 1 && (p.endsWith('/') || p.endsWith(path.sep)) ? p.slice(0, -1) : p;
134
150
  }
@@ -223,6 +239,33 @@ export function resolveRecRef(ref: string): RecRef {
223
239
  return resolveRefFromId(ref, REC_CONFIG) as RecRef;
224
240
  }
225
241
 
242
+ /** Resolves a finalized performance trace and verifies the host's committed-file inventory. */
243
+ export function resolveTraceRef(ref: string): TraceRef {
244
+ if (isUrlRef(ref)) {
245
+ throw new ArtifactResolutionError(ref, [], 'a trace ref cannot be a URL — record one first with `capture perf trace <url>` or `--start`/`--stop`');
246
+ }
247
+ const resolved = (path.isAbsolute(ref) ? resolveRefFromPath(ref, TRACE_CONFIG) : (rejectRelativePath(ref), resolveRefFromId(ref, TRACE_CONFIG))) as { kind: 'trace'; id: string; dir: string };
248
+ let meta: Record<string, unknown>;
249
+ try { meta = JSON.parse(readPrivateFile(path.join(resolved.dir, 'meta.json')).toString('utf8')) as Record<string, unknown>; }
250
+ catch (error) { throw new ArtifactResolutionError(ref, [path.join(resolved.dir, 'meta.json')], `could not read trace metadata: ${error instanceof Error ? error.message : String(error)}`, TRACE_CONFIG.creatingCommand); }
251
+ const completion = meta.completion;
252
+ if (completion !== 'complete' && completion !== 'partial' && completion !== 'orphaned') {
253
+ throw new ArtifactResolutionError(ref, [path.join(resolved.dir, 'meta.json')], 'trace metadata has no valid completion state', TRACE_CONFIG.creatingCommand);
254
+ }
255
+ const inventory = meta.files;
256
+ const inventoryMalformed = !Array.isArray(inventory);
257
+ const inventoryMismatch = !inventoryMalformed && inventory.some((entry) => {
258
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return true;
259
+ const item = entry as Record<string, unknown>;
260
+ const name = item.name;
261
+ const bytes = item.bytes;
262
+ if (typeof name !== 'string' || typeof bytes !== 'number' || !Number.isSafeInteger(bytes) || bytes < 0 || name.includes(path.sep)) return true;
263
+ try { return fs.statSync(path.join(resolved.dir, name)).size !== bytes; } catch { return true; }
264
+ });
265
+ const truncatedReason = inventoryMalformed ? 'artifact_inventory_missing_or_malformed' : inventoryMismatch ? 'artifact_inventory_mismatch' : undefined;
266
+ return { ...resolved, completion: truncatedReason ? 'truncated' : completion, ...(truncatedReason ? { reason: truncatedReason } : typeof meta.reason === 'string' ? { reason: meta.reason } : {}) };
267
+ }
268
+
226
269
  // ============================================================================
227
270
  // Readers
228
271
  // ============================================================================
@@ -249,6 +292,7 @@ const CREATING_COMMAND_BY_FILE: Record<string, string> = {
249
292
  'rects.jsonl': 'capture motion rec',
250
293
  'events.jsonl': 'capture motion rec',
251
294
  'markers.json': 'capture motion rec',
295
+ 'trace.json': 'capture perf trace',
252
296
  };
253
297
 
254
298
  function creatingCommandFor(filename: string): string | undefined {
@@ -268,7 +312,7 @@ export function artifactPath(ref: ArtifactRef, filename: string, opts: { mustExi
268
312
  throw new ArtifactResolutionError(
269
313
  ref.id,
270
314
  [filePath],
271
- `${filename} is not present in ${ref.kind === 'snap' ? 'snapshot' : 'recording'} ${ref.id}`,
315
+ `${filename} is not present in ${ref.kind === 'snap' ? 'snapshot' : ref.kind === 'rec' ? 'recording' : 'trace'} ${ref.id}`,
272
316
  creatingCommandFor(filename),
273
317
  );
274
318
  }
@@ -30,6 +30,7 @@ export const RESULT_TAGS = [
30
30
  'diff',
31
31
  'census',
32
32
  'explain',
33
+ 'text',
33
34
  'sweep',
34
35
  'focus-map',
35
36
  'scroll-map',
@@ -45,6 +46,7 @@ export const RESULT_TAGS = [
45
46
  'session-stopped',
46
47
  'sessions',
47
48
  'session-har',
49
+ 'collectors',
48
50
  'log-tail',
49
51
  'clicked',
50
52
  'typed',
@@ -52,6 +54,11 @@ export const RESULT_TAGS = [
52
54
  'navigated',
53
55
  'exec-result',
54
56
  'elements',
57
+ 'listeners',
58
+ 'cardinality',
59
+ 'frames',
60
+ 'resources',
61
+ 'repeated',
55
62
  'screenshot',
56
63
  'tabs',
57
64
  'tab-opened',
@@ -60,10 +67,20 @@ export const RESULT_TAGS = [
60
67
  'browser-launched',
61
68
  'browser-quit',
62
69
  'network',
70
+ 'mock',
63
71
  'cdp-result',
64
72
  'libs',
65
73
  'lib',
66
74
  'ax-map',
75
+ 'trace',
76
+ 'vitals',
77
+ 'insights',
78
+ 'heap-snapshot',
79
+ 'heap-census',
80
+ 'heap-objects',
81
+ 'retainers',
82
+ 'heap-diff',
83
+ 'lighthouse',
67
84
  ] as const;
68
85
 
69
86
  export type ResultTag = (typeof RESULT_TAGS)[number];
@@ -723,7 +740,7 @@ function resolveFactLine(fl: FactLine, mode: 'xml' | 'json'): string {
723
740
  // ---------------------------------------------------------------------------
724
741
 
725
742
  export interface Attestation {
726
- readonly kind: 'snapshot' | 'recording';
743
+ readonly kind: 'snapshot' | 'recording' | 'trace';
727
744
  /** Snapshot or recording id, e.g. "snap-a3f2" / "rec-9f31". */
728
745
  readonly id: string;
729
746
  /** Absolute artifact directory path. */
@@ -738,7 +755,9 @@ export interface Attestation {
738
755
  // hands to renderResult()/emitResult().
739
756
  // ---------------------------------------------------------------------------
740
757
 
741
- export type ResultAttrs = Record<string, string | number | boolean | undefined>;
758
+ export type ResultAttrs = Record<string, string | number | boolean | Capped | undefined>;
759
+
760
+ export type JsonValue = string | number | boolean | null | Capped | readonly JsonValue[] | { readonly [key: string]: JsonValue };
742
761
 
743
762
  export interface RenderableResult {
744
763
  /** Trusted block tag — must be one of RESULT_TAGS. */
@@ -753,6 +772,10 @@ export interface RenderableResult {
753
772
  readonly attrs?: ResultAttrs;
754
773
  /** Snapshot/recording identity + optional method note. */
755
774
  readonly attestation?: Attestation;
775
+ /** Optional structured JSON mirror for artifact names. Prose retains the rendered FactLine artifact list. */
776
+ readonly jsonArtifacts?: readonly string[];
777
+ /** Optional structured JSON mirror for sections. Prose retains the rendered FactLine sections. */
778
+ readonly jsonSections?: readonly JsonValue[];
756
779
  /** Lead sentence(s), rendered immediately after the opening tag. */
757
780
  readonly summary?: FactLine;
758
781
  /** Pre-formatted artifact listing (typically via formatArtifactList),
@@ -764,20 +787,23 @@ export interface RenderableResult {
764
787
  * multi-line paragraph/preformatted block that should NOT be blank-line
765
788
  * split — e.g. a motion timeline's consecutive `t=...` rows. */
766
789
  readonly sections?: readonly FactLine[];
790
+ /** Structured JSON mirror of sections when rows are records rather than prose. All strings are recursively sanitized by the renderer. */
791
+ readonly jsonSections?: unknown;
767
792
  /** A single follow_up line, built only from trusted command templates via
768
793
  * `fact`/`text`/`data` — ids/paths are always embedded as escaped data,
769
794
  * never as literal instruction text. */
770
795
  readonly followUp?: FactLine;
771
796
  }
772
797
 
773
- function renderAttrValue(raw: string | number | boolean): string {
798
+ function renderAttrValue(raw: string | number | boolean | Capped): string {
799
+ if (isCapped(raw)) return escapeXmlAttr(capLength(neutralizeControl(String(raw.value)), raw.maxLength));
774
800
  if (typeof raw === 'number') return sanitizeNumber(raw);
775
801
  if (typeof raw === 'boolean') return String(raw);
776
- const cleaned = neutralizeControl(raw);
777
- return escapeXmlAttr(capLength(cleaned, DEFAULT_ATTR_MAX));
802
+ return escapeXmlAttr(capLength(neutralizeControl(raw), DEFAULT_ATTR_MAX));
778
803
  }
779
804
 
780
- function jsonAttrValue(raw: string | number | boolean): string | number | boolean {
805
+ function jsonAttrValue(raw: string | number | boolean | Capped): string | number | boolean {
806
+ if (isCapped(raw)) return capLength(neutralizeControl(String(raw.value)), raw.maxLength);
781
807
  if (typeof raw !== 'string') return raw;
782
808
  return capLength(neutralizeControl(raw), DEFAULT_ATTR_MAX);
783
809
  }
@@ -816,7 +842,7 @@ function renderAttrs(attrs: ResultAttrs): string {
816
842
  * the prose and --json paths. */
817
843
  function mergeAttestationAttrs(attrs: ResultAttrs, attestation?: Attestation): ResultAttrs {
818
844
  if (!attestation) return attrs;
819
- const idKey = attestation.kind === 'snapshot' ? 'snap' : 'rec';
845
+ const idKey = attestation.kind === 'snapshot' ? 'snap' : attestation.kind === 'recording' ? 'rec' : 'trace';
820
846
  const canonical: ResultAttrs = { path: attestation.path, [idKey]: attestation.id };
821
847
 
822
848
  for (const [key, canonicalValue] of Object.entries(canonical)) {
@@ -839,6 +865,14 @@ function mergeAttestationAttrs(attrs: ResultAttrs, attestation?: Attestation): R
839
865
  return merged;
840
866
  }
841
867
 
868
+ function jsonValue(value: JsonValue): JsonValue {
869
+ if (isCapped(value)) return capLength(neutralizeControl(String(value.value)), value.maxLength);
870
+ if (typeof value === 'string') return capLength(neutralizeControl(value), DEFAULT_DATA_MAX);
871
+ if (typeof value === 'number' || typeof value === 'boolean' || value === null) return value;
872
+ if (Array.isArray(value)) return value.map(jsonValue);
873
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [capLength(neutralizeControl(key), DEFAULT_DATA_MAX), jsonValue(child)]));
874
+ }
875
+
842
876
  function assembleBody(result: RenderableResult): string {
843
877
  const header: string[] = [];
844
878
  if (result.attestation?.note) header.push(resolveFactLine(result.attestation.note, 'xml'));
@@ -864,6 +898,22 @@ export function renderResult(result: RenderableResult): string {
864
898
  return `${block}\nfollow_up: ${resolveFactLine(result.followUp, 'xml')}`;
865
899
  }
866
900
 
901
+ function sanitizeJsonValue(value: unknown, seen = new WeakSet<object>()): unknown {
902
+ if (isCapped(value)) return capLength(neutralizeControl(String(value.value)), value.maxLength);
903
+ if (typeof value === 'string') return capLength(neutralizeControl(value), DEFAULT_DATA_MAX);
904
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
905
+ if (typeof value === 'boolean' || value === null) return value;
906
+ if (Array.isArray(value)) return value.map(item => sanitizeJsonValue(item, seen));
907
+ if (typeof value === 'object') {
908
+ if (seen.has(value)) throw new Error('render.ts: structured JSON sections cannot contain cycles');
909
+ seen.add(value);
910
+ const record = Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [capLength(neutralizeControl(key), DEFAULT_ATTR_MAX), sanitizeJsonValue(item, seen)]));
911
+ seen.delete(value);
912
+ return record;
913
+ }
914
+ return null;
915
+ }
916
+
867
917
  /** Build the plain-object JSON mirror of a result, applying the same
868
918
  * length-cap / control-char redaction as the prose renderer (minus XML
869
919
  * entity escaping, which JSON.stringify already makes unnecessary). */
@@ -875,7 +925,7 @@ export function toJsonResult(result: RenderableResult): Record<string, unknown>
875
925
  .filter(([, v]) => v !== undefined)
876
926
  .map(([k, v]) => {
877
927
  assertValidAttrKey(k);
878
- return [k, jsonAttrValue(v as string | number | boolean)];
928
+ return [k, jsonAttrValue(v as string | number | boolean | Capped)];
879
929
  }),
880
930
  );
881
931
 
@@ -891,7 +941,8 @@ export function toJsonResult(result: RenderableResult): Record<string, unknown>
891
941
  }
892
942
  if (result.summary) out.summary = resolveFactLine(result.summary, 'json');
893
943
  if (result.artifacts) out.artifacts = resolveFactLine(result.artifacts, 'json');
894
- if (result.sections?.length) out.sections = result.sections.map((s) => resolveFactLine(s, 'json'));
944
+ if (result.jsonSections !== undefined) out.sections = sanitizeJsonValue(result.jsonSections);
945
+ else if (result.sections?.length) out.sections = result.sections.map((s) => resolveFactLine(s, 'json'));
895
946
  if (result.followUp) out.followUp = resolveFactLine(result.followUp, 'json');
896
947
 
897
948
  return out;
@@ -0,0 +1,6 @@
1
+ import { type ParsedArgs } from '../cdp/types.js';
2
+
3
+ /** Select the final stdout record set after a leaf has applied its own semantic filters and stable ordering. */
4
+ export function selectRecords<T>(records: readonly T[], parsed: Pick<ParsedArgs, 'limit'>, defaultLimit: number): T[] {
5
+ return records.slice(0, parsed.limit ?? defaultLimit);
6
+ }