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