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