@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
@@ -15,18 +15,17 @@
15
15
  * sequence; sessionless with no `--out` → a fresh `oneshot-{id}/page` dir
16
16
  * under the capture root (never a loose /tmp file).
17
17
  */
18
- import * as fs from 'fs';
19
18
  import * as path from 'path';
20
19
  import { type ParsedArgs } from '../../types.js';
21
20
  import { withConnection } from '../../connection.js';
22
- import { captureScreenshotWithCssViewport, type CapturedRegion, type ScreenshotCrop } from '../../screenshot.js';
21
+ import { type CDPClient } from '../../client.js';
22
+ import { captureScreenshotWithCssViewport, writeScreenshot, type CapturedRegion, type ScreenshotCrop, type ScrollOffset } from '../../screenshot.js';
23
23
  import { parseViewport, type Viewport } from '../../viewport.js';
24
24
  import { parseColorScheme, withAppliedColorScheme, type ColorScheme } from '../../color-scheme.js';
25
25
  import { nextStepPath } from '../../../session-context.js';
26
26
  import { createOneshotSession } from '../../../session/commands.js';
27
27
  import { resolveLiveTarget, type LiveClient, type ResolutionFailure } from '../../../interact.js';
28
28
  import { emitResolutionError } from './click.js';
29
- import { assertUnderCaptureRoot, writeBinaryPrivate } from '../../../session/artifacts.js';
30
29
  import {
31
30
  emitResult,
32
31
  fact,
@@ -45,13 +44,14 @@ input:
45
44
  --crop-selector <sel> resolve exactly one live target (bare CSS takes precedence; exact accessible name applies when CSS finds none; ax:<name>, axid:<id>, or backend:<id>), scroll it into view, then crop its border box and surroundings
46
45
  --pad <px> nonnegative integer CSS pixels added on every side of --crop-selector (default: 0; not accepted with --crop)
47
46
  --zoom <factor> positive decimal CSS-to-image scale requested for a CSS crop; the 1600px image cap may produce a smaller reported scale
48
- --out <path> destination file; default: the active session's shots/ sequence, or a fresh oneshot-*/page/ dir under the capture root when no session is active
47
+ --out <path> caller-owned destination PNG; default: the active session's shots/ sequence, or a fresh one-shot bundle when no session is active
48
+ --artifact-dir <path> root for that sessionless one-shot bundle (ignored with --out or an active session)
49
49
  --target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
50
50
  --json mirror the result as JSON
51
51
  output:
52
- <screenshot path=… width=… height=… css-width=… css-height=… css-x=… css-y=… css-to-image-x=… css-to-image-y=… emulation=none|viewport|full-page color-scheme=dark|light> — saved path, PNG pixel dimensions, the CSS-pixel region the image covers (its page-coordinate origin and size) with the CSS-to-image scale derived from it, byte size, crop provenance, and any requested transient emulation
52
+ <screenshot path=… width=… height=… css-width=… css-height=… css-x=… css-y=… css-to-image-x=… css-to-image-y=… effective-downscale-x=… effective-downscale-y=… scroll-x=… scroll-y=… emulation=none|viewport|full-page color-scheme=dark|light> — saved path, PNG pixel dimensions, the CSS-pixel region the image covers (its page-coordinate origin and size) with the CSS-to-image scale derived from it, visual-viewport scroll offset at capture, any effective downscale below 1 image px/CSS px, byte size, crop provenance, and any requested transient emulation
53
53
  effects:
54
- no flags: none — the capture reads the viewport as-is, with zero Emulation.* calls. --viewport/--full-page applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture — two page-observable resizes. --color-scheme applies a transient Emulation.setEmulatedMedia prefers-color-scheme override and clears it after the capture. --crop-selector scrolls its resolved target into view. CSS crops are intersected with the source visual viewport; an empty intersection fails without writing an image. --crop/--crop-selector cannot be combined with --full-page.`;
54
+ no flags: none — the capture reads the viewport as-is, with zero Emulation.* calls. Sessionless captures write one bundle under --artifact-dir when supplied; --out writes only its caller-owned PNG. --viewport/--full-page applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture — two page-observable resizes. --color-scheme applies a transient Emulation.setEmulatedMedia prefers-color-scheme override and clears it after the capture. --crop-selector scrolls its resolved target into view. CSS crops are intersected with the source visual viewport; a selector crop reports its requested and delivered regions when its padded box cannot fit. An empty intersection fails without writing an image. --crop/--crop-selector cannot be combined with --full-page.`;
55
55
 
56
56
  // ---------------------------------------------------------------------------
57
57
  // Test-injectable dependency seam (the CDP-stub test pattern; the capture
@@ -94,29 +94,13 @@ export function pngDimensions(png: Buffer): { width: number; height: number } |
94
94
 
95
95
  /**
96
96
  * Resolves the output path: explicit `--out` > active session's `shots/`
97
- * sequence > a fresh `oneshot-{id}/page` dir under the capture root.
97
+ * sequence > a fresh `oneshot-{id}/page` dir under the selected artifact root.
98
98
  */
99
99
  async function resolveOutPath(parsed: ParsedArgs): Promise<string> {
100
100
  if (parsed.out) return parsed.out;
101
101
  const sessionPath = await deps.nextStepPath('shot', 'manual');
102
102
  if (sessionPath) return sessionPath;
103
- return path.join(deps.createOneshotSession('page').artifactsDir, 'shot.png');
104
- }
105
-
106
- /**
107
- * Writes the PNG privately (0600 under a 0700 dir) when it lands under
108
- * CAPTURE_ROOT (auto-generated session/oneshot paths), and with a plain
109
- * write when the user gave an explicit `--out` outside the capture tree —
110
- * a user-chosen destination whose permissions are the user's to decide.
111
- */
112
- function writeScreenshot(outPath: string, png: Buffer): void {
113
- try {
114
- assertUnderCaptureRoot(outPath);
115
- } catch {
116
- fs.writeFileSync(outPath, png);
117
- return;
118
- }
119
- writeBinaryPrivate(outPath, png);
103
+ return path.join(deps.createOneshotSession('page', parsed.artifactDir).artifactsDir, 'shot.png');
120
104
  }
121
105
 
122
106
  // ---------------------------------------------------------------------------
@@ -125,6 +109,8 @@ function writeScreenshot(outPath: string, png: Buffer): void {
125
109
 
126
110
  export type EmulationMode = 'none' | 'viewport' | 'full-page';
127
111
 
112
+ const LEGIBILITY_SCALE_THRESHOLD = 0.5;
113
+
128
114
  interface CropProvenance {
129
115
  readonly source: 'coordinates' | 'selector';
130
116
  readonly requested: ScreenshotCrop;
@@ -139,11 +125,16 @@ function round6(value: number): number {
139
125
  return Math.round(value * 1e6) / 1e6;
140
126
  }
141
127
 
128
+ function sameRect(a: ScreenshotCrop, b: CapturedRegion): boolean {
129
+ return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
130
+ }
131
+
142
132
  export function buildScreenshotResult(f: {
143
133
  path: string;
144
134
  bytes: number;
145
135
  dimensions: { width: number; height: number } | null;
146
136
  cssViewport?: CapturedRegion;
137
+ scrollOffset?: ScrollOffset;
147
138
  emulation: EmulationMode;
148
139
  viewport?: { width: number; height: number };
149
140
  colorScheme?: ColorScheme;
@@ -155,6 +146,17 @@ export function buildScreenshotResult(f: {
155
146
  const scale = f.dimensions && f.cssViewport
156
147
  ? { x: f.dimensions.width / f.cssViewport.width, y: f.dimensions.height / f.cssViewport.height }
157
148
  : undefined;
149
+ const downscale = scale && (scale.x < 1 || scale.y < 1)
150
+ ? { x: scale.x < 1 ? scale.x : undefined, y: scale.y < 1 ? scale.y : undefined }
151
+ : undefined;
152
+ const downscaleDescription = downscale
153
+ ? [
154
+ downscale.x === undefined ? undefined : `horizontal ${downscale.x.toFixed(6)}×`,
155
+ downscale.y === undefined ? undefined : `vertical ${downscale.y.toFixed(6)}×`,
156
+ ].filter((axis): axis is string => axis !== undefined).join(' and ')
157
+ : undefined;
158
+ const cropConstrained = f.crop?.source === 'selector' && f.cssViewport !== undefined && !sameRect(f.crop.requested, f.cssViewport);
159
+ const legibilityConstrained = scale !== undefined && Math.min(scale.x, scale.y) < LEGIBILITY_SCALE_THRESHOLD && Math.min(scale.x, scale.y) < (f.crop?.zoom ?? 1);
158
160
  const sections: FactLine[] = [
159
161
  scale && f.cssViewport
160
162
  ? fact`CSS-to-image scale: ${scale.x.toFixed(6)} image px/CSS px horizontally and ${scale.y.toFixed(6)} image px/CSS px vertically (captured CSS region ${round6(f.cssViewport.width)}×${round6(f.cssViewport.height)} at page origin ${round6(f.cssViewport.x)},${round6(f.cssViewport.y)}).`
@@ -162,6 +164,12 @@ export function buildScreenshotResult(f: {
162
164
  f.crop
163
165
  ? fact`CSS crop: ${f.crop.source === 'selector' ? `selector ${f.crop.selector} (backend:${f.crop.backendNodeId}), border box plus ${f.crop.pad}px padding` : 'coordinates'} requested ${formatRect(f.crop.requested)} at zoom ${f.crop.zoom}; the captured CSS region above is its intersection with the source visual viewport.`
164
166
  : text`CSS crop: none — the captured CSS region above is the source visual viewport.`,
167
+ ...(cropConstrained && f.crop && f.cssViewport
168
+ ? [fact`Crop constraint: requested ${formatRect(f.crop.requested)}; delivered ${formatRect(f.cssViewport)} because the requested selector crop extended outside the ${f.emulation === 'viewport' ? 'effective live visual viewport after its transient device-metrics override' : 'live visual viewport'}, so the artifact contains its visual-viewport intersection.`]
169
+ : []),
170
+ f.scrollOffset
171
+ ? fact`scroll offset at capture: x=${round6(f.scrollOffset.x)} y=${round6(f.scrollOffset.y)} CSS px.`
172
+ : text`scroll offset at capture unavailable: the browser did not return the visual viewport origin.`,
165
173
  f.emulation === 'none'
166
174
  ? text`viewport emulation: none — the browser's actual current viewport was captured.`
167
175
  : f.emulation === 'viewport'
@@ -172,6 +180,7 @@ export function buildScreenshotResult(f: {
172
180
  f.colorScheme
173
181
  ? fact`color scheme: transient prefers-color-scheme=${f.colorScheme} override applied for the capture and cleared after.`
174
182
  : text`color scheme: browser default — no Emulation.setEmulatedMedia call was made.`,
183
+ ...(legibilityConstrained ? [fact`Image constraint: the 1600px image-side cap reduced this artifact below ${LEGIBILITY_SCALE_THRESHOLD} image px per CSS px.`] : []),
175
184
  ];
176
185
  return {
177
186
  tag: 'screenshot',
@@ -185,17 +194,27 @@ export function buildScreenshotResult(f: {
185
194
  'css-y': f.cssViewport === undefined ? undefined : round6(f.cssViewport.y),
186
195
  'css-to-image-x': scale?.x.toFixed(6),
187
196
  'css-to-image-y': scale?.y.toFixed(6),
197
+ 'effective-downscale-x': downscale?.x?.toFixed(6),
198
+ 'effective-downscale-y': downscale?.y?.toFixed(6),
188
199
  emulation: f.emulation,
189
200
  'color-scheme': f.colorScheme,
190
201
  'crop-source': f.crop?.source,
191
202
  'crop-selector': f.crop?.selector,
192
203
  'crop-backend-node-id': f.crop?.backendNodeId,
193
204
  'requested-zoom': f.crop?.zoom,
205
+ 'crop-constrained': cropConstrained ? 'visual-viewport' : undefined,
206
+ 'scroll-x': f.scrollOffset === undefined ? undefined : round6(f.scrollOffset.x),
207
+ 'scroll-y': f.scrollOffset === undefined ? undefined : round6(f.scrollOffset.y),
194
208
  },
195
209
  summary: f.dimensions
196
- ? fact`saved ${f.path} — ${f.dimensions.width}x${f.dimensions.height}px, ${f.bytes} bytes.`
210
+ ? downscaleDescription
211
+ ? fact`saved ${f.path} — ${f.dimensions.width}x${f.dimensions.height}px, ${f.bytes} bytes; effective downscale: ${downscaleDescription} (image px/CSS px).`
212
+ : fact`saved ${f.path} — ${f.dimensions.width}x${f.dimensions.height}px, ${f.bytes} bytes.`
197
213
  : fact`saved ${f.path} — ${f.bytes} bytes.`,
198
214
  sections,
215
+ followUp: legibilityConstrained
216
+ ? text`A selector crop with --zoom 1 captures a bounded region at 1 image px/CSS px when each captured CSS side is at most 1600px.`
217
+ : undefined,
199
218
  };
200
219
  }
201
220
 
@@ -231,6 +250,25 @@ function padded(rect: ScreenshotCrop, pad: number): ScreenshotCrop {
231
250
  return { x: rect.x - pad, y: rect.y - pad, width: rect.width + 2 * pad, height: rect.height + 2 * pad };
232
251
  }
233
252
 
253
+ async function pageBorderRect(client: CDPClient, backendNodeId: number): Promise<ScreenshotCrop | undefined> {
254
+ const box = (await client.send('DOM.getBoxModel', { backendNodeId })) as { model?: unknown };
255
+ const rect = borderRect(box.model);
256
+ if (!rect) return undefined;
257
+
258
+ // DOM box-model quads are relative to the live viewport. Screenshot clips
259
+ // use page coordinates, so account for the scroll DOM.scrollIntoViewIfNeeded
260
+ // just performed for a selector crop.
261
+ const metrics = (await client.send('Page.getLayoutMetrics')) as {
262
+ cssVisualViewport?: { pageX?: unknown; pageY?: unknown; clientWidth?: unknown; clientHeight?: unknown };
263
+ };
264
+ const pageX = metrics.cssVisualViewport?.pageX;
265
+ const pageY = metrics.cssVisualViewport?.pageY;
266
+ if (typeof pageX !== 'number' || !Number.isFinite(pageX) || typeof pageY !== 'number' || !Number.isFinite(pageY)) {
267
+ throw new Error('Page.getLayoutMetrics returned no valid visual-viewport page origin for --crop-selector.');
268
+ }
269
+ return { ...rect, x: rect.x + pageX, y: rect.y + pageY };
270
+ }
271
+
234
272
  // ---------------------------------------------------------------------------
235
273
  // page shot
236
274
  // ---------------------------------------------------------------------------
@@ -326,8 +364,6 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
326
364
  return;
327
365
  }
328
366
 
329
- const emulation: EmulationMode = parsed.fullPage ? 'full-page' : viewport ? 'viewport' : 'none';
330
-
331
367
  // connection.ts derives the recorder-routed action label from
332
368
  // parsed.command, which the router leaves as the branch token 'page' —
333
369
  // restore the verb so stderr diagnostics identify this leaf.
@@ -340,8 +376,7 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
340
376
  const resolved = await resolveLiveTarget(client as unknown as LiveClient, parsed.cropSelector);
341
377
  if (!resolved.ok) return { failure: resolved } as const;
342
378
  await client.send('DOM.scrollIntoViewIfNeeded', { backendNodeId: resolved.backendNodeId });
343
- const box = (await client.send('DOM.getBoxModel', { backendNodeId: resolved.backendNodeId })) as { model?: unknown };
344
- const rect = borderRect(box.model);
379
+ const rect = await pageBorderRect(client, resolved.backendNodeId);
345
380
  if (!rect) throw new Error(`Resolved target backend:${resolved.backendNodeId} has no measurable border box for --crop-selector.`);
346
381
  const pad = parsed.pad ?? 0;
347
382
  requestedCrop = padded(rect, pad);
@@ -352,7 +387,7 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
352
387
  const screenshot = await captureScreenshotWithCssViewport(client, viewport, { fullPage: parsed.fullPage, crop: requestedCrop, zoom });
353
388
  const outPath = await resolveOutPath(parsed);
354
389
  writeScreenshot(outPath, screenshot.png);
355
- return { path: outPath, bytes: screenshot.png.length, dimensions: pngDimensions(screenshot.png), cssViewport: screenshot.cssViewport, crop: cropProvenance } as const;
390
+ return { path: outPath, bytes: screenshot.png.length, dimensions: pngDimensions(screenshot.png), cssViewport: screenshot.cssViewport, scrollOffset: screenshot.scrollOffset, crop: cropProvenance } as const;
356
391
  }),
357
392
  { settle: 0 },
358
393
  );
@@ -361,5 +396,5 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
361
396
  emitResolutionError(parsed, 'page shot', result.failure as ResolutionFailure);
362
397
  return;
363
398
  }
364
- emitResult(buildScreenshotResult({ ...result, emulation, viewport, colorScheme }), { json: parsed.json });
399
+ emitResult(buildScreenshotResult({ ...result, emulation: parsed.fullPage ? 'full-page' : viewport ? 'viewport' : 'none', viewport, colorScheme }), { json: parsed.json });
365
400
  }
@@ -32,7 +32,7 @@ input:
32
32
  --settle <ms> network-settle window applied after typing (default: 500; 1500 with an active session; 0 disables)
33
33
  --no-screenshot skip the auto-screenshot
34
34
  output:
35
- <typed> — the typed text, the resolved field identity (backend-node-id, role, name) and focus-click coordinates when --into was given, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
35
+ <typed> — the typed text, the resolved field identity (backend-node-id, role, name) and focus-click coordinates when --into was given, a hit-test receiver fact when another node covers that focus-click point, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
36
36
  effects:
37
37
  dispatches a real focus click (when --into) followed by real text insertion; writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
38
38
 
@@ -65,7 +65,7 @@ export async function cmdPageType(parsed: ParsedArgs, _args: string[]): Promise<
65
65
  if (parsed.into) {
66
66
  const resolved = await resolveLiveTarget(live, parsed.into);
67
67
  if (!resolved.ok) return { failure: resolved } as const;
68
- dispatch = await focusAndType(live, resolved, textArg);
68
+ dispatch = await focusAndType(live, resolved, textArg, { inspectHitTest: true });
69
69
  } else {
70
70
  await typeText(live, textArg);
71
71
  }
@@ -84,6 +84,9 @@ export async function cmdPageType(parsed: ParsedArgs, _args: string[]): Promise<
84
84
  dispatch
85
85
  ? fact`typed "${textArg}" into ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}), focus click at x=${dispatch.x} y=${dispatch.y}`
86
86
  : fact`typed "${textArg}" into the focused element`,
87
+ ...(dispatch?.hitTestReceiverBackendNodeId === undefined
88
+ ? []
89
+ : [fact`focus-click hit test at x=${dispatch.x} y=${dispatch.y} resolved to backend:${dispatch.hitTestReceiverBackendNodeId}, not target backend:${dispatch.backendNodeId}`]),
87
90
  fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
88
91
  ];
89
92
  if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
@@ -97,6 +100,7 @@ export async function cmdPageType(parsed: ParsedArgs, _args: string[]): Promise<
97
100
  'backend-node-id': dispatch.backendNodeId,
98
101
  role: dispatch.role ?? undefined,
99
102
  name: dispatch.name ?? undefined,
103
+ 'hit-test-receiver-backend-node-id': dispatch.hitTestReceiverBackendNodeId,
100
104
  }
101
105
  : {},
102
106
  summary: lineList(rows),
@@ -0,0 +1,32 @@
1
+ import { invalidInput } from '../../../errors.js';
2
+ import { type ParsedArgs } from '../../types.js';
3
+ import { cmdPerfTrace } from './trace.js';
4
+ import { cmdPerfVitals } from './vitals.js';
5
+ import { cmdPerfInsights } from './insights.js';
6
+
7
+ export const COMMAND_BLOCK = `<command name="perf">
8
+ the Chrome performance-trace substrate — \`trace\` records one, \`vitals\` and \`insights\` are read-only queries over it
9
+ use for Core Web Vitals and DevTools insights over a page load or one interaction, timed from trace events
10
+ </command>`;
11
+
12
+ const HELP = `<command name="perf" description="the Chrome performance-trace substrate">
13
+ <model>\`trace\` drives (and records) the browser, one-shot or composed across intervening commands; the other leaves are cheap reads over the finalized trace artifact. Findings exit 0 — a report, not a failure; input/precondition errors exit 1. No leaf accepts --gate.</model>
14
+ <subcommand name="trace" description="record a Chrome performance trace" whenToUse="Use to record the substrate that vitals and insights read — a page load, one interaction, or a window you open and close."/>
15
+ <subcommand name="vitals" description="Core Web Vitals from a recorded trace" whenToUse="Use for LCP, INP, and CLS with their subparts and attribution."/>
16
+ <subcommand name="insights" description="the DevTools insight set from a recorded trace" whenToUse="Use for the engine's computed insights — render-blocking requests, forced reflow, layout-shift culprits, and the rest of the set."/>
17
+ </command>`;
18
+
19
+ export async function perfMain(parsed: ParsedArgs, _args: string[]): Promise<void> {
20
+ const leaf = parsed.positional[0];
21
+ const rest: ParsedArgs = { ...parsed, positional: parsed.positional.slice(1) };
22
+ switch (leaf) {
23
+ case 'trace': return cmdPerfTrace(rest);
24
+ case 'vitals': return cmdPerfVitals(rest);
25
+ case 'insights': return cmdPerfInsights(rest);
26
+ case undefined:
27
+ console.log(HELP);
28
+ return;
29
+ default:
30
+ throw invalidInput(`Unknown perf leaf: ${leaf}.`, 'unknown_command');
31
+ }
32
+ }
@@ -0,0 +1,214 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { type ParsedArgs } from '../../types.js';
4
+ import { analyzeChromeTrace, type TraceInsight, type TraceInsightField, type TraceInsightSet } from '../../trace-analysis.js';
5
+ import { resolveTraceRef } from '../../../output/artifact.js';
6
+ import { data, emitResult, fact, line, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
7
+ import { selectRecords } from '../../../output/selection.js';
8
+
9
+ const HELP = `capture perf insights <trace> [--name <insight> --full] [--limit <N>] — the DevTools insight set computed from a recorded trace
10
+
11
+ input:
12
+ <trace> trace id in the active session or an absolute trace path (required; the trace must be finalized)
13
+ --name <insight> select one engine insight by name from the default aggregation
14
+ --limit <N> return at most N selected insight records in prose and JSON; default 25
15
+ --full with --name, include a bounded related-event drill-down only when every selected event fits within 50 events and 48,000 serialized bytes; otherwise returns the bounded --name view and states the factual bound
16
+ output: <insights …> — default output is a bounded aggregation of engine insight names, scalar metrics, and related-event counts; --name adds top stack and duration-distribution facts for that insight; --name with --full preserves those facts before adding a complete bounded event drill-down, or refuses partial event detail when the selected corpus exceeds its stated bound; capture reports engine facts without converting an insight's presence into an assessment of the page; --json mirrors
17
+ effects: read-only — reads the finalized trace artifact, never drives the browser`;
18
+
19
+ function error(parsed: ParsedArgs, status: string, message: string): void {
20
+ emitResult({ tag: 'error', attrs: { command: 'perf insights', status }, summary: fact`${message}` }, { json: parsed.json });
21
+ process.exitCode = 1;
22
+ }
23
+
24
+ function relatedEventCount(fields: Readonly<Record<string, TraceInsightField>>): number {
25
+ const related = fields.relatedEvents;
26
+ if (Array.isArray(related)) return related.length;
27
+ if (related !== null && typeof related === 'object' && !Array.isArray(related)) {
28
+ const entries = related.entries;
29
+ if (Array.isArray(entries)) return entries.length;
30
+ }
31
+ return 0;
32
+ }
33
+
34
+ function scalarMetrics(fields: Readonly<Record<string, TraceInsightField>>): Record<string, number | boolean> {
35
+ return Object.fromEntries(Object.entries(fields).flatMap(([name, value]) => typeof value === 'number' || typeof value === 'boolean' ? [[name, value]] : []));
36
+ }
37
+
38
+ function aggregate(insight: TraceInsight): { name: string; relatedEventCount: number; scalarMetrics: Record<string, number | boolean> } {
39
+ return { name: insight.name, relatedEventCount: relatedEventCount(insight.fields), scalarMetrics: scalarMetrics(insight.fields) };
40
+ }
41
+
42
+ function aggregateLine(insight: TraceInsight): FactLine {
43
+ const summary = aggregate(insight);
44
+ const metrics = Object.entries(summary.scalarMetrics).map(([name, value]) => `${name}=${value}`);
45
+ return fact`${summary.name}: ${summary.relatedEventCount} related event(s); scalar metrics: ${metrics.length ? metrics.join(', ') : 'none'}.`;
46
+ }
47
+
48
+ const FULL_EVENT_LIMIT = 50;
49
+ const FULL_BYTE_LIMIT = 48_000;
50
+
51
+ type RelatedEvent = Record<string, unknown>;
52
+
53
+ interface FullFields {
54
+ scalarMetrics: Record<string, number | boolean>;
55
+ relatedEvents: { events: RelatedEvent[]; omitted: number };
56
+ }
57
+
58
+ interface FullBudget {
59
+ remainingEvents: number;
60
+ remainingBytes: number;
61
+ }
62
+
63
+ interface NamedFields {
64
+ relatedEventCount: number;
65
+ durationDistribution: string;
66
+ topStack: { label: string; count: number } | null;
67
+ }
68
+
69
+ function relatedEvents(fields: Readonly<Record<string, TraceInsightField>>): RelatedEvent[] {
70
+ const related = fields.relatedEvents;
71
+ if (Array.isArray(related)) return related.filter((event): event is RelatedEvent => event !== null && typeof event === 'object' && !Array.isArray(event));
72
+ if (related !== null && typeof related === 'object' && !Array.isArray(related) && Array.isArray(related.entries)) {
73
+ return related.entries.filter((event): event is RelatedEvent => event !== null && typeof event === 'object' && !Array.isArray(event));
74
+ }
75
+ return [];
76
+ }
77
+
78
+ function durationDistribution(events: readonly RelatedEvent[]): string {
79
+ const durations = events.map(event => event.dur).filter((duration): duration is number => typeof duration === 'number' && Number.isFinite(duration)).sort((a, b) => a - b);
80
+ if (durations.length === 0) return 'unavailable (no related event carried a finite dur)';
81
+ const at = (percentile: number) => durations[Math.min(durations.length - 1, Math.floor((durations.length - 1) * percentile))]! / 1000;
82
+ return `n=${durations.length}, min=${at(0)}ms, p50=${at(0.5)}ms, p95=${at(0.95)}ms, max=${at(1)}ms`;
83
+ }
84
+
85
+ function stackLabel(event: RelatedEvent): string | null {
86
+ const args = event.args && typeof event.args === 'object' ? event.args as { data?: unknown; beginData?: unknown } : undefined;
87
+ const data = args?.data ?? args?.beginData;
88
+ const stack = data && typeof data === 'object' ? (data as { stackTrace?: unknown; stack?: unknown }).stackTrace ?? (data as { stack?: unknown }).stack : undefined;
89
+ const frame = Array.isArray(stack) ? stack[0] : stack && typeof stack === 'object' ? stack : undefined;
90
+ if (!frame || typeof frame !== 'object') return null;
91
+ const value = frame as { functionName?: unknown; url?: unknown; lineNumber?: unknown; columnNumber?: unknown };
92
+ const name = typeof value.functionName === 'string' && value.functionName ? value.functionName : '(anonymous)';
93
+ const location = typeof value.url === 'string' ? `${value.url}:${typeof value.lineNumber === 'number' ? value.lineNumber + 1 : '?'}` : null;
94
+ return location ? `${name} at ${location}` : name;
95
+ }
96
+
97
+ function namedFields(insight: TraceInsight): NamedFields {
98
+ const events = relatedEvents(insight.fields);
99
+ const stacks = new Map<string, number>();
100
+ for (const event of events) {
101
+ const label = stackLabel(event);
102
+ if (label) stacks.set(label, (stacks.get(label) ?? 0) + 1);
103
+ }
104
+ const topStack = [...stacks.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
105
+ return {
106
+ relatedEventCount: relatedEventCount(insight.fields),
107
+ durationDistribution: durationDistribution(events),
108
+ topStack: topStack ? { label: topStack[0], count: topStack[1] } : null,
109
+ };
110
+ }
111
+
112
+ function namedRecordLine(insight: TraceInsight): FactLine {
113
+ const details = namedFields(insight);
114
+ return fact`${insight.name}: ${details.relatedEventCount} related event(s); duration distribution ${details.durationDistribution}; top stack ${details.topStack ? `${details.topStack.label} (${details.topStack.count} event(s))` : 'unavailable'}.`;
115
+ }
116
+
117
+ function fullFields(insight: TraceInsight, budget: FullBudget): FullFields {
118
+ const events = relatedEvents(insight.fields);
119
+ const retained: RelatedEvent[] = [];
120
+ for (const event of events) {
121
+ const bytes = Buffer.byteLength(JSON.stringify(event));
122
+ if (budget.remainingEvents === 0 || bytes > budget.remainingBytes) break;
123
+ retained.push(event);
124
+ budget.remainingEvents--;
125
+ budget.remainingBytes -= bytes;
126
+ }
127
+ return {
128
+ scalarMetrics: scalarMetrics(insight.fields),
129
+ relatedEvents: { events: retained, omitted: events.length - retained.length },
130
+ };
131
+ }
132
+
133
+ function fullRecordLine(insight: TraceInsight, fields: FullFields): FactLine {
134
+ return lineList([
135
+ namedRecordLine(insight),
136
+ line(fact`${insight.name}: complete related-event detail (${fields.relatedEvents.events.length} event(s)); fields: `, data(JSON.stringify(fields), FULL_BYTE_LIMIT)),
137
+ ]);
138
+ }
139
+
140
+ function completeFullFields(insight: TraceInsight): FullFields {
141
+ return {
142
+ scalarMetrics: scalarMetrics(insight.fields),
143
+ relatedEvents: { events: relatedEvents(insight.fields), omitted: 0 },
144
+ };
145
+ }
146
+
147
+ function fullDetailSize(insights: readonly TraceInsight[]): { bytes: number; largestChars: number } {
148
+ return insights.reduce(({ bytes, largestChars }, insight) => {
149
+ const serialized = JSON.stringify(completeFullFields(insight));
150
+ return { bytes: bytes + Buffer.byteLength(serialized), largestChars: Math.max(largestChars, serialized.length) };
151
+ }, { bytes: 0, largestChars: 0 });
152
+ }
153
+
154
+ function section(set: TraceInsightSet, insights: readonly TraceInsight[], full: boolean, named: boolean, fullByInsight: ReadonlyMap<TraceInsight, FullFields>): FactLine {
155
+ const records = insights.length ? insights.map(insight => full ? fullRecordLine(insight, fullByInsight.get(insight)!) : named ? namedRecordLine(insight) : aggregateLine(insight)) : [text`No engine records matched the requested insight name.`];
156
+ return lineList([fact`Insight set ${set.id} for ${set.url}`, ...records]);
157
+ }
158
+
159
+ function noInsightSet(events: readonly object[], completion: string): { section: FactLine; json: Record<string, string | number> } {
160
+ const navigations = events.filter((event) => (event as { name?: unknown }).name === 'navigationStart').length;
161
+ const recording = completion === 'complete'
162
+ ? `The trace was recorded successfully and finalized (completion=${completion}).`
163
+ : `The trace artifact was finalized with completion=${completion}.`;
164
+ return navigations === 0
165
+ ? { section: fact`${recording} This trace-engine analysis emitted 0 navigation-scoped insight set(s) because the trace contains no navigation. That engine scope does not describe the other performance evidence recorded in trace.json.`, json: { status: 'no-insight-set', navigations, 'trace-recording': `finalized:${completion}`, 'engine-analysis-scope': 'navigation-scoped-insight-sets', 'navigation-insight-sets': 0, reason: 'no-navigation' } }
166
+ : { section: text`No DevTools insight set was produced for the ${navigations} recorded navigation(s), so this recording cannot yield DevTools insights.`, json: { status: 'no-insight-set', navigations, reason: 'engine-produced-no-insight-set' } };
167
+ }
168
+
169
+ export async function cmdPerfInsights(parsed: ParsedArgs): Promise<void> {
170
+ if (parsed.help) { console.log(HELP); return; }
171
+ const input = parsed.positional[0];
172
+ if (!input) return error(parsed, 'invalid_target', 'perf insights requires one finalized trace id or absolute trace path.');
173
+ try {
174
+ const trace = resolveTraceRef(input);
175
+ const raw = JSON.parse(fs.readFileSync(path.join(trace.dir, 'trace.json'), 'utf8')) as { traceEvents?: object[] };
176
+ if (!Array.isArray(raw.traceEvents)) throw new Error('trace.json is not a Chrome trace with a traceEvents array');
177
+ const analysis = await analyzeChromeTrace({ traceEvents: raw.traceEvents });
178
+ let remaining = parsed.limit ?? 25;
179
+ const selected = analysis.insightSets.map((set) => {
180
+ const filtered = parsed.name === undefined ? set.insights : set.insights.filter(insight => insight.name === parsed.name);
181
+ const insights = selectRecords(filtered, { limit: remaining }, remaining);
182
+ remaining -= insights.length;
183
+ return { ...set, insights };
184
+ });
185
+ const selectedInsights = selected.flatMap((set) => set.insights);
186
+ const relatedEventsTotal = selectedInsights.reduce((count, insight) => count + relatedEvents(insight.fields).length, 0);
187
+ const detailSize = fullDetailSize(selectedInsights);
188
+ const fullRefused = parsed.full && (relatedEventsTotal > FULL_EVENT_LIMIT || detailSize.bytes > FULL_BYTE_LIMIT || detailSize.largestChars > FULL_BYTE_LIMIT);
189
+ const fullBudget: FullBudget = { remainingEvents: FULL_EVENT_LIMIT, remainingBytes: FULL_BYTE_LIMIT };
190
+ const fullByInsight = new Map<TraceInsight, FullFields>();
191
+ if (parsed.full && !fullRefused) {
192
+ for (const set of selected) {
193
+ for (const insight of set.insights) fullByInsight.set(insight, fullFields(insight, fullBudget));
194
+ }
195
+ }
196
+ const relatedEventsRetained = [...fullByInsight.values()].reduce((total, fields) => total + fields.relatedEvents.events.length, 0);
197
+ const noSet = selected.length === 0 ? noInsightSet(raw.traceEvents, trace.completion) : undefined;
198
+ const result: RenderableResult = {
199
+ tag: 'insights',
200
+ attestation: { kind: 'trace', id: trace.id, path: trace.dir },
201
+ attrs: { completion: trace.completion, ...(trace.reason ? { reason: trace.reason } : {}), engine: analysis.source.engine, 'insight-sets': selected.length, insights: selected.reduce((count, set) => count + set.insights.length, 0), limit: parsed.limit ?? 25, ...(parsed.name ? { name: parsed.name } : {}), ...(parsed.full && !fullRefused ? { detail: 'full', 'events-retained': relatedEventsRetained, 'events-omitted': relatedEventsTotal - relatedEventsRetained, 'event-bytes-retained': FULL_BYTE_LIMIT - fullBudget.remainingBytes } : parsed.full ? { detail: 'aggregation', full: 'refused', 'full-reason': 'selected-event-corpus-exceeds-bound', 'related-events': relatedEventsTotal, 'related-event-bytes': detailSize.bytes } : { detail: 'aggregation' }) },
202
+ summary: parsed.full && !fullRefused
203
+ ? fact`The selected related-event drill-down contains every selected event within the ${FULL_EVENT_LIMIT}-event/${FULL_BYTE_LIMIT}-byte bound; each record begins with its duration-distribution and top-stack facts.`
204
+ : parsed.full
205
+ ? fact`The selected related-event corpus has ${relatedEventsTotal} event(s) and ${detailSize.bytes} serialized detail bytes, exceeding the ${FULL_EVENT_LIMIT}-event/${FULL_BYTE_LIMIT}-byte full-detail bound; this result preserves the bounded --name aggregation rather than emitting a partial event corpus.`
206
+ : parsed.name
207
+ ? fact`The selected insight reports its related-event count, duration distribution, and most frequent reported stack without emitting the event corpus.`
208
+ : fact`Insight names, scalar metrics, and related-event counts are computed by the DevTools trace engine (${analysis.source.engine}, an unstable upstream API pinned by capture). Use \`--name <insight>\` for stack and duration facts, or add \`--full\` for a bounded related-event drill-down.`,
209
+ sections: selected.length ? selected.map(set => section(set, set.insights, parsed.full === true && !fullRefused, parsed.name !== undefined, fullByInsight)) : [noSet!.section],
210
+ jsonSections: selected.length ? selected.map(set => ({ set: set.id, url: set.url, insights: parsed.full && !fullRefused ? set.insights.map(insight => ({ ...aggregate(insight), ...namedFields(insight), fields: fullByInsight.get(insight)! })) : parsed.name ? set.insights.map(insight => ({ ...aggregate(insight), ...namedFields(insight) })) : set.insights.map(aggregate) })) : [noSet!.json],
211
+ };
212
+ emitResult(result, { json: parsed.json });
213
+ } catch (cause) { error(parsed, 'artifact_unavailable', cause instanceof Error ? cause.message : String(cause)); }
214
+ }