@crouton-kit/capture 1.3.8 → 1.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  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 +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  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/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. package/src/tab-session.ts +0 -91
@@ -1,36 +1,416 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { randomBytes } from 'crypto';
4
+
1
5
  import { type ParsedArgs } from '../../types.js';
2
- import { emitResult, fact, type RenderableResult } from '../../../output/render.js';
6
+ import { withConnection } from '../../connection.js';
7
+ import { isRecorderHeldClient } from '../../recorder-client.js';
8
+ import { parseViewport, type Viewport } from '../../viewport.js';
9
+ import { captureSnapshotSubstrate } from '../../measure/snapshot.js';
10
+ import { sanitizeString } from '../../measure/redaction.js';
11
+ import { DEFAULT_SETTLE_TIMEOUT_MS } from '../../measure/settle.js';
12
+ import { createOneshotSession } from '../../../session/commands.js';
13
+ import { getActiveSession } from '../../../session-context.js';
14
+ import { removeArtifactTree } from '../../../session/artifacts.js';
15
+ import { ArtifactResolutionError, isUrlRef, readMeta, resolveSnapRef, type SnapRef } from '../../../output/artifact.js';
16
+ import { captureError, invalidInput } from '../../../errors.js';
17
+ import {
18
+ emitResult,
19
+ fact,
20
+ text,
21
+ formatArtifactList,
22
+ renderResult,
23
+ toJsonResult,
24
+ type ArtifactEntry,
25
+ type RenderableResult,
26
+ } from '../../../output/render.js';
27
+ const USAGE = `capture measure snap [url|snap] — drive the page and write one settled snapshot substrate directory
28
+
29
+ input:
30
+ [url|snap] a URL drives the page fresh; a snap id or absolute path re-captures states over that base snapshot. Outside a session a URL writes under a private one-shot session; with an active session, snapshots are written under it and later resolve by snap id
31
+ --freeze-animations pause CSS/WAAPI animation before capture
32
+ --settle-timeout <ms> override the default 5000ms settle wait
33
+ --capture-unsettled write the full substrate despite non-settlement, marking unstable regions
34
+ --pixels also write per-element raster crops
35
+ --state <state[:selector]> force a pseudo-state or real control state (repeatable)
36
+ --viewport <WxH> temporarily capture at a CSS-pixel viewport (repeatable); exact <positive-safe-int>x<positive-safe-int> grammar with lowercase x and no whitespace
37
+ output: <snapshot id=… path=…> — the settled artifact directory (geometry, styles, hit-test, text, forms, animation, ax, queries, focus, scroll, layers; per-element crops with --pixels) every other measure/motion query leaf reads instead of re-driving the browser; --json mirrors
38
+ effects: drives the target page to capture; writes one snapshot artifact directory under the active (or a private one-shot) session`;
39
+
40
+ interface SnapshotMetaForBase {
41
+ readonly url?: string | null;
42
+ }
43
+
44
+ export interface MeasureSnapCapture {
45
+ readonly id: string;
46
+ readonly dir: string;
47
+ readonly base?: SnapRef;
48
+ readonly artifacts: readonly string[];
49
+ }
50
+
51
+ interface ViewportSpec extends Viewport {
52
+ readonly label: string;
53
+ }
54
+
55
+ function generateSnapId(): string {
56
+ return `snap-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
57
+ }
58
+
59
+ function readArrayCount(filePath: string, field: string): number | undefined {
60
+ try {
61
+ const value = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, unknown>;
62
+ return Array.isArray(value[field]) ? value[field].length : undefined;
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ }
67
+
68
+ function artifactEntries(dir: string, artifacts: readonly string[]): ArtifactEntry[] {
69
+ const entries: ArtifactEntry[] = [];
70
+ const geometryCount = readArrayCount(path.join(dir, 'geometry.json'), 'elements');
71
+ for (const name of artifacts) {
72
+ entries.push({
73
+ name,
74
+ ...(name === 'geometry.json' && geometryCount !== undefined ? { note: `${geometryCount} elements` } : {}),
75
+ });
76
+ }
77
+ return entries;
78
+ }
79
+
80
+ type ViewportClient = { send: (method: string, params?: Record<string, unknown>) => Promise<unknown> };
3
81
 
4
- const USAGE = `Usage: capture measure snap [url|snap] [--freeze-animations] [--settle-timeout <ms>] [--capture-unsettled] [--pixels] [--state <state[:selector]>]... [--viewport <WxH>]...
82
+ type ViewportOwnership =
83
+ | { readonly owner: 'native' }
84
+ | { readonly owner: 'recorder' }
85
+ | { readonly owner: 'foreign'; readonly reason: 'dpr-matched-no-display' | 'screen-info-unreadable' };
5
86
 
6
- Drive the page (or re-capture states over a base snapshot) and write one
7
- settled snapshot substrate directory: geometry, styles, hit-test, text,
8
- forms, animation, ax, queries, focus, scroll, layers, and (with --pixels)
9
- per-element crops. Every other measure/motion query leaf reads this
10
- artifact instead of re-driving the browser.
87
+ interface ScreenInfo {
88
+ readonly devicePixelRatio?: unknown;
89
+ }
11
90
 
12
- Options:
13
- --freeze-animations Pause CSS/WAAPI animation before capture
14
- --settle-timeout <ms> Override the default 5000ms settle wait
15
- --capture-unsettled Write full substrate despite non-settlement,
16
- marking unstable regions
17
- --pixels Also write per-element raster crops
18
- --state <state[:selector]> Force a pseudo-state or real control state
19
- (repeatable)
20
- --viewport <WxH> Viewport to snap at (repeatable)
91
+ /**
92
+ * CDP does not expose device-metrics override state. A recorder-held client is
93
+ * explicitly owned by the recorder; otherwise, compare the page DPR to the
94
+ * browser's actual displays. An emulated DPR that differs from every display
95
+ * is a foreign override, while native browser chrome (outer vs. content size)
96
+ * and native high-DPI displays are not ownership signals. If Chrome cannot
97
+ * provide screen information, leave the target untouched rather than clearing
98
+ * an override we cannot restore.
99
+ *
100
+ * `Emulation.getScreenInfos` is an experimental CDP method; if a future Chrome
101
+ * removes or renames it the read fails and every viewport capture degrades to
102
+ * `screen-info-unreadable` — i.e. all-refuse. That is the safe direction: it
103
+ * never clears an override it cannot restore.
104
+ */
105
+ async function viewportOwnership(client: ViewportClient): Promise<ViewportOwnership> {
106
+ if (isRecorderHeldClient(client)) return { owner: 'recorder' };
107
+ try {
108
+ const [page, screens] = await Promise.all([
109
+ client.send('Runtime.evaluate', {
110
+ expression: 'window.devicePixelRatio',
111
+ returnByValue: true,
112
+ }) as Promise<{ result?: { value?: unknown } }>,
113
+ client.send('Emulation.getScreenInfos') as Promise<{ screenInfos?: readonly ScreenInfo[] }>,
114
+ ]);
115
+ const dpr = page.result?.value;
116
+ const displays = screens.screenInfos;
117
+ if (typeof dpr === 'number' && Number.isFinite(dpr) && dpr > 0 && Array.isArray(displays) && displays.length > 0) {
118
+ // A headed browser at page-zoom ≠ 100% multiplies window.devicePixelRatio
119
+ // (display DPR × zoom), so a genuinely native but zoomed target reports a
120
+ // DPR that matches no display and is refused here as foreign. Refusal is
121
+ // the safe direction — it never clobbers an override it cannot restore —
122
+ // and there is no clean CDP getter for the current zoom factor to subtract
123
+ // it back out.
124
+ if (displays.some((screen) => screen.devicePixelRatio === dpr)) return { owner: 'native' };
125
+ return { owner: 'foreign', reason: 'dpr-matched-no-display' };
126
+ }
127
+ } catch {
128
+ // An unreadable target is not evidence that its current override is ours.
129
+ }
130
+ return { owner: 'foreign', reason: 'screen-info-unreadable' };
131
+ }
21
132
 
22
- Not yet implemented.`;
133
+ /**
134
+ * Refusal message for a non-native viewport target, stating only what was
135
+ * measured: a recorder-held target, a DPR that matched no display (an override
136
+ * is present but unreadable), or screen info that could not be read at all (no
137
+ * override may exist — the read simply failed).
138
+ */
139
+ function viewportRefusal(ownership: Exclude<ViewportOwnership, { owner: 'native' }>): string {
140
+ if (ownership.owner === 'recorder') {
141
+ return '--viewport cannot replace a recorder-owned device-metrics override because CDP cannot read and restore it';
142
+ }
143
+ if (ownership.reason === 'dpr-matched-no-display') {
144
+ return '--viewport cannot replace a foreign-owned device-metrics override because CDP cannot read and restore it';
145
+ }
146
+ return '--viewport cannot confirm device-metrics override ownership because the browser screen information could not be read';
147
+ }
148
+
149
+ export async function withAppliedViewport<T>(client: ViewportClient, viewport: ViewportSpec | undefined, fn: () => Promise<T>): Promise<T> {
150
+ if (!viewport) return fn();
151
+ const ownership = await viewportOwnership(client);
152
+ if (ownership.owner !== 'native') {
153
+ throw captureError('precondition', 'viewport_unavailable', viewportRefusal(ownership));
154
+ }
155
+ let ownsDeviceMetricsOverride = false;
156
+ let primaryFailed = false;
157
+ let primaryError: unknown;
158
+ try {
159
+ // A rejected response does not prove that Chrome rejected the request.
160
+ // Claim cleanup responsibility before awaiting the override request.
161
+ ownsDeviceMetricsOverride = true;
162
+ await client.send('Emulation.setDeviceMetricsOverride', {
163
+ width: viewport.width,
164
+ height: viewport.height,
165
+ deviceScaleFactor: 1,
166
+ mobile: false,
167
+ });
168
+ return await fn();
169
+ } catch (error) {
170
+ primaryFailed = true;
171
+ primaryError = error;
172
+ throw error;
173
+ } finally {
174
+ if (ownsDeviceMetricsOverride) {
175
+ try {
176
+ await client.send('Emulation.clearDeviceMetricsOverride');
177
+ } catch (cleanupError) {
178
+ if (primaryFailed) {
179
+ throw new AggregateError(
180
+ [primaryError, cleanupError],
181
+ 'Viewport capture failed and device-metrics cleanup also failed.',
182
+ { cause: primaryError },
183
+ );
184
+ }
185
+ throw cleanupError;
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ function settledNote(settled: boolean, captured: boolean, settleMs: number, settleTimeoutMs: number, unstableCount: number) {
192
+ if (settled) {
193
+ return fact`Settled after ${settleMs}ms (two consecutive identical captures; DOM quiet ≥300ms).`;
194
+ }
195
+ if (captured) {
196
+ return fact`Captured after ${settleMs}ms despite non-settlement (--capture-unsettled). Full substrate written; ${unstableCount} unstable region(s) marked.`;
197
+ }
198
+ return fact`Page did not settle within ${settleTimeoutMs}ms. No queryable substrate was written; evidence artifacts only (${unstableCount} unstable region(s)).`;
199
+ }
200
+
201
+ /**
202
+ * Captures one snapshot without emitting it. URL-taking query leaves use this
203
+ * seam rather than importing the substrate directly, preserving snap as the
204
+ * sole live-driving primitive.
205
+ */
206
+ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.positional[0], viewportValue = parsed.viewport): Promise<MeasureSnapCapture> {
207
+ if (parsed.positional.length > 1) {
208
+ throw invalidInput('measure snap accepts at most one positional URL or snapshot reference');
209
+ }
210
+ if (parsed.settleTimeout !== undefined && (!Number.isFinite(parsed.settleTimeout) || parsed.settleTimeout <= 0)) {
211
+ throw invalidInput('--settle-timeout must be a positive number of milliseconds');
212
+ }
213
+ const viewport: ViewportSpec | undefined = viewportValue === undefined
214
+ ? undefined
215
+ : { label: viewportValue, ...parseViewport(viewportValue) };
216
+
217
+ let base: SnapRef | undefined;
218
+ let baseUrl: string | null = null;
219
+ if (targetRef && !isUrlRef(targetRef)) {
220
+ base = await resolveSnapRef(targetRef);
221
+ baseUrl = readMeta<SnapshotMetaForBase>(base).url ?? null;
222
+ }
223
+
224
+ const active = getActiveSession();
225
+ const snapId = generateSnapId();
226
+ let snapDir: string | undefined;
227
+ let allocatedRoot: string | undefined;
228
+
229
+ // A positional URL is an explicit alternate target, matching --url's
230
+ // existing connection semantics. A base snapshot uses the active tab when
231
+ // available; otherwise its recorded URL identifies the tab to re-capture.
232
+ const connectionArgs: ParsedArgs = targetRef && isUrlRef(targetRef)
233
+ ? { ...parsed, url: targetRef, target: undefined, viewport: viewport?.label }
234
+ : !parsed.target && !active?.targetId && baseUrl
235
+ ? { ...parsed, url: baseUrl, viewport: viewport?.label }
236
+ : { ...parsed, viewport: viewport?.label };
237
+
238
+ try {
239
+ let artifacts: readonly string[] = [];
240
+ await withConnection(connectionArgs, async (client, tab) => {
241
+ await withAppliedViewport(client, viewport, async () => {
242
+ const oneshot = active ? undefined : createOneshotSession('measure');
243
+ const destination = active ? path.join(active.dir, 'measure', 'snaps') : oneshot!.artifactsDir;
244
+ snapDir = path.join(destination, snapId);
245
+ allocatedRoot = active ? snapDir : oneshot!.dir;
246
+ const result = await captureSnapshotSubstrate({
247
+ target: { client, tabId: tab.id },
248
+ url: tab.url || baseUrl,
249
+ path: snapDir,
250
+ snapId,
251
+ settleTimeout: parsed.settleTimeout ?? DEFAULT_SETTLE_TIMEOUT_MS,
252
+ freezeAnimations: parsed.freezeAnimations,
253
+ captureUnsettled: parsed.captureUnsettled,
254
+ pixels: parsed.pixels,
255
+ state: parsed.state ?? [],
256
+ viewport: viewport?.label ?? null,
257
+ });
258
+ artifacts = result.artifacts;
259
+ });
260
+ }, { settle: 0 });
261
+
262
+ if (!snapDir) throw new Error('measure snap did not allocate an artifact directory');
263
+ return { id: snapId, dir: snapDir, artifacts, ...(base ? { base } : {}) };
264
+ } catch (err) {
265
+ if (allocatedRoot) removeArtifactTree(allocatedRoot);
266
+ throw err;
267
+ }
268
+ }
269
+
270
+ function buildSnapshotResult(captured: MeasureSnapCapture): RenderableResult {
271
+ const meta = readMeta<{
272
+ url: string | null;
273
+ viewport: string | null;
274
+ settled: boolean;
275
+ settleMs: number;
276
+ settleTimeoutMs: number;
277
+ unstableRegionCount: number;
278
+ states: readonly string[];
279
+ }>({ kind: 'snap', id: captured.id, dir: captured.dir });
280
+ const elements = readArrayCount(path.join(captured.dir, 'geometry.json'), 'elements');
281
+ const artifacts = artifactEntries(captured.dir, captured.artifacts);
282
+ const capturedFullSubstrate = fs.existsSync(path.join(captured.dir, 'geometry.json'));
283
+
284
+ return {
285
+ tag: 'snapshot',
286
+ attestation: {
287
+ kind: 'snapshot',
288
+ id: captured.id,
289
+ path: captured.dir,
290
+ note: settledNote(meta.settled, capturedFullSubstrate, meta.settleMs, meta.settleTimeoutMs, meta.unstableRegionCount),
291
+ },
292
+ attrs: {
293
+ url: meta.url ?? undefined,
294
+ viewport: meta.viewport ?? undefined,
295
+ elements,
296
+ settled: meta.settled,
297
+ 'settle-ms': meta.settleMs,
298
+ ...(captured.base ? { base: sanitizeString(captured.base.id) } : {}),
299
+ ...(meta.states.length ? { states: meta.states.length } : {}),
300
+ },
301
+ summary: meta.settled || capturedFullSubstrate
302
+ ? text`Snapshot substrate captured.`
303
+ : text`The evidence snapshot is not queryable because the settledness requirement was not met.`,
304
+ artifacts: formatArtifactList(artifacts),
305
+ followUp: meta.settled || capturedFullSubstrate
306
+ ? fact`Query snapshot ${captured.id} with \`capture measure check ${captured.id}\`, \`capture measure census --snap ${captured.id} --axis color\`, or \`capture measure map focus ${captured.id}\`.`
307
+ : fact`Re-run with \`--freeze-animations\`, a longer \`--settle-timeout\`, or \`--capture-unsettled\` to choose a queryable capture.`,
308
+ };
309
+ }
310
+
311
+ interface SnapErrorDetails {
312
+ readonly status: string;
313
+ readonly message: string;
314
+ readonly resolution?: ArtifactResolutionError;
315
+ }
316
+
317
+ function safeErrorDetail(value: unknown): string {
318
+ return sanitizeString(value instanceof Error ? value.message : String(value));
319
+ }
320
+
321
+ export function classifySnapError(err: unknown): SnapErrorDetails {
322
+ if (err instanceof ArtifactResolutionError) {
323
+ return { status: 'snapshot_ref_unavailable', message: safeErrorDetail(err), resolution: err };
324
+ }
325
+ const message = safeErrorDetail(err);
326
+ if (message.startsWith('--viewport cannot ')) {
327
+ return { status: 'viewport_unavailable', message };
328
+ }
329
+ if (message.startsWith('measure snap accepts') || message.startsWith('--settle-timeout') || message.startsWith('--viewport')) {
330
+ return { status: 'invalid_input', message };
331
+ }
332
+ if (message.includes('No tab found') || message.includes('Use --target') || message.includes('Tab has no WebSocket debugger URL')) {
333
+ return { status: 'target_unavailable', message };
334
+ }
335
+ if (message.includes('WebSocket') || message.includes('CDP') || message.includes('timeout')) {
336
+ return { status: 'transport_unavailable', message };
337
+ }
338
+ if (message.includes('artifact') || message.includes('capture root')) {
339
+ return { status: 'artifact_unavailable', message };
340
+ }
341
+ return { status: 'capture_failed', message };
342
+ }
343
+
344
+ function cleanupCaptures(captures: readonly MeasureSnapCapture[]): void {
345
+ const roots = new Set<string>();
346
+ for (const capture of captures) {
347
+ const oneShotRoot = path.resolve(capture.dir, '..', '..', '..');
348
+ roots.add(path.basename(oneShotRoot).startsWith('oneshot-') ? oneShotRoot : capture.dir);
349
+ }
350
+ for (const root of roots) removeArtifactTree(root);
351
+ }
352
+
353
+ function errorSections(details: SnapErrorDetails) {
354
+ const sections = [fact`detail: ${details.message}`];
355
+ if (details.resolution) {
356
+ sections.push(
357
+ fact`ref: ${sanitizeString(details.resolution.ref)}`,
358
+ ...details.resolution.searched.map((searched) => fact`searched: ${sanitizeString(searched)}`),
359
+ ...(details.resolution.creatingCommand ? [fact`creating-command: ${sanitizeString(details.resolution.creatingCommand)}`] : []),
360
+ );
361
+ }
362
+ return sections;
363
+ }
23
364
 
24
365
  export async function cmdMeasureSnap(parsed: ParsedArgs, _args: string[]): Promise<void> {
25
366
  if (parsed.help) {
26
367
  console.log(USAGE);
27
- process.exit(0);
368
+ return;
369
+ }
370
+
371
+ const captures: MeasureSnapCapture[] = [];
372
+ try {
373
+ const viewportValues = parsed.viewports?.length ? parsed.viewports : [parsed.viewport];
374
+ // Validate every repeat before allocating the first private artifact tree.
375
+ const viewports = viewportValues.map((value): ViewportSpec | undefined => {
376
+ if (value === undefined) return undefined;
377
+ return { label: value, ...parseViewport(value) };
378
+ });
379
+ const results = [] as RenderableResult[];
380
+ for (const viewport of viewports) {
381
+ const capture = await captureMeasureSnap(parsed, parsed.positional[0], viewport?.label);
382
+ captures.push(capture);
383
+ results.push(buildSnapshotResult(capture));
384
+ }
385
+ if (parsed.json && results.length > 1) {
386
+ process.stdout.write(`${JSON.stringify(results.map((result) => toJsonResult(result)), null, 2)}\n`);
387
+ return;
388
+ }
389
+ if (!parsed.json && results.length > 1) {
390
+ process.stdout.write(`${results.map((result) => renderResult(result)).join('\n')}\n`);
391
+ return;
392
+ }
393
+ emitResult(results[0]!, { json: parsed.json });
394
+ } catch (err) {
395
+ cleanupCaptures(captures);
396
+ const classified = classifySnapError(err);
397
+ const result: RenderableResult = {
398
+ tag: 'error',
399
+ attrs: {
400
+ command: 'measure snap',
401
+ status: classified.status,
402
+ ...(classified.resolution ? {
403
+ recovery: 'artifact-resolution-error',
404
+ ref: sanitizeString(classified.resolution.ref),
405
+ searched: sanitizeString(classified.resolution.searched.join('\n')),
406
+ 'searched-paths': classified.resolution.searched.length,
407
+ 'creating-command': classified.resolution.creatingCommand ? sanitizeString(classified.resolution.creatingCommand) : undefined,
408
+ } : {}),
409
+ },
410
+ summary: text`Snapshot capture did not complete.`,
411
+ sections: errorSections(classified),
412
+ };
413
+ emitResult(result, { json: parsed.json });
414
+ process.exitCode = 1;
28
415
  }
29
- const result: RenderableResult = {
30
- tag: 'error',
31
- attrs: { command: 'measure snap', status: 'not_implemented' },
32
- summary: fact`\`measure snap\` is not implemented yet.`,
33
- };
34
- emitResult(result, { json: parsed.json });
35
- process.exit(1);
36
416
  }