@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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { ArtifactResolutionError, resolveSnapRef } from '../../../output/artifact.js';
|
|
2
|
+
import { data, emitResult, fact, formatCoordinate, line, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
|
|
3
|
+
import { type ParsedArgs } from '../../types.js';
|
|
4
|
+
import { measureTextSnapshot, writeTextCrop, type TextMeasurement } from '../../measure/text.js';
|
|
5
|
+
|
|
6
|
+
const USAGE = `capture measure text <snap> --selector <target> — direct DOM content text, font, recorded color-model contrast, and provenance from one settled snapshot
|
|
7
|
+
|
|
8
|
+
input:
|
|
9
|
+
<snap> snapshot id in the active session or absolute artifact path (required)
|
|
10
|
+
--selector <target> one recorded CSS/text/AX selector input or backend:<id> (required)
|
|
11
|
+
--crop write a measured selector crop artifact; its sessionless bundle is rooted by --artifact-dir
|
|
12
|
+
--artifact-dir <path> root for the sessionless crop bundle; ignored when an active session owns the crop
|
|
13
|
+
output: <text selector=… contrast=…> — one target's direct DOM content text, font metrics, recorded foreground/background color model, contrast ratio, and recorded style provenance
|
|
14
|
+
effects: reads an existing snapshot and never drives the browser; --crop maps recorded CSS geometry through queries.json's CSS viewport and writes one PNG under the active session or, sessionless, a one-shot bundle under --artifact-dir`;
|
|
15
|
+
|
|
16
|
+
function attestation(measurement: TextMeasurement) {
|
|
17
|
+
return {
|
|
18
|
+
kind: 'snapshot' as const,
|
|
19
|
+
id: measurement.ref.id,
|
|
20
|
+
path: measurement.ref.dir,
|
|
21
|
+
note: measurement.meta.settled === false
|
|
22
|
+
? text`Snapshot was captured unsettled; text facts intersecting an unstable region may vary on re-capture.`
|
|
23
|
+
: measurement.meta.settled === true
|
|
24
|
+
? fact`Snapshot settled${measurement.meta.settleMs === undefined ? '' : ` after ${measurement.meta.settleMs}ms`}.`
|
|
25
|
+
: text`Snapshot settledness was not recorded.`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function textFacts(measurement: TextMeasurement): FactLine[] {
|
|
30
|
+
if (!measurement.textAvailable) return [fact`Text collection was unavailable: ${measurement.textUnavailableReason ?? 'no reason was recorded'}; no direct DOM content text, text metrics, or font facts are available.`];
|
|
31
|
+
const record = measurement.text;
|
|
32
|
+
if (!record) return [text`No text-layout record joined to the selected geometry record by backend node id or geometry element id.`];
|
|
33
|
+
const font = record.font ?? {};
|
|
34
|
+
const lines = record.lines ?? [];
|
|
35
|
+
return [
|
|
36
|
+
line(text`Direct DOM content text: `, data(record.text ?? '(not recorded)', Number.MAX_SAFE_INTEGER)),
|
|
37
|
+
text`This is raw text from the target's direct DOM text nodes; CSS text transforms and generated ::before/::after content are not represented in this text value.`,
|
|
38
|
+
fact`Text metrics: chars=${record.textLength ?? (record.text?.length ?? 0)}; lines=${record.lineCount ?? lines.length}; truncated=${String(record.truncated ?? false)}; truncation-style=${record.truncationStyle ?? 'none'}; scroll-width=${record.scrollWidth ?? 0}; client-width=${record.clientWidth ?? 0}.`,
|
|
39
|
+
fact`Font metrics: family=${font.family ?? 'unknown'}; size=${font.size ?? 'unknown'}; weight=${font.weight ?? 'unknown'}; line-height=${font.lineHeight ?? 'unknown'}; writing-mode=${record.writingMode ?? 'unknown'}; direction=${record.direction ?? 'unknown'}; fallback-used=${String(record.fallbackUsed ?? 'unknown')}.`,
|
|
40
|
+
fact`Text-line collection: ${lines.length} recorded line box(es); platform-fonts-available=${String(record.platformFontsAvailable ?? false)}; platform-families=${(record.platformFonts ?? []).map((entry) => entry.familyName ?? 'unknown').join(', ') || 'none recorded'}.`,
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function contrastFacts(measurement: TextMeasurement): FactLine[] {
|
|
45
|
+
const contrast = measurement.contrast;
|
|
46
|
+
if (!contrast.available) return [fact`Foreground/background color model and contrast ratio were not resolved: ${contrast.uncertainty}.`];
|
|
47
|
+
return [
|
|
48
|
+
fact`Foreground in the recorded color model: rgb(${contrast.foreground.join(', ')}).`,
|
|
49
|
+
fact`Background in the recorded color model: rgb(${contrast.background.join(', ')}); opaque background-color source=${contrast.backgroundSource}.`,
|
|
50
|
+
fact`Contrast ratio in the recorded color model: ${contrast.ratio.toFixed(2)}:1.`,
|
|
51
|
+
fact`Color-model provenance: ${contrast.provenance}. The model composites recorded computed color, background-color, and geometry opacity; background images, gradients, and other paint are not recorded.`,
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function styleFacts(measurement: TextMeasurement): FactLine[] {
|
|
56
|
+
const facts: FactLine[] = measurement.styles.map((style) => fact`Style winner for ${style.element}: ${style.property}=${style.value}; selector ${style.selector}; specificity ${style.specificity}; source ${style.source}.${style.uncertainty ? ` Cascade uncertainty: ${style.uncertainty}.` : ''}`);
|
|
57
|
+
if (!facts.length) facts.push(text`No recorded winning declaration provenance for the selected color or background-color paint chain.`);
|
|
58
|
+
if (measurement.styleProvenanceUnavailableFor.length) facts.push(fact`Winning-declaration provenance was unavailable for: ${measurement.styleProvenanceUnavailableFor.join(', ')}.`);
|
|
59
|
+
return facts;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function invalidInput(message: FactLine): RenderableResult {
|
|
63
|
+
return {
|
|
64
|
+
tag: 'error',
|
|
65
|
+
attrs: { command: 'measure text', status: 'invalid_input' },
|
|
66
|
+
summary: message,
|
|
67
|
+
followUp: text`Run capture measure text <snap> --selector <CSS|backend:id|axid:id|ax:name|text:value>.`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function cmdMeasureText(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
72
|
+
if (parsed.help) { console.log(USAGE); return; }
|
|
73
|
+
if (parsed.positional.length !== 1) {
|
|
74
|
+
emitResult(invalidInput(fact`Expected exactly one snapshot target; received ${parsed.positional.length}.`), { json: parsed.json });
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!parsed.selector?.trim()) {
|
|
79
|
+
emitResult(invalidInput(text`The --selector flag is required.`), { json: parsed.json });
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const ref = await resolveSnapRef(parsed.positional[0]!);
|
|
85
|
+
const report = measureTextSnapshot(ref, parsed.selector);
|
|
86
|
+
if (report.kind === 'missing-selector') {
|
|
87
|
+
emitResult({
|
|
88
|
+
tag: 'error',
|
|
89
|
+
attrs: { command: 'measure text', status: 'missing_selector', selector: report.selector },
|
|
90
|
+
summary: fact`No geometry element matched selector input ${report.selector}.`,
|
|
91
|
+
sections: [lineList(report.available.candidates.map((candidate, index) => fact`${index + 1}. ${candidate}`))],
|
|
92
|
+
followUp: text`Use an exact recorded selector, backend id, AX identity, or text identity from this snapshot.`,
|
|
93
|
+
}, { json: parsed.json });
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (report.kind === 'ambiguous-selector') {
|
|
98
|
+
emitResult({
|
|
99
|
+
tag: 'error',
|
|
100
|
+
attrs: { command: 'measure text', status: 'ambiguous_selector', selector: report.selector, matches: report.matchCount, displayed: report.candidates.length },
|
|
101
|
+
summary: fact`Selector input ${report.selector} matched ${report.matchCount} recorded elements; no text target was selected.`,
|
|
102
|
+
sections: [lineList(report.candidates.map((candidate, index) => fact`${index + 1}. ${candidate.selector ?? candidate.id}${typeof candidate.backendNodeId === 'number' ? ` — backend:${candidate.backendNodeId}` : ''}`))],
|
|
103
|
+
followUp: text`Retry with an exact CSS selector, axid, or numeric backend id from the candidate identity facts.`,
|
|
104
|
+
}, { json: parsed.json });
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const crop = parsed.textCrop ? writeTextCrop(report, parsed.artifactDir) : undefined;
|
|
109
|
+
const target = report.element.selector ?? report.element.id;
|
|
110
|
+
const result: RenderableResult = {
|
|
111
|
+
tag: 'text',
|
|
112
|
+
attestation: attestation(report),
|
|
113
|
+
attrs: {
|
|
114
|
+
selector: target,
|
|
115
|
+
matches: report.matchCount,
|
|
116
|
+
...(typeof report.element.backendNodeId === 'number' ? { backend: report.element.backendNodeId } : {}),
|
|
117
|
+
settled: report.meta.settled ?? 'unknown',
|
|
118
|
+
contrast: report.contrast.available ? `${report.contrast.ratio.toFixed(2)}:1` : 'unresolved',
|
|
119
|
+
...(crop ? { crop: crop.path } : {}),
|
|
120
|
+
},
|
|
121
|
+
summary: report.textAvailable
|
|
122
|
+
? report.contrast.available
|
|
123
|
+
? fact`Recorded direct-DOM-content text, font, color-model contrast, and style-provenance facts for ${target} from one snapshot.`
|
|
124
|
+
: fact`Recorded direct-DOM-content text, font, and style-provenance facts for ${target}; the recorded color model did not resolve a contrast ratio.`
|
|
125
|
+
: report.contrast.available
|
|
126
|
+
? fact`Recorded color-model contrast and style-provenance facts for ${target}; text collection was unavailable (${report.textUnavailableReason ?? 'no reason was recorded'}).`
|
|
127
|
+
: fact`Recorded style-provenance facts for ${target}; text collection was unavailable (${report.textUnavailableReason ?? 'no reason was recorded'}) and the recorded color model did not resolve a contrast ratio.`,
|
|
128
|
+
sections: [
|
|
129
|
+
...(report.element.rect && report.element.rect.width !== undefined && report.element.rect.height !== undefined ? [line(text`Target geometry: `, formatCoordinate({ x: report.element.rect.x, y: report.element.rect.y, w: report.element.rect.width, h: report.element.rect.height }))] : [text`Target geometry was not recorded.`]),
|
|
130
|
+
...textFacts(report),
|
|
131
|
+
...contrastFacts(report),
|
|
132
|
+
...styleFacts(report),
|
|
133
|
+
...(crop ? [fact`Crop artifact: ${crop.path}; requested CSS geometry x=${crop.requested.x} y=${crop.requested.y} w=${crop.requested.w} h=${crop.requested.h}; delivered raster geometry x=${crop.delivered.x} y=${crop.delivered.y} w=${crop.delivered.w} h=${crop.delivered.h}; CSS-to-raster scale x=${crop.scale.x} y=${crop.scale.y}, derived from queries.json's recorded CSS viewport.`] : []),
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
emitResult(result, { json: parsed.json });
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const detail = error instanceof ArtifactResolutionError || error instanceof Error ? error.message : String(error);
|
|
139
|
+
emitResult({
|
|
140
|
+
tag: 'error',
|
|
141
|
+
attrs: { command: 'measure text', status: error instanceof ArtifactResolutionError ? 'artifact_unavailable' : 'text_unavailable' },
|
|
142
|
+
summary: fact`The requested text measurement could not be read: ${detail}`,
|
|
143
|
+
followUp: text`Create a settled snapshot with capture measure snap <url>, then pass its id or absolute path.`,
|
|
144
|
+
}, { json: parsed.json });
|
|
145
|
+
process.exitCode = 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -16,26 +16,18 @@ import { cmdMotionResponse } from './response.js';
|
|
|
16
16
|
|
|
17
17
|
/** Root-help representation of this branch, assembled by `src/capture.ts`. */
|
|
18
18
|
export const COMMAND_BLOCK = `<command name="motion">
|
|
19
|
-
|
|
20
|
-
use
|
|
21
|
-
rec · mask · timeline · jank · response — \`capture motion -h\`
|
|
19
|
+
the screencast-recording substrate — what changed on screen across frames; \`rec\` writes the recording, every other leaf is a read-only query over it
|
|
20
|
+
use for per-element frame geometry, changed regions, and input-to-settle timing; use \`perf\` instead for trace-event timing
|
|
22
21
|
</command>`;
|
|
23
22
|
|
|
24
|
-
export const MOTION_USAGE =
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
input
|
|
31
|
-
|
|
32
|
-
<subcommand name="rec" args="[url] --do <action> [--duration <seconds>] | --start | --stop" whenToUse="record an interaction — one-shot action, or composed across commands with --start/--stop (needs an active session)"/>
|
|
33
|
-
<subcommand name="mask" args="<rec> [--limit <N>]" whenToUse="motion-diff composite image + per-region facts"/>
|
|
34
|
-
<subcommand name="timeline" args="<rec> --element <sel> [--prop <prop>]" whenToUse="per-frame geometry/scroll/property timeline for one element"/>
|
|
35
|
-
<subcommand name="jank" args="<rec>" whenToUse="dropped-frame/long-task/layout-shift facts"/>
|
|
36
|
-
<subcommand name="response" args="<rec> [--action <action>] [--occurrence <n>]" whenToUse="input-to-settled response timeline"/>
|
|
37
|
-
|
|
38
|
-
capture motion <leaf> -h Per-leaf usage`;
|
|
23
|
+
export const MOTION_USAGE = `<command name="motion" description="recording lifecycle and read-only queries over a finalized recording">
|
|
24
|
+
<model>\`rec\` drives and records the browser, one-shot or composed across intervening commands; every other leaf below reads the finalized recording artifact. Findings exit 0 — a report, not a failure; input and precondition errors exit 1. No leaf accepts --gate.</model>
|
|
25
|
+
<subcommand name="rec" description="interaction recording" whenToUse="Use to record an interaction in one command or across a held session."/>
|
|
26
|
+
<subcommand name="mask" description="motion-difference image and regions" whenToUse="Use to inspect where a recording changed across frames."/>
|
|
27
|
+
<subcommand name="timeline" description="element timeline" whenToUse="Use to inspect one element's geometry, scroll, or property values over time."/>
|
|
28
|
+
<subcommand name="jank" description="long-task and layout-shift facts" whenToUse="Use to inspect rendering interruption and layout instability in a recording."/>
|
|
29
|
+
<subcommand name="response" description="input-to-settled timeline" whenToUse="Use to measure the response from one recorded input to settled rendering."/>
|
|
30
|
+
</command>`;
|
|
39
31
|
|
|
40
32
|
export async function motionMain(parsed: ParsedArgs, args: string[]): Promise<void> {
|
|
41
33
|
const leaf = parsed.positional[0];
|
|
@@ -3,11 +3,11 @@ import { resolveRecRef } from '../../../output/artifact.js';
|
|
|
3
3
|
import { emitResult, fact, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
|
|
4
4
|
import { readMotionJank, type ArtifactLossFact, type LayoutShiftFact, type LayoutShiftRectFact, type LongTaskFact } from '../../motion/jank.js';
|
|
5
5
|
|
|
6
|
-
const USAGE = `capture motion jank <rec> —
|
|
6
|
+
const USAGE = `capture motion jank <rec> — long-task-record and layout-shift facts over a finalized recording
|
|
7
7
|
|
|
8
8
|
input:
|
|
9
9
|
<rec> recording id in the active session or an absolute recording path (required; the recording must be finalized)
|
|
10
|
-
output: <jank …> —
|
|
10
|
+
output: <jank …> — long-task-record and layout-shift facts. Its PerformanceObserver records carry no call-stack, handler, or script/style/layout/paint attribution; \`capture perf trace\`/\`capture perf insights\` own trace-engine attribution when a trace records it. Screencast frames are change-driven, not a dropped-frame measurement; use \`capture perf trace\` for frame-timing questions. Observer and screencast timing is recorder-relative performance.now(), trace timing recorder-relative only when an explicit trace/performance baseline was retained; --json mirrors
|
|
11
11
|
effects: read-only — reads the finalized recording artifact, never drives the browser`;
|
|
12
12
|
|
|
13
13
|
export async function cmdMotionJank(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
@@ -25,15 +25,9 @@ export async function cmdMotionJank(parsed: ParsedArgs, _args: string[]): Promis
|
|
|
25
25
|
const state = typeof meta.state === 'string' ? meta.state : 'unknown';
|
|
26
26
|
const sections: FactLine[] = [fact`${analysis.timingNote}`];
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
)));
|
|
32
|
-
} else {
|
|
33
|
-
sections.push(fact`Dropped-frame intervals: 0; ${analysis.frameCount} timestamped rect sample(s), cadence ${analysis.cadenceMs?.toFixed(2) ?? 'unavailable'}ms.`);
|
|
34
|
-
}
|
|
35
|
-
if (analysis.missingFrameSampleCount > 0) sections.push(fact`${analysis.missingFrameSampleCount} screencast frame(s) have no rect timestamp sample; dropped-frame count is incomplete.`);
|
|
36
|
-
if (analysis.cadenceMs === null) sections.push(text`Fewer than two positive frame intervals were retained; nominal cadence is unavailable and the dropped-frame count is incomplete.`);
|
|
28
|
+
sections.push(fact`PerformanceObserver records carry no call-stack, handler, or script/style/layout/paint attribution. Trace-engine attribution belongs to capture perf trace and capture perf insights when a trace records it.`);
|
|
29
|
+
sections.push(fact`Page.screencastFrame is change-driven; its ${analysis.frameCount} timestamped rect sample(s) do not measure page dropped frames.`);
|
|
30
|
+
if (analysis.missingFrameSampleCount > 0) sections.push(fact`${analysis.missingFrameSampleCount} screencast frame(s) have no rect timestamp sample.`);
|
|
37
31
|
|
|
38
32
|
if (analysis.longTasks.length) {
|
|
39
33
|
sections.push(lineList(analysis.longTasks.map((task, index) => formatLongTask(task, index))));
|
|
@@ -47,6 +41,10 @@ export async function cmdMotionJank(parsed: ParsedArgs, _args: string[]): Promis
|
|
|
47
41
|
sections.push(text`Layout-shift records: 0.`);
|
|
48
42
|
}
|
|
49
43
|
|
|
44
|
+
if (analysis.preRecordingObserverEntriesExcluded > 0) {
|
|
45
|
+
sections.push(fact`${analysis.preRecordingObserverEntriesExcluded} buffered PerformanceObserver entr${analysis.preRecordingObserverEntriesExcluded === 1 ? 'y was' : 'ies were'} excluded because ${analysis.preRecordingObserverEntriesExcluded === 1 ? 'it predates' : 'they predate'} the recorder arm baseline.`);
|
|
46
|
+
}
|
|
47
|
+
|
|
50
48
|
if (analysis.artifactLoss.length) {
|
|
51
49
|
sections.push(lineList(analysis.artifactLoss.map(formatArtifactLoss)));
|
|
52
50
|
}
|
|
@@ -62,15 +60,15 @@ export async function cmdMotionJank(parsed: ParsedArgs, _args: string[]): Promis
|
|
|
62
60
|
attrs: {
|
|
63
61
|
state,
|
|
64
62
|
frames: analysis.frameCount,
|
|
65
|
-
'dropped-frames': analysis.droppedFrameCount,
|
|
66
|
-
'dropped-frames-incomplete': analysis.droppedFramesIncomplete,
|
|
67
63
|
'long-task-records': analysis.longTasks.length,
|
|
68
64
|
'long-task-records-incomplete': analysis.longTasksIncomplete,
|
|
69
65
|
'layout-shift-records': analysis.layoutShifts.length,
|
|
70
66
|
'layout-shift-records-incomplete': analysis.layoutShiftsIncomplete,
|
|
67
|
+
'pre-recording-observer-entries-excluded': analysis.preRecordingObserverEntriesExcluded,
|
|
71
68
|
'timestamp-uncertainty': analysis.frameTimestampUncertainty,
|
|
69
|
+
'observer-attribution': 'no-call-stack-handler-or-script-style-layout-paint-attribution',
|
|
72
70
|
},
|
|
73
|
-
summary: fact`${analysis.
|
|
71
|
+
summary: fact`${analysis.longTasks.length} long-task record(s)${analysis.longTasksIncomplete ? ' (incomplete)' : ''}, ${analysis.layoutShifts.length} layout-shift record(s)${analysis.layoutShiftsIncomplete ? ' (incomplete)' : ''}; ${analysis.preRecordingObserverEntriesExcluded} pre-recording observer entr${analysis.preRecordingObserverEntriesExcluded === 1 ? 'y' : 'ies'} excluded.`,
|
|
74
72
|
sections,
|
|
75
73
|
};
|
|
76
74
|
emitResult(result, { json: parsed.json });
|
|
@@ -86,14 +84,7 @@ function formatLongTask(task: LongTaskFact, index: number): FactLine {
|
|
|
86
84
|
: task.timingDomain === 'trace-relative-first-event'
|
|
87
85
|
? `t=${task.startMs!.toFixed(2)}→${task.endMs!.toFixed(2)}ms relative to first trace event`
|
|
88
86
|
: 'timing unavailable after navigation gap';
|
|
89
|
-
|
|
90
|
-
? task.timingDomain === 'unavailable'
|
|
91
|
-
? '; dropped-frame overlap unavailable after navigation gap'
|
|
92
|
-
: '; dropped-frame overlap unavailable across trace-relative and recorder-relative timing domains'
|
|
93
|
-
: task.overlapsDroppedFrames.length
|
|
94
|
-
? `, overlaps dropped-frame interval(s) ending at frame ${task.overlapsDroppedFrames.join(', ')}`
|
|
95
|
-
: '';
|
|
96
|
-
return fact`long-task record ${index + 1} (${task.source}): ${timingText}, duration ${task.durationMs.toFixed(2)}ms${overlapText}.`;
|
|
87
|
+
return fact`long-task record ${index + 1} (${task.source}): ${timingText}, duration ${task.durationMs.toFixed(2)}ms.`;
|
|
97
88
|
}
|
|
98
89
|
|
|
99
90
|
function formatLayoutShift(shift: LayoutShiftFact, index: number): FactLine {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type ParsedArgs } from '../../types.js';
|
|
2
|
+
import { selectRecords } from '../../../output/selection.js';
|
|
2
3
|
import { createMotionMask } from '../../motion/mask.js';
|
|
3
4
|
import { readMeta, resolveRecRef } from '../../../output/artifact.js';
|
|
4
|
-
import { emitResult, fact, formatArtifactList, line, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
|
|
5
|
+
import { capped, emitResult, fact, formatArtifactList, line, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
|
|
5
6
|
|
|
6
7
|
const DEFAULT_REGION_LIMIT = 20;
|
|
7
8
|
|
|
@@ -9,7 +10,7 @@ const USAGE = `capture motion mask <rec> — motion-diff composite image plus pe
|
|
|
9
10
|
|
|
10
11
|
input:
|
|
11
12
|
<rec> recording id in the active session or an absolute recording path (required; the recording must be finalized)
|
|
12
|
-
--limit <N>
|
|
13
|
+
--limit <N> return at most N size-sorted region rows in prose and JSON (default: ${DEFAULT_REGION_LIMIT})
|
|
13
14
|
output: <motion-mask …> — the composite image path plus per-region area, distance, velocity, and element attribution where recorded rects overlap; frame-derived times carry ±1-frame uncertainty; --json mirrors
|
|
14
15
|
effects: reads the finalized recording artifact, never drives the browser; writes motion-mask.png inside the recording directory`;
|
|
15
16
|
|
|
@@ -36,10 +37,8 @@ export async function cmdMotionMask(parsed: ParsedArgs, _args: string[]): Promis
|
|
|
36
37
|
return emitCommandError(parsed, 'recording_not_finalized', `Recording ${ref.id} has state ${state}; finalize it with \`capture motion rec --stop\` before creating a mask.`);
|
|
37
38
|
}
|
|
38
39
|
const mask = createMotionMask(ref);
|
|
39
|
-
// A recording can have many isolated changed-pixel components. Limit the
|
|
40
|
-
// human/agent prose view, while JSON keeps the complete size-sorted list.
|
|
41
40
|
const proseLimit = parsed.limit ?? DEFAULT_REGION_LIMIT;
|
|
42
|
-
const reportedRegions =
|
|
41
|
+
const reportedRegions = selectRecords(mask.regions, parsed, proseLimit);
|
|
43
42
|
const regionLines = reportedRegions.length
|
|
44
43
|
? reportedRegions.map((region) => formatRegion(region))
|
|
45
44
|
: [text`No changed pixels were measured across the recorded frame pairs.`];
|
|
@@ -63,8 +62,8 @@ export async function cmdMotionMask(parsed: ParsedArgs, _args: string[]): Promis
|
|
|
63
62
|
artifacts: formatArtifactList([{ name: 'motion-mask.png', note: 'motion-diff composite' }]),
|
|
64
63
|
sections: [
|
|
65
64
|
...(mask.caveat ? [line(text`${mask.caveat}`)] : []),
|
|
66
|
-
...(mask.regions.length >
|
|
67
|
-
? [fact`Showing ${reportedRegions.length} of ${mask.regions.length} size-sorted changed-pixel regions;
|
|
65
|
+
...(mask.regions.length > reportedRegions.length
|
|
66
|
+
? [fact`Showing ${reportedRegions.length} of ${mask.regions.length} size-sorted changed-pixel regions; the recording artifact retains all rows.`]
|
|
68
67
|
: []),
|
|
69
68
|
lineList(regionLines),
|
|
70
69
|
],
|
|
@@ -74,7 +73,9 @@ export async function cmdMotionMask(parsed: ParsedArgs, _args: string[]): Promis
|
|
|
74
73
|
};
|
|
75
74
|
emitResult(result, { json: parsed.json });
|
|
76
75
|
} catch (err) {
|
|
77
|
-
|
|
76
|
+
// The recording resolved and was read; createMotionMask's rejection names
|
|
77
|
+
// why its contents cannot form a mask, which is not an unavailable artifact.
|
|
78
|
+
emitCommandError(parsed, 'mask_failed', err instanceof Error ? capped(err.message, 600) : capped(String(err), 600));
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
|
|
@@ -88,7 +89,7 @@ function formatRegion(region: ReturnType<typeof createMotionMask>['regions'][num
|
|
|
88
89
|
);
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
function emitCommandError(parsed: ParsedArgs, status: string, message: string): void {
|
|
92
|
+
function emitCommandError(parsed: ParsedArgs, status: string, message: string | ReturnType<typeof capped>): void {
|
|
92
93
|
emitResult({
|
|
93
94
|
tag: 'error',
|
|
94
95
|
attrs: { command: 'motion mask', status },
|