@crouton-kit/capture 1.3.7 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/bin/capture +32786 -9989
  2. package/package.json +9 -3
  3. package/src/capture.ts +86 -163
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -116
  6. package/src/cdp/bridge/protocol.ts +187 -1
  7. package/src/cdp/bridge/server.ts +211 -84
  8. package/src/cdp/bridge/spawn.ts +110 -1
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +35 -4
  11. package/src/cdp/commands/cdp.ts +266 -69
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +87 -0
  15. package/src/cdp/commands/measure/check.ts +109 -0
  16. package/src/cdp/commands/measure/diff.ts +120 -0
  17. package/src/cdp/commands/measure/explain.ts +133 -0
  18. package/src/cdp/commands/measure/index.ts +106 -0
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +52 -0
  21. package/src/cdp/commands/measure/map-layers.ts +59 -0
  22. package/src/cdp/commands/measure/map-scroll.ts +94 -0
  23. package/src/cdp/commands/measure/snap.ts +416 -0
  24. package/src/cdp/commands/measure/sweep.ts +227 -0
  25. package/src/cdp/commands/motion/index.ts +61 -0
  26. package/src/cdp/commands/motion/jank.ts +132 -0
  27. package/src/cdp/commands/motion/mask.ts +92 -0
  28. package/src/cdp/commands/motion/rec.ts +534 -0
  29. package/src/cdp/commands/motion/response.ts +76 -0
  30. package/src/cdp/commands/motion/timeline.ts +152 -0
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/coordinates.ts +158 -0
  46. package/src/cdp/detect.ts +42 -13
  47. package/src/cdp/dispatch.ts +76 -26
  48. package/src/cdp/domains.ts +75 -0
  49. package/src/cdp/har-recorder.ts +648 -115
  50. package/src/cdp/leaf-grammar.ts +99 -0
  51. package/src/cdp/measure/census.ts +205 -0
  52. package/src/cdp/measure/check.ts +318 -0
  53. package/src/cdp/measure/collectors/animation.ts +385 -0
  54. package/src/cdp/measure/collectors/ax.ts +278 -0
  55. package/src/cdp/measure/collectors/focus.ts +1041 -0
  56. package/src/cdp/measure/collectors/forms.ts +905 -0
  57. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  58. package/src/cdp/measure/collectors/hittest.ts +835 -0
  59. package/src/cdp/measure/collectors/layers.ts +697 -0
  60. package/src/cdp/measure/collectors/media.ts +522 -0
  61. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  62. package/src/cdp/measure/collectors/queries.ts +464 -0
  63. package/src/cdp/measure/collectors/scroll.ts +599 -0
  64. package/src/cdp/measure/collectors/states.ts +992 -0
  65. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  66. package/src/cdp/measure/collectors/styles.ts +355 -0
  67. package/src/cdp/measure/collectors/text.ts +713 -0
  68. package/src/cdp/measure/diff.ts +470 -0
  69. package/src/cdp/measure/explain.ts +498 -0
  70. package/src/cdp/measure/map-ax.ts +633 -0
  71. package/src/cdp/measure/map-focus.ts +188 -0
  72. package/src/cdp/measure/map-layers.ts +177 -0
  73. package/src/cdp/measure/map-scroll.ts +317 -0
  74. package/src/cdp/measure/redaction.ts +51 -0
  75. package/src/cdp/measure/settle.ts +1051 -0
  76. package/src/cdp/measure/snapshot.ts +390 -0
  77. package/src/cdp/measure/sweep.ts +330 -0
  78. package/src/cdp/measure/types.ts +220 -0
  79. package/src/cdp/measure/v2/collectors.ts +19 -0
  80. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  81. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  82. package/src/cdp/measure/v2/target-lock.ts +15 -0
  83. package/src/cdp/measure/v2/target.ts +16 -0
  84. package/src/cdp/motion/jank.ts +433 -0
  85. package/src/cdp/motion/mask.ts +421 -0
  86. package/src/cdp/motion/recorder.ts +764 -0
  87. package/src/cdp/motion/response.ts +356 -0
  88. package/src/cdp/motion/timeline.ts +215 -0
  89. package/src/cdp/record.ts +5 -212
  90. package/src/cdp/recorder-bridge.ts +1894 -0
  91. package/src/cdp/recorder-client.ts +326 -0
  92. package/src/cdp/scope-lock.ts +73 -0
  93. package/src/cdp/screenshot.ts +199 -66
  94. package/src/cdp/source-map.ts +309 -0
  95. package/src/cdp/targets.ts +21 -1
  96. package/src/cdp/timing.ts +87 -0
  97. package/src/cdp/types.ts +87 -6
  98. package/src/cdp/viewport.ts +21 -0
  99. package/src/cdp.ts +12 -58
  100. package/src/command-help.ts +29 -0
  101. package/src/command-resolution.ts +22 -0
  102. package/src/command-walker.ts +35 -0
  103. package/src/contracts/a11y.ts +33 -0
  104. package/src/contracts/cursor.ts +35 -0
  105. package/src/contracts/index.ts +15 -0
  106. package/src/contracts/motion.ts +19 -0
  107. package/src/contracts/neutral.ts +56 -0
  108. package/src/contracts/primitives.ts +205 -0
  109. package/src/contracts/release.ts +17 -0
  110. package/src/contracts/results.ts +208 -0
  111. package/src/contracts/routes.ts +140 -0
  112. package/src/contracts/snapshot.ts +92 -0
  113. package/src/cursor/index.ts +13 -0
  114. package/src/cursor/service.ts +310 -0
  115. package/src/errors.ts +71 -0
  116. package/src/har-manager.ts +992 -41
  117. package/src/interact.ts +410 -98
  118. package/src/output/artifact-lifecycle.ts +371 -0
  119. package/src/output/artifact.ts +526 -0
  120. package/src/output/diff.ts +111 -0
  121. package/src/output/exact-raw.ts +114 -0
  122. package/src/output/render.ts +987 -0
  123. package/src/output/selector.ts +185 -0
  124. package/src/output/structured.ts +211 -0
  125. package/src/registry.ts +208 -0
  126. package/src/session/artifacts.ts +481 -0
  127. package/src/session/commands.ts +1237 -368
  128. package/src/session/coordinator.ts +251 -0
  129. package/src/session/har-filter.ts +57 -0
  130. package/src/session/log-tailer.ts +653 -0
  131. package/src/session-context.ts +302 -34
  132. package/src/vault/bundle.ts +32 -17
  133. package/src/cdp/commands/exec.ts +0 -105
  134. package/src/cdp/commands/har.ts +0 -128
  135. package/src/cdp/commands/tabs.ts +0 -183
  136. package/src/cdp/commands/traffic.ts +0 -121
  137. package/src/cdp/commands/ui.ts +0 -172
  138. package/src/cdp/execute.ts +0 -113
  139. package/src/cdp/har-output.ts +0 -47
  140. package/src/cdp/usage.ts +0 -77
  141. package/src/tab-session.ts +0 -91
@@ -0,0 +1,227 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { randomBytes } from 'crypto';
4
+
5
+ import { type ParsedArgs } from '../../types.js';
6
+ import { withConnection } from '../../connection.js';
7
+ import { captureSnapshotSubstrate } from '../../measure/snapshot.js';
8
+ import { createOneshotSession } from '../../../session/commands.js';
9
+ import { getActiveSession } from '../../../session-context.js';
10
+ import { emitResult, fact, line, lineList, renderResult, text, type FactLine, type RenderableResult } from '../../../output/render.js';
11
+ import { assertSweepBounds, parsePositiveNumber } from '../../leaf-grammar.js';
12
+ import {
13
+ SWEEP_AXES,
14
+ analyzeSweepSamples,
15
+ applySweepEmulation,
16
+ isSweepAxis,
17
+ numericSweepValues,
18
+ readSweepEnvironment,
19
+ readSweepSnapshot,
20
+ refineNumericSweep,
21
+ restoreSweepEnvironment,
22
+ type SweepArtifact,
23
+ type SweepAxis,
24
+ type SweepRecovery,
25
+ type SweepSample,
26
+ writeSweepArtifact,
27
+ writeSweepRecoveryArtifact,
28
+ } from '../../measure/sweep.js';
29
+
30
+ const USAGE = `capture measure sweep [url] --axis <axis> — responsive/environment sampling with recursive bracketing of observed state changes
31
+
32
+ input:
33
+ [url] page to sweep; outside a session, snapshots and sweep.json are written under one private one-shot measure directory
34
+ --axis <axis> width|dpr|zoom|color-scheme|reduced-motion (required)
35
+ --from <val> numeric range start, or a color-scheme/reduced-motion value
36
+ --to <val> numeric range end, or a color-scheme/reduced-motion value
37
+ --viewport-height <val> fixed viewport height for width/dpr sweeps
38
+ output: <sweep axis=… samples=… transitions=…> — observed state facts per sampled point, each sampled snapshot path included; --json mirrors
39
+ effects: drives the page repeatedly under CDP Emulation settings; writes one snapshot substrate per sampled point plus sweep.json`;
40
+
41
+ const SAMPLE_LIMIT = 96;
42
+
43
+ export interface SweepCommandDependencies {
44
+ readonly withConnection: typeof withConnection;
45
+ readonly captureSnapshotSubstrate: typeof captureSnapshotSubstrate;
46
+ readonly getActiveSession: typeof getActiveSession;
47
+ readonly createOneshotSession: typeof createOneshotSession;
48
+ readonly emitResult: typeof emitResult;
49
+ }
50
+
51
+ const defaultDependencies: SweepCommandDependencies = { withConnection, captureSnapshotSubstrate, getActiveSession, createOneshotSession, emitResult };
52
+
53
+ function sweepId(): string {
54
+ return `sweep-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
55
+ }
56
+
57
+ /** Lists a bounded set of regular files already written to a failed snapshot directory. */
58
+ function writtenSnapshotArtifacts(dir: string): string[] {
59
+ try {
60
+ return fs.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile()).map((entry) => entry.name).sort().slice(0, 64);
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
65
+
66
+ // The `--from`/`--to`/`--viewport-height` numeric grammar and the enum-axis
67
+ // domain are validated up front in `validateCliInvocation` (before any
68
+ // artifact allocation) via the shared `parsePositiveNumber`/`assertSweepBounds`
69
+ // predicates below; the runtime path re-runs the same pure functions so the
70
+ // two layers can never diverge.
71
+ const parsePositive = parsePositiveNumber;
72
+
73
+ function valuesForAxis(axis: SweepAxis, parsed: ParsedArgs): Array<number | string> {
74
+ assertSweepBounds(axis, parsed.from, parsed.to);
75
+ if (axis === 'width') return numericSweepValues(parsePositive(parsed.from, 320, '--from'), parsePositive(parsed.to, 1440, '--to'), axis);
76
+ if (axis === 'dpr') return numericSweepValues(parsePositive(parsed.from, 1, '--from'), parsePositive(parsed.to, 3, '--to'), axis);
77
+ if (axis === 'zoom') return numericSweepValues(parsePositive(parsed.from, 0.5, '--from'), parsePositive(parsed.to, 2, '--to'), axis);
78
+ if (axis === 'color-scheme') return [parsed.from ?? 'light', parsed.to ?? 'dark'];
79
+ return [parsed.from ?? 'no-preference', parsed.to ?? 'reduce'];
80
+ }
81
+
82
+ export function transitionSections(artifact: SweepArtifact): FactLine[] {
83
+ const ranges = artifact.ranges.map((range) => line(fact` observed matching fingerprint ${range.from}–${range.to} → ${range.snapId} (${range.snapDir})`));
84
+ const transitions = artifact.transitions.flatMap((transition, index) => {
85
+ const header = line(fact`${index + 1}. bracketed between ${transition.bracket.from} and ${transition.bracket.to} — ${transition.before} → ${transition.after}`);
86
+ const changes = transition.changes.slice(0, 12).map((change) => {
87
+ const provenance = change.provenance?.source ? fact` (${change.provenance.selector ?? 'winning declaration'}${change.provenance.specificity ? `, specificity ${change.provenance.specificity}` : ''}, ${change.provenance.source})` : change.provenance?.selector ? fact` (${change.provenance.selector}${change.provenance.specificity ? `, specificity ${change.provenance.specificity}` : ''})` : text``;
88
+ return line(fact` ${change.selector} ${change.property}: ${change.before ?? 'unset'} → ${change.after ?? 'unset'}`, provenance);
89
+ });
90
+ return [header, ...changes];
91
+ });
92
+ const uncertainty = artifact.uncertainties.map((interval) => fact` ${interval.from}–${interval.to}: ${interval.reason === 'sampling_limit' ? `not sampled after the ${artifact.sampleLimit} sample limit` : 'adjacent sampled endpoints at resolution limit'}`);
93
+ const settled = artifact.samples.map((sample) => {
94
+ const caveats = sample.unstableRegions.map((region) => fact` region ${region.id}${region.selector ? ` (${region.selector})` : ''}${region.reason ? `: ${region.reason}` : ''}`);
95
+ const sampleFact = fact` ${sample.value}: ${sample.snapId} (${sample.snapDir}) settled=${String(sample.settled)}`;
96
+ return caveats.length ? line(sampleFact, line(text` — unstable`, lineList(caveats))) : sampleFact;
97
+ });
98
+ return [
99
+ lineList([text`Observed matching-fingerprint spans (no assertion about unsampled values):`, ...(ranges.length ? ranges : [text` none`])]),
100
+ lineList([text`Observed state changes (bracketed by samples):`, ...(transitions.length ? transitions : [text` none observed across sampled values`])]),
101
+ lineList([text`Sampling uncertainty:`, ...(uncertainty.length ? uncertainty : [text` none`])]),
102
+ lineList([text`Captured sample paths and settledness:`, ...settled]),
103
+ artifact.environmentRestoration ? lineList([
104
+ text`Environment restoration provenance:`,
105
+ fact` observed/restored effective settings: ${artifact.environmentRestoration.observed.join(', ')}`,
106
+ fact` CDP does not expose prior override configuration for: ${artifact.environmentRestoration.unobservable.join(', ')}`,
107
+ ]) : text``,
108
+ ];
109
+ }
110
+
111
+ export function renderSweepArtifact(artifact: SweepArtifact): string {
112
+ return renderResult({ tag: 'sweep', attrs: { axis: artifact.axis, from: artifact.from, to: artifact.to, samples: artifact.samples.length, transitions: artifact.transitions.length }, summary: fact`Sweep samples are recorded as observed state facts.`, sections: transitionSections(artifact), followUp: artifact.samples.length > 1 ? fact`Compare two sampled substrates with \`capture measure diff --before ${artifact.samples[0].snapDir} --after ${artifact.samples[1].snapDir}\`.` : undefined });
113
+ }
114
+
115
+ export async function cmdMeasureSweep(parsed: ParsedArgs, args: string[]): Promise<void> {
116
+ await runMeasureSweep(parsed, args);
117
+ }
118
+
119
+ /** Runs sweep orchestration; dependency overrides keep the thrown-capture recovery path verifiable without a browser. */
120
+ export async function runMeasureSweep(parsed: ParsedArgs, _args: string[], overrides: Partial<SweepCommandDependencies> = {}): Promise<void> {
121
+ const dependencies = { ...defaultDependencies, ...overrides };
122
+ if (parsed.help) {
123
+ console.log(USAGE);
124
+ return;
125
+ }
126
+ if (parsed.positional.length > 1) {
127
+ dependencies.emitResult({ tag: 'error', attrs: { command: 'measure sweep', status: 'invalid_input' }, summary: text`Measure sweep accepts at most one positional URL.` }, { json: parsed.json });
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+ if (!isSweepAxis(parsed.axis)) {
132
+ dependencies.emitResult({ tag: 'error', attrs: { command: 'measure sweep', status: 'invalid_axis' }, summary: fact`--axis is required and must be one of ${SWEEP_AXES.join(', ')}.` }, { json: parsed.json });
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+
137
+ const axis = parsed.axis;
138
+ const active = dependencies.getActiveSession();
139
+ const oneShot = active ? undefined : dependencies.createOneshotSession('measure');
140
+ const snapsDir = active ? path.join(active.dir, 'measure', 'snaps') : oneShot!.artifactsDir;
141
+ const artifactDir = active ? path.join(active.dir, 'measure', 'sweeps', sweepId()) : path.join(oneShot!.dir, 'measure', 'sweeps', sweepId());
142
+ const recoverySamples: Array<SweepRecovery['samples'][number]> = [];
143
+ let environmentRestoration: SweepRecovery['environmentRestoration'] = 'not_attempted';
144
+ try {
145
+ const values = valuesForAxis(axis, parsed);
146
+ const connectionArgs: ParsedArgs = parsed.positional[0] ? { ...parsed, url: parsed.positional[0], target: undefined } : parsed;
147
+ const samplesByValue = new Map<string, SweepSample>();
148
+ let uncertainties: SweepArtifact['uncertainties'] = [];
149
+ let environmentFacts: SweepArtifact['environmentRestoration'];
150
+ await dependencies.withConnection(connectionArgs, async (client, tab) => {
151
+ const baseline = await readSweepEnvironment(client);
152
+ environmentFacts = {
153
+ observed: ['viewport width/height', 'device pixel ratio', 'page scale', ...(baseline.media === undefined ? [] : [`effective media type (${baseline.media})`]), 'prefers-color-scheme', 'prefers-reduced-motion'],
154
+ unobservable: ['arbitrary pre-existing device metrics fields', 'arbitrary media feature overrides', ...(baseline.media === undefined ? ['arbitrary pre-existing media types (not observable or restored)'] : [])],
155
+ };
156
+ const samplingEnvironment = { ...baseline, height: parsed.viewportHeight === undefined ? baseline.height : parsePositive(parsed.viewportHeight, 1, '--viewport-height') };
157
+ const capture = async (value: number | string): Promise<SweepSample> => {
158
+ const key = String(value);
159
+ const prior = samplesByValue.get(key);
160
+ if (prior) return prior;
161
+ await applySweepEmulation(client, axis, value, samplingEnvironment);
162
+ const id = `snap-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
163
+ const dir = path.join(snapsDir, id);
164
+ const recovery: SweepRecovery['samples'][number] = { value, snapId: id, snapDir: dir, status: 'pending', captured: false, settled: false, unstableRegions: [], artifacts: [] };
165
+ recoverySamples.push(recovery);
166
+ let result;
167
+ try {
168
+ result = await dependencies.captureSnapshotSubstrate({
169
+ target: { client, tabId: tab.id }, url: tab.url, path: dir, snapId: id,
170
+ settleTimeout: parsed.settleTimeout, freezeAnimations: parsed.freezeAnimations,
171
+ captureUnsettled: parsed.captureUnsettled, pixels: parsed.pixels, state: parsed.state ?? [],
172
+ viewport: `${axis === 'width' ? value : samplingEnvironment.width}x${samplingEnvironment.height}`,
173
+ });
174
+ } catch (error) {
175
+ Object.assign(recovery, { status: 'failed', failure: 'capture_threw', artifacts: writtenSnapshotArtifacts(dir) });
176
+ throw error;
177
+ }
178
+ Object.assign(recovery, { status: result.captured ? 'captured' : 'evidence_only', captured: result.captured, settled: result.settled, unstableRegions: result.unstableRegions, artifacts: result.artifacts });
179
+ if (!result.captured) throw new Error('evidence-only snapshot');
180
+ const sample = readSweepSnapshot(id, dir, value, result.unstableRegions, result.settled);
181
+ samplesByValue.set(key, sample);
182
+ return sample;
183
+ };
184
+ try {
185
+ for (const value of values) await capture(value);
186
+ if (axis === 'width' || axis === 'dpr' || axis === 'zoom') {
187
+ const tolerance = axis === 'width' ? 1 : 0.01;
188
+ const refined = await refineNumericSweep([...samplesByValue.values()], tolerance, capture, SAMPLE_LIMIT);
189
+ samplesByValue.clear();
190
+ for (const sample of refined.samples) samplesByValue.set(String(sample.value), sample);
191
+ uncertainties = refined.uncertainties;
192
+ }
193
+ } finally {
194
+ try {
195
+ await restoreSweepEnvironment(client, baseline);
196
+ environmentRestoration = 'restored';
197
+ } catch (error) {
198
+ environmentRestoration = 'failed';
199
+ throw error;
200
+ }
201
+ }
202
+ }, { settle: 0 });
203
+
204
+ const samples = (axis === 'width' || axis === 'dpr' || axis === 'zoom') ? [...samplesByValue.values()].sort((a, b) => Number(a.value) - Number(b.value)) : values.map((value) => samplesByValue.get(String(value))!).filter(Boolean);
205
+ const from = values[0];
206
+ const to = values[values.length - 1];
207
+ const analyzed = analyzeSweepSamples(axis, from, to, samples);
208
+ const artifact: SweepArtifact = { axis, from, to, capturedAt: new Date().toISOString(), samples, ...analyzed, uncertainties, sampleLimit: SAMPLE_LIMIT, environmentRestoration: environmentFacts };
209
+ const artifactPath = writeSweepArtifact(artifactDir, artifact);
210
+ const result: RenderableResult = {
211
+ tag: 'sweep', attrs: { axis, from, to, samples: samples.length, transitions: artifact.transitions.length, path: artifactDir },
212
+ summary: fact`Sweep artifact written to ${artifactPath}. It records sampled snapshot paths, discrete-state fingerprints, bracketed observed changes, and sampling uncertainty.`,
213
+ sections: transitionSections(artifact),
214
+ followUp: samples.length > 1 ? fact`Compare two sampled substrates with \`capture measure diff --before ${samples[0].snapDir} --after ${samples[1].snapDir}\`.` : undefined,
215
+ };
216
+ dependencies.emitResult(result, { json: parsed.json });
217
+ } catch {
218
+ const evidenceOnly = recoverySamples.some((sample) => sample.status === 'evidence_only');
219
+ const recoveryPath = writeSweepRecoveryArtifact(artifactDir, { axis, capturedAt: new Date().toISOString(), reason: evidenceOnly ? 'evidence_only' : 'capture_failed', environmentRestoration, samples: recoverySamples });
220
+ dependencies.emitResult({
221
+ tag: 'error', attrs: { command: 'measure sweep', status: evidenceOnly ? 'evidence_only' : 'sweep_failed', path: recoveryPath },
222
+ summary: fact`Sweep did not produce a queryable sample set. Recovery artifact: ${recoveryPath}.`,
223
+ sections: recoverySamples.length ? [text`Partial snapshot/recovery provenance:`, ...recoverySamples.map((sample) => fact`${sample.value}: ${sample.snapDir} status=${sample.status} captured=${sample.captured} settled=${sample.settled} artifacts=${sample.artifacts.join(', ') || 'none'} unstable=${sample.unstableRegions.map((region) => `${region.id}${region.reason ? `:${region.reason}` : ''}`).join(', ') || 'none'}`), fact`environment restoration: ${environmentRestoration}`] : undefined,
224
+ }, { json: parsed.json });
225
+ process.exitCode = 1;
226
+ }
227
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `capture motion` branch router.
3
+ *
4
+ * Shifts the consumed leaf token out of `parsed.positional` before handing
5
+ * off, so every leaf command still finds its own primary target (a URL, a
6
+ * recording id, ...) at `positional[0]` exactly as every other capture
7
+ * command does.
8
+ */
9
+ import { invalidInput } from '../../../errors.js';
10
+ import { type ParsedArgs } from '../../types.js';
11
+ import { cmdMotionRec } from './rec.js';
12
+ import { cmdMotionMask } from './mask.js';
13
+ import { cmdMotionTimeline } from './timeline.js';
14
+ import { cmdMotionJank } from './jank.js';
15
+ import { cmdMotionResponse } from './response.js';
16
+
17
+ /** Root-help representation of this branch, assembled by `src/capture.ts`. */
18
+ export const COMMAND_BLOCK = `<command name="motion">
19
+ facts over a recording — recorder lifecycle plus read-only queries over a finalized recording
20
+ use when recording an interaction (one-shot or composed) and reading motion facts: diffs, timelines, jank, input response
21
+ rec · mask · timeline · jank · response — \`capture motion -h\`
22
+ </command>`;
23
+
24
+ export const MOTION_USAGE = `capture motion — recorder lifecycle + read-only queries over a finalized recording.
25
+
26
+ \`rec\` drives (and records) the browser, one-shot or composed across
27
+ intervening commands; every other leaf below is a cheap read over the
28
+ finalized recording artifact. Every leaf defaults to rendered prose; --json
29
+ mirrors the same result. Findings exit 0 — a report, not a failure;
30
+ input/precondition errors exit 1. No leaf accepts --gate.
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`;
39
+
40
+ export async function motionMain(parsed: ParsedArgs, args: string[]): Promise<void> {
41
+ const leaf = parsed.positional[0];
42
+ const rest: ParsedArgs = { ...parsed, positional: parsed.positional.slice(1) };
43
+
44
+ switch (leaf) {
45
+ case 'rec':
46
+ return cmdMotionRec(rest, args);
47
+ case 'mask':
48
+ return cmdMotionMask(rest, args);
49
+ case 'timeline':
50
+ return cmdMotionTimeline(rest, args);
51
+ case 'jank':
52
+ return cmdMotionJank(rest, args);
53
+ case 'response':
54
+ return cmdMotionResponse(rest, args);
55
+ case undefined:
56
+ console.log(MOTION_USAGE);
57
+ return;
58
+ default:
59
+ throw invalidInput(`Unknown motion leaf: ${leaf}.`, 'unknown_command');
60
+ }
61
+ }
@@ -0,0 +1,132 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { resolveRecRef } from '../../../output/artifact.js';
3
+ import { emitResult, fact, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
4
+ import { readMotionJank, type ArtifactLossFact, type LayoutShiftFact, type LayoutShiftRectFact, type LongTaskFact } from '../../motion/jank.js';
5
+
6
+ const USAGE = `capture motion jank <rec> — dropped-frame, long-task-record, and layout-shift facts over a finalized recording
7
+
8
+ input:
9
+ <rec> recording id in the active session or an absolute recording path (required; the recording must be finalized)
10
+ output: <jank …> — dropped-frame, long-task-record, and layout-shift facts; 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
+ effects: read-only — reads the finalized recording artifact, never drives the browser`;
12
+
13
+ export async function cmdMotionJank(parsed: ParsedArgs, _args: string[]): Promise<void> {
14
+ if (parsed.help) {
15
+ console.log(USAGE);
16
+ return;
17
+ }
18
+ if (parsed.positional.length !== 1 || !parsed.positional[0]) {
19
+ return emitCommandError(parsed, 'invalid_target', 'motion jank requires exactly one recording id or absolute recording path.', 'Record one with `capture motion rec <url> --do <action>` .');
20
+ }
21
+
22
+ try {
23
+ const ref = resolveRecRef(parsed.positional[0]);
24
+ const { analysis, meta } = readMotionJank(ref);
25
+ const state = typeof meta.state === 'string' ? meta.state : 'unknown';
26
+ const sections: FactLine[] = [fact`${analysis.timingNote}`];
27
+
28
+ if (analysis.droppedFrames.length) {
29
+ sections.push(lineList(analysis.droppedFrames.map((drop, index) =>
30
+ fact`${index + 1}. frames ${drop.beforeFrame}→${drop.afterFrame}: interval ${drop.intervalMs.toFixed(2)}ms, cadence ${analysis.cadenceMs?.toFixed(2) ?? 'unavailable'}ms, estimated dropped frames ${drop.estimatedDroppedFrames}, t=${drop.startMs.toFixed(2)}→${drop.endMs.toFixed(2)}ms (±frame)`,
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.`);
37
+
38
+ if (analysis.longTasks.length) {
39
+ sections.push(lineList(analysis.longTasks.map((task, index) => formatLongTask(task, index))));
40
+ } else {
41
+ sections.push(text`Long-task records: 0.`);
42
+ }
43
+
44
+ if (analysis.layoutShifts.length) {
45
+ sections.push(...analysis.layoutShifts.map((shift, index) => formatLayoutShift(shift, index)));
46
+ } else {
47
+ sections.push(text`Layout-shift records: 0.`);
48
+ }
49
+
50
+ if (analysis.artifactLoss.length) {
51
+ sections.push(lineList(analysis.artifactLoss.map(formatArtifactLoss)));
52
+ }
53
+
54
+ const result: RenderableResult = {
55
+ tag: 'jank',
56
+ attestation: {
57
+ kind: 'recording',
58
+ id: ref.id,
59
+ path: ref.dir,
60
+ note: fact`Recording state ${state}. ${analysis.timingNote}`,
61
+ },
62
+ attrs: {
63
+ state,
64
+ frames: analysis.frameCount,
65
+ 'dropped-frames': analysis.droppedFrameCount,
66
+ 'dropped-frames-incomplete': analysis.droppedFramesIncomplete,
67
+ 'long-task-records': analysis.longTasks.length,
68
+ 'long-task-records-incomplete': analysis.longTasksIncomplete,
69
+ 'layout-shift-records': analysis.layoutShifts.length,
70
+ 'layout-shift-records-incomplete': analysis.layoutShiftsIncomplete,
71
+ 'timestamp-uncertainty': analysis.frameTimestampUncertainty,
72
+ },
73
+ summary: fact`${analysis.droppedFrameCount} estimated dropped frame(s)${analysis.droppedFramesIncomplete ? ' (incomplete)' : ''}, ${analysis.longTasks.length} long-task record(s)${analysis.longTasksIncomplete ? ' (incomplete)' : ''}, ${analysis.layoutShifts.length} layout-shift record(s)${analysis.layoutShiftsIncomplete ? ' (incomplete)' : ''}.`,
74
+ sections,
75
+ };
76
+ emitResult(result, { json: parsed.json });
77
+ } catch (error) {
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ emitCommandError(parsed, 'artifact_unavailable', message, 'Pass a finalized recording id/path; `capture motion rec --stop` finalizes a composed recording.');
80
+ }
81
+ }
82
+
83
+ function formatLongTask(task: LongTaskFact, index: number): FactLine {
84
+ const timingText = task.timingDomain === 'recorder-performance'
85
+ ? `t=${task.startMs!.toFixed(2)}→${task.endMs!.toFixed(2)}ms`
86
+ : task.timingDomain === 'trace-relative-first-event'
87
+ ? `t=${task.startMs!.toFixed(2)}→${task.endMs!.toFixed(2)}ms relative to first trace event`
88
+ : 'timing unavailable after navigation gap';
89
+ const overlapText = task.overlapsDroppedFrames === null
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}.`;
97
+ }
98
+
99
+ function formatLayoutShift(shift: LayoutShiftFact, index: number): FactLine {
100
+ const attribution = shift.attribution === 'observer-sources'
101
+ ? 'PerformanceObserver sources'
102
+ : shift.attribution === 'frame-diff-inferred'
103
+ ? `inferred from rect samples bracketing t=${shift.beforeFrameMs?.toFixed(2)}→${shift.afterFrameMs?.toFixed(2)}ms (±frame)`
104
+ : 'unavailable';
105
+ const timing = shift.tMs === null ? 'timing unavailable after navigation gap' : `t=${shift.tMs.toFixed(2)}ms`;
106
+ const header = fact`layout shift ${index + 1}: ${timing}, value ${shift.value}${shift.hadRecentInput === undefined ? '' : `, had-recent-input ${shift.hadRecentInput}`}; attribution ${attribution}`;
107
+ return shift.rects.length ? lineList([header, ...shift.rects.map(formatShiftRect)]) : lineList([header, text`no element-attributed rect record was retained for this layout-shift entry`]);
108
+ }
109
+
110
+ function formatShiftRect(item: LayoutShiftRectFact): FactLine {
111
+ const current = item.rect ? `x=${item.rect.x} y=${item.rect.y} w=${item.rect.w} h=${item.rect.h}` : 'unavailable';
112
+ const previous = item.previousRect ? `x=${item.previousRect.x} y=${item.previousRect.y} w=${item.previousRect.w} h=${item.previousRect.h}` : 'unavailable';
113
+ const movement = item.delta ? `x=${item.delta.x} y=${item.delta.y} w=${item.delta.w} h=${item.delta.h}` : 'unavailable';
114
+ return fact` ${item.elementId === undefined ? 'element id unavailable' : `element ${item.elementId}`}: rect ${current}; previous rect ${previous}; delta ${movement}`;
115
+ }
116
+
117
+ function formatArtifactLoss(loss: ArtifactLossFact): FactLine {
118
+ const details = loss.message ?? loss.reason ?? 'detail unavailable';
119
+ const affected = loss.affectedCounts.length ? `${loss.affectedCounts.join(', ')} (incomplete)` : 'no jank count family';
120
+ return fact`Artifact loss/recovery: ${loss.kind}; ${loss.count ?? 'unknown'} record(s); ${details}. Affected counts: ${affected}.`;
121
+ }
122
+
123
+ function emitCommandError(parsed: ParsedArgs, status: string, message: string, recovery: string): void {
124
+ const result: RenderableResult = {
125
+ tag: 'error',
126
+ attrs: { command: 'motion jank', status },
127
+ summary: fact`${message}`,
128
+ followUp: fact`${recovery}`,
129
+ };
130
+ emitResult(result, { json: parsed.json });
131
+ process.exitCode = 1;
132
+ }
@@ -0,0 +1,92 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { createMotionMask } from '../../motion/mask.js';
3
+ import { readMeta, resolveRecRef } from '../../../output/artifact.js';
4
+ import { emitResult, fact, formatArtifactList, line, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
5
+
6
+ const DEFAULT_REGION_LIMIT = 20;
7
+
8
+ const USAGE = `capture motion mask <rec> — motion-diff composite image plus per-region facts over a finalized recording
9
+
10
+ input:
11
+ <rec> recording id in the active session or an absolute recording path (required; the recording must be finalized)
12
+ --limit <N> render at most N size-sorted region rows (default: ${DEFAULT_REGION_LIMIT}); --json always contains every region row
13
+ 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
+ effects: reads the finalized recording artifact, never drives the browser; writes motion-mask.png inside the recording directory`;
15
+
16
+ export async function cmdMotionMask(parsed: ParsedArgs, _args: string[]): Promise<void> {
17
+ if (parsed.help) {
18
+ console.log(USAGE);
19
+ return;
20
+ }
21
+ if (parsed.positional.length !== 1) {
22
+ return emitCommandError(parsed, 'invalid_target', 'motion mask requires exactly one recording id or absolute recording path. Create one with `capture motion rec`.');
23
+ }
24
+
25
+ try {
26
+ const ref = resolveRecRef(parsed.positional[0]);
27
+ const meta = readMeta<{ state?: unknown }>(ref);
28
+ const state = typeof meta.state === 'string' ? meta.state : 'unknown';
29
+ if (state !== 'finalized' && state !== 'orphaned-finalized') {
30
+ return emitCommandError(parsed, 'recording_not_finalized', `Recording ${ref.id} has state ${state}; finalize it with \`capture motion rec --stop\` before creating a mask.`);
31
+ }
32
+ const mask = createMotionMask(ref);
33
+ // A recording can have many isolated changed-pixel components. Limit the
34
+ // human/agent prose view, while JSON keeps the complete size-sorted list.
35
+ const proseLimit = parsed.limit ?? DEFAULT_REGION_LIMIT;
36
+ const reportedRegions = parsed.json ? mask.regions : mask.regions.slice(0, proseLimit);
37
+ const regionLines = reportedRegions.length
38
+ ? reportedRegions.map((region) => formatRegion(region))
39
+ : [text`No changed pixels were measured across the recorded frame pairs.`];
40
+ const result: RenderableResult = {
41
+ tag: 'motion-mask',
42
+ attestation: {
43
+ kind: 'recording',
44
+ id: ref.id,
45
+ path: ref.dir,
46
+ note: text`Frame-derived onset, distance, and velocity times carry ±1 frame uncertainty; timestamps use screencast-frame timing when available.`,
47
+ },
48
+ attrs: {
49
+ state,
50
+ regions: mask.regions.length,
51
+ 'prose-regions': Math.min(mask.regions.length, proseLimit),
52
+ image: `${ref.id}/motion-mask.png`,
53
+ 'timestamp-uncertainty': '±1 frame',
54
+ ...(mask.caveat ? { window: 'partial (viewport resize)' } : {}),
55
+ },
56
+ summary: fact`Composite written: motion-mask.png (${mask.width}×${mask.height}, ${mask.comparedFramePairs} adjacent frame pair(s)); transparent pixels did not differ and hue runs blue (early) to red (late).`,
57
+ artifacts: formatArtifactList([{ name: 'motion-mask.png', note: 'motion-diff composite' }]),
58
+ sections: [
59
+ ...(mask.caveat ? [line(text`${mask.caveat}`)] : []),
60
+ ...(mask.regions.length > proseLimit && !parsed.json
61
+ ? [fact`Showing ${reportedRegions.length} of ${mask.regions.length} size-sorted changed-pixel regions; --json contains all region rows.`]
62
+ : []),
63
+ lineList(regionLines),
64
+ ],
65
+ followUp: mask.regions[0]?.element
66
+ ? line(text`Per-frame geometry for region 1: \`capture motion timeline `, fact`${ref.id}`, text` --element `, fact`${mask.regions[0].element.label}\`.`)
67
+ : undefined,
68
+ };
69
+ emitResult(result, { json: parsed.json });
70
+ } catch (err) {
71
+ emitCommandError(parsed, 'artifact_unavailable', err instanceof Error ? err.message : String(err));
72
+ }
73
+ }
74
+
75
+ function formatRegion(region: ReturnType<typeof createMotionMask>['regions'][number]): FactLine {
76
+ const attribution = region.element
77
+ ? fact` · attributed rect: \`${region.element.label}\`${region.element.backendNodeId === undefined ? '' : ` (backend ${region.element.backendNodeId})`}`
78
+ : text` · no recorded element rect overlapped this region`;
79
+ return line(
80
+ fact`${region.index}. x=${region.x} y=${region.y} w=${region.width} h=${region.height} — area ${region.areaPixels} changed px; distance ${region.distancePx}px; velocity ${region.velocityPxPerSecond}px/s; frame-time ${region.startMs}–${region.endMs}ms`,
81
+ attribution,
82
+ );
83
+ }
84
+
85
+ function emitCommandError(parsed: ParsedArgs, status: string, message: string): void {
86
+ emitResult({
87
+ tag: 'error',
88
+ attrs: { command: 'motion mask', status },
89
+ summary: fact`${message}`,
90
+ }, { json: parsed.json });
91
+ process.exitCode = 1;
92
+ }