@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.
- package/bin/capture +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|