@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.
- package/README.md +178 -0
- package/bin/capture +31278 -7312
- package/package.json +3 -1
- package/src/capture.ts +21 -18
- package/src/cdp/args.ts +269 -49
- package/src/cdp/bridge/server.ts +6 -5
- package/src/cdp/bridge/spawn.ts +41 -97
- package/src/cdp/commands/bridge-serve.ts +11 -21
- package/src/cdp/commands/cdp.ts +44 -22
- package/src/cdp/commands/heap/census.ts +61 -0
- package/src/cdp/commands/heap/common.ts +77 -0
- package/src/cdp/commands/heap/diff.ts +92 -0
- package/src/cdp/commands/heap/index.ts +38 -0
- package/src/cdp/commands/heap/objects.ts +42 -0
- package/src/cdp/commands/heap/retainers.ts +52 -0
- package/src/cdp/commands/heap/snapshot.ts +94 -0
- package/src/cdp/commands/lib.ts +9 -17
- package/src/cdp/commands/lighthouse.ts +414 -0
- package/src/cdp/commands/measure/census.ts +2 -1
- package/src/cdp/commands/measure/check.ts +70 -16
- package/src/cdp/commands/measure/diff.ts +1 -2
- package/src/cdp/commands/measure/explain.ts +44 -7
- package/src/cdp/commands/measure/index.ts +24 -32
- package/src/cdp/commands/measure/map-ax.ts +2 -1
- package/src/cdp/commands/measure/map-focus.ts +2 -1
- package/src/cdp/commands/measure/map-layers.ts +5 -3
- package/src/cdp/commands/measure/map-scroll.ts +2 -1
- package/src/cdp/commands/measure/snap.ts +5 -4
- package/src/cdp/commands/measure/sweep.ts +16 -11
- package/src/cdp/commands/measure/text.ts +147 -0
- package/src/cdp/commands/motion/index.ts +10 -18
- package/src/cdp/commands/motion/jank.ts +13 -22
- package/src/cdp/commands/motion/mask.ts +10 -9
- package/src/cdp/commands/motion/rec.ts +228 -26
- package/src/cdp/commands/motion/timeline.ts +1 -1
- package/src/cdp/commands/page/click.ts +15 -5
- package/src/cdp/commands/page/elements.ts +43 -11
- package/src/cdp/commands/page/exec.ts +53 -18
- package/src/cdp/commands/page/index.ts +20 -20
- package/src/cdp/commands/page/inspect.ts +338 -0
- package/src/cdp/commands/page/navigate.ts +83 -38
- package/src/cdp/commands/page/repeat.ts +176 -0
- package/src/cdp/commands/page/scroll.ts +10 -5
- package/src/cdp/commands/page/shot.ts +66 -31
- package/src/cdp/commands/page/type.ts +6 -2
- package/src/cdp/commands/perf/index.ts +32 -0
- package/src/cdp/commands/perf/insights.ts +214 -0
- package/src/cdp/commands/perf/trace.ts +235 -0
- package/src/cdp/commands/perf/vitals.ts +76 -0
- package/src/cdp/commands/session/collectors.ts +54 -0
- package/src/cdp/commands/stub.ts +10 -0
- package/src/cdp/commands/tab/close.ts +2 -2
- package/src/cdp/commands/tab/index.ts +16 -23
- package/src/cdp/commands/tab/launch.ts +1 -1
- package/src/cdp/commands/tab/mock/index.ts +24 -0
- package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
- package/src/cdp/commands/tab/mock/start.ts +65 -0
- package/src/cdp/commands/tab/mock/stop.ts +39 -0
- package/src/cdp/commands/tab/network.ts +22 -13
- package/src/cdp/commands/tab/reset.ts +11 -12
- package/src/cdp/connection.ts +19 -39
- package/src/cdp/detect.ts +27 -7
- package/src/cdp/dispatch.ts +25 -4
- package/src/cdp/domains.ts +2 -2
- package/src/cdp/har-recorder.ts +0 -1
- package/src/cdp/heap-snapshot.ts +647 -0
- package/src/cdp/host/client.ts +22 -0
- package/src/cdp/host/collector.ts +74 -0
- package/src/cdp/host/collectors/heap.ts +79 -0
- package/src/cdp/host/collectors/intercept.ts +247 -0
- package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
- package/src/cdp/host/collectors/trace.ts +108 -0
- package/src/cdp/host/core.ts +217 -0
- package/src/cdp/host/handle.ts +47 -0
- package/src/cdp/host/held-client.ts +91 -0
- package/src/cdp/host/kinds.ts +97 -0
- package/src/cdp/host/lifecycle.ts +123 -0
- package/src/cdp/host/server.ts +88 -0
- package/src/cdp/measure/check.ts +181 -16
- package/src/cdp/measure/collectors/hittest.ts +16 -10
- package/src/cdp/measure/collectors/scroll.ts +15 -7
- package/src/cdp/measure/collectors/style-provenance.ts +11 -0
- package/src/cdp/measure/collectors/styles.ts +4 -4
- package/src/cdp/measure/explain.ts +14 -2
- package/src/cdp/measure/map-layers.ts +52 -8
- package/src/cdp/measure/snapshot.ts +5 -3
- package/src/cdp/measure/sweep.ts +29 -6
- package/src/cdp/measure/text.ts +227 -0
- package/src/cdp/motion/jank.ts +29 -83
- package/src/cdp/motion/mask.ts +18 -9
- package/src/cdp/motion/recorder.ts +86 -731
- package/src/cdp/scope-lock.ts +9 -9
- package/src/cdp/screenshot.ts +51 -21
- package/src/cdp/timing.ts +5 -5
- package/src/cdp/trace-analysis.ts +320 -0
- package/src/cdp/types.ts +31 -1
- package/src/cdp.ts +4 -6
- package/src/har-manager.ts +2 -4
- package/src/interact.ts +50 -23
- package/src/output/artifact.ts +49 -5
- package/src/output/render.ts +60 -9
- package/src/output/selection.ts +6 -0
- package/src/session/artifacts.ts +122 -12
- package/src/session/commands.ts +348 -113
- package/src/session/coordinator.ts +0 -93
- package/src/session/har-redact.ts +29 -23
- package/src/session-context.ts +13 -9
- package/src/cdp/bridge/protocol.ts +0 -224
- package/src/cdp/recorder-client.ts +0 -326
- 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 {
|
|
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
|
|
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;
|
|
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
|
|
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
|
-
?
|
|
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
|
|
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
|
+
}
|