@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
@@ -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
@@ -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
  // ---------------------------------------------------------------------------
@@ -357,7 +363,7 @@ async function axIdentityFor(
357
363
  export async function clickResolved(
358
364
  client: LiveClient,
359
365
  resolved: ResolvedTarget,
360
- opts: { mark?: string } = {},
366
+ opts: { mark?: string; inspectHitTest?: boolean; includeHitTestReceiver?: boolean } = {},
361
367
  ): Promise<ClickDispatch> {
362
368
  const { backendNodeId } = resolved;
363
369
 
@@ -381,20 +387,37 @@ export async function clickResolved(
381
387
  const x = (quad[0] + quad[2] + quad[4] + quad[6]) / 4;
382
388
  const y = (quad[1] + quad[3] + quad[5] + quad[7]) / 4;
383
389
 
384
- 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 };
385
407
  if (opts.mark !== undefined && client.sendMarked) {
386
408
  await client.sendMarked('Input.dispatchMouseEvent', pressParams, opts.mark);
387
409
  } else {
388
410
  await client.send('Input.dispatchMouseEvent', pressParams);
389
411
  }
390
- 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 });
391
413
 
392
414
  return {
393
415
  backendNodeId,
394
416
  role: resolved.role,
395
417
  name: resolved.name,
396
- x: Math.round(x),
397
- y: Math.round(y),
418
+ x: dispatchX,
419
+ y: dispatchY,
420
+ ...(hitTestReceiverBackendNodeId === undefined ? {} : { hitTestReceiverBackendNodeId }),
398
421
  };
399
422
  }
400
423
 
@@ -413,9 +436,10 @@ export async function focusAndType(
413
436
  client: LiveClient,
414
437
  resolved: ResolvedTarget,
415
438
  text: string,
439
+ opts: { inspectHitTest?: boolean; includeHitTestReceiver?: boolean } = {},
416
440
  ): Promise<ClickDispatch> {
417
441
  client.suppressNextFocusClickMark?.();
418
- const dispatch = await clickResolved(client, resolved);
442
+ const dispatch = await clickResolved(client, resolved, opts);
419
443
  // Small delay for focus to settle
420
444
  await new Promise((r) => setTimeout(r, 100));
421
445
  await typeText(client, text);
@@ -423,18 +447,20 @@ export async function focusAndType(
423
447
  }
424
448
 
425
449
  /**
426
- * Scrolls a resolved container to `top`, `bottom`, or a pixel offset by
427
- * assigning its `scrollTop` — `DOM.resolveNode` + `Runtime.callFunctionOn`
428
- * with the destination passed as data, never concatenated as code. When the
429
- * caller supplies `opts.mark` and the transport records landmarks
430
- * ({@link LiveClient.sendMarked}), the one mutating call carries the label —
431
- * 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.
432
458
  */
433
459
  export async function scrollResolved(
434
460
  client: LiveClient,
435
461
  resolved: ResolvedTarget,
436
462
  to: string,
437
- opts: { mark?: string } = {},
463
+ opts: { mark?: string; behavior?: 'instant' | 'smooth' } = {},
438
464
  ): Promise<ScrollDispatch> {
439
465
  if (!isScrollDestination(to)) {
440
466
  throw captureError(
@@ -467,8 +493,9 @@ export async function scrollResolved(
467
493
  const params: Record<string, unknown> = {
468
494
  objectId,
469
495
  functionDeclaration:
470
- 'function(to) { const n = to === "top" ? 0 : to === "bottom" ? this.scrollHeight : Number(to); this.scrollTop = n; return this.scrollTop; }',
471
- 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,
472
499
  returnByValue: true,
473
500
  };
474
501
  const result = (
@@ -484,15 +511,15 @@ export async function scrollResolved(
484
511
  `Scroll dispatch threw in-page: ${result.exceptionDetails.text ?? 'unknown error'}.`,
485
512
  );
486
513
  }
487
- const scrollTop = result?.result?.value;
488
- if (typeof scrollTop !== 'number') {
489
- 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.', {
490
517
  method: 'Runtime.callFunctionOn',
491
518
  response: result,
492
519
  });
493
520
  }
494
521
 
495
- 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 };
496
523
  } catch (error) {
497
524
  primaryFailed = true;
498
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
+ }