@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,59 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { captureMeasureSnap } from './snap.js';
3
+ import { buildMeasureMapLayersResult } from '../../measure/map-layers.js';
4
+ import { resolveSnapRef } from '../../../output/artifact.js';
5
+ import { emitResult, fact, text, type RenderableResult } from '../../../output/render.js';
6
+
7
+ const USAGE = `capture measure map layers [url|snap] — paint/compositor facts recorded in a snapshot's layers.json
8
+
9
+ input:
10
+ [url|snap] required target: a URL creates a settled snapshot first; a snapshot id or absolute path is read without re-driving the browser
11
+ output: <layer-map …> — layer bounds, compositing reasons, DOMSnapshot paint order, per-node membership, and available source provenance for layer-affecting declarations; --json mirrors
12
+ effects: read-only over an existing snapshot artifact; a URL target writes one settled snapshot first`;
13
+
14
+ function errorResult(err: unknown): RenderableResult {
15
+ const detail = err instanceof Error ? err.message : String(err);
16
+ return {
17
+ tag: 'error',
18
+ attrs: { command: 'measure map layers', status: 'artifact_unavailable' },
19
+ summary: fact`Layer-map facts could not be read: ${detail}`,
20
+ followUp: text`Pass a settled snapshot id or its absolute artifact path, or pass a URL to create a snapshot first with capture measure snap.`,
21
+ };
22
+ }
23
+
24
+ export async function cmdMeasureMapLayers(parsed: ParsedArgs, _args: string[]): Promise<void> {
25
+ if (parsed.help) {
26
+ console.log(USAGE);
27
+ return;
28
+ }
29
+ if (parsed.positional.length > 1) {
30
+ emitResult({
31
+ tag: 'error',
32
+ attrs: { command: 'measure map layers', status: 'invalid_target' },
33
+ summary: text`measure map layers accepts at most one URL, snapshot id, or absolute snapshot artifact path.`,
34
+ }, { json: parsed.json });
35
+ process.exitCode = 1;
36
+ return;
37
+ }
38
+ const target = parsed.positional[0];
39
+ if (!target) {
40
+ emitResult({
41
+ tag: 'error',
42
+ attrs: { command: 'measure map layers', status: 'missing_target' },
43
+ summary: text`A URL, snapshot id, or absolute snapshot artifact path is required to read layer-map facts.`,
44
+ followUp: text`Capture one with capture measure snap <url>, then pass its snapshot id or artifact path.`,
45
+ }, { json: parsed.json });
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+
50
+ try {
51
+ const ref = await resolveSnapRef(target, {
52
+ onUrl: async (url) => captureMeasureSnap(parsed, url),
53
+ });
54
+ emitResult(buildMeasureMapLayersResult(ref), { json: parsed.json });
55
+ } catch (err) {
56
+ emitResult(errorResult(err), { json: parsed.json });
57
+ process.exitCode = 1;
58
+ }
59
+ }
@@ -0,0 +1,94 @@
1
+ import { type ParsedArgs } from '../../types.js';
2
+ import { captureMeasureSnap } from './snap.js';
3
+ import { ArtifactResolutionError, resolveSnapRef, type SnapUrlResult } from '../../../output/artifact.js';
4
+ import { emitResult, fact, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
5
+ import { measureMapScroll } from '../../measure/map-scroll.js';
6
+
7
+ const USAGE = `capture measure map scroll [url|snap] — scroll-container topology recorded in a snapshot's scroll.json
8
+
9
+ input:
10
+ [url|snap] required target: a URL creates a settled snapshot first; a snapshot id from the active session or an absolute snapshot artifact path is read without re-driving the browser
11
+ output: <scroll-map …> — containers, ranges, current/max offsets, sticky/fixed occupancy, snap points, visual/layout viewport facts, and reachable-content samples; --json mirrors
12
+ effects: read-only over an existing snapshot artifact; a URL target writes one settled snapshot first`;
13
+
14
+ type CaptureSnap = (parsed: ParsedArgs, target: string) => Promise<SnapUrlResult>;
15
+ type Emit = typeof emitResult;
16
+
17
+ interface MapScrollDeps {
18
+ readonly captureSnap?: CaptureSnap;
19
+ readonly emit?: Emit;
20
+ }
21
+
22
+ function artifactRecovery(err: ArtifactResolutionError): FactLine[] {
23
+ return [
24
+ fact`artifact resolution error: ${err.message}`,
25
+ fact`received ref: ${err.ref}`,
26
+ fact`searched path count: ${err.searched.length}`,
27
+ ...err.searched.map((candidate) => fact`searched path: ${candidate}`),
28
+ fact`creating command: ${err.creatingCommand ?? 'capture measure snap'}`,
29
+ ];
30
+ }
31
+
32
+ export async function runMeasureMapScroll(parsed: ParsedArgs, deps: MapScrollDeps = {}): Promise<void> {
33
+ const captureSnap = deps.captureSnap ?? captureMeasureSnap;
34
+ const emit = deps.emit ?? emitResult;
35
+
36
+ if (parsed.help) {
37
+ console.log(USAGE);
38
+ return;
39
+ }
40
+
41
+ if (parsed.positional.length > 1) {
42
+ const result: RenderableResult = {
43
+ tag: 'error',
44
+ attrs: { command: 'measure map scroll', status: 'invalid_target' },
45
+ summary: text`Measure map scroll accepts one URL, snapshot id, or absolute snapshot path.`,
46
+ followUp: text`Pass one target, for example: \`capture measure map scroll snap-a3f2\`.`,
47
+ };
48
+ emit(result, { json: parsed.json });
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+
53
+ const target = parsed.positional[0] ?? parsed.url;
54
+ if (!target) {
55
+ const result: RenderableResult = {
56
+ tag: 'error',
57
+ attrs: { command: 'measure map scroll', status: 'missing_target' },
58
+ summary: text`No snapshot or URL target was received.`,
59
+ followUp: text`Create a snapshot with \`capture measure snap <url>\`, then pass its id or absolute path to \`capture measure map scroll\`.`,
60
+ };
61
+ emit(result, { json: parsed.json });
62
+ process.exitCode = 1;
63
+ return;
64
+ }
65
+
66
+ try {
67
+ const ref = await resolveSnapRef(target, {
68
+ onUrl: async (url) => captureSnap({ ...parsed, positional: [url] }, url),
69
+ });
70
+ emit(measureMapScroll(ref), { json: parsed.json });
71
+ } catch (err) {
72
+ const detail = err instanceof Error ? err.message : String(err);
73
+ const recovery = err instanceof ArtifactResolutionError ? artifactRecovery(err) : [];
74
+ const result: RenderableResult = {
75
+ tag: 'error',
76
+ attrs: {
77
+ command: 'measure map scroll',
78
+ status: 'artifact_unavailable',
79
+ recovery: err instanceof ArtifactResolutionError ? 'artifact-resolution-error' : undefined,
80
+ 'searched-paths': err instanceof ArtifactResolutionError ? err.searched.length : undefined,
81
+ 'creating-command': err instanceof ArtifactResolutionError ? (err.creatingCommand ?? 'capture measure snap') : undefined,
82
+ },
83
+ summary: fact`Scroll topology could not be read: ${detail}`,
84
+ sections: recovery.length ? [lineList(recovery)] : undefined,
85
+ followUp: text`Create a settled snapshot with \`capture measure snap <url>\`; it writes scroll.json for this query.`,
86
+ };
87
+ emit(result, { json: parsed.json });
88
+ process.exitCode = 1;
89
+ }
90
+ }
91
+
92
+ export async function cmdMeasureMapScroll(parsed: ParsedArgs, _args: string[]): Promise<void> {
93
+ await runMeasureMapScroll(parsed);
94
+ }
@@ -0,0 +1,416 @@
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 { 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> };
81
+
82
+ type ViewportOwnership =
83
+ | { readonly owner: 'native' }
84
+ | { readonly owner: 'recorder' }
85
+ | { readonly owner: 'foreign'; readonly reason: 'dpr-matched-no-display' | 'screen-info-unreadable' };
86
+
87
+ interface ScreenInfo {
88
+ readonly devicePixelRatio?: unknown;
89
+ }
90
+
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
+ }
132
+
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
+ }
364
+
365
+ export async function cmdMeasureSnap(parsed: ParsedArgs, _args: string[]): Promise<void> {
366
+ if (parsed.help) {
367
+ console.log(USAGE);
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;
415
+ }
416
+ }