@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.
- package/bin/capture +32786 -9989
- package/package.json +9 -3
- package/src/capture.ts +86 -163
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -116
- package/src/cdp/bridge/protocol.ts +187 -1
- package/src/cdp/bridge/server.ts +211 -84
- package/src/cdp/bridge/spawn.ts +110 -1
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +35 -4
- package/src/cdp/commands/cdp.ts +266 -69
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +87 -0
- package/src/cdp/commands/measure/check.ts +109 -0
- package/src/cdp/commands/measure/diff.ts +120 -0
- package/src/cdp/commands/measure/explain.ts +133 -0
- package/src/cdp/commands/measure/index.ts +106 -0
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +52 -0
- package/src/cdp/commands/measure/map-layers.ts +59 -0
- package/src/cdp/commands/measure/map-scroll.ts +94 -0
- package/src/cdp/commands/measure/snap.ts +416 -0
- package/src/cdp/commands/measure/sweep.ts +227 -0
- package/src/cdp/commands/motion/index.ts +61 -0
- package/src/cdp/commands/motion/jank.ts +132 -0
- package/src/cdp/commands/motion/mask.ts +92 -0
- package/src/cdp/commands/motion/rec.ts +534 -0
- package/src/cdp/commands/motion/response.ts +76 -0
- package/src/cdp/commands/motion/timeline.ts +152 -0
- 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/coordinates.ts +158 -0
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +76 -26
- package/src/cdp/domains.ts +75 -0
- 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 +1894 -0
- 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/source-map.ts +309 -0
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +87 -0
- package/src/cdp/types.ts +87 -6
- 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 +526 -0
- package/src/output/diff.ts +111 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +987 -0
- package/src/output/selector.ts +185 -0
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +481 -0
- package/src/session/commands.ts +1237 -368
- 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 +302 -34
- 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 -77
- package/src/tab-session.ts +0 -91
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact resolver + reader + caveat propagation — the shared read-only API
|
|
3
|
+
* every `measure`/`motion` query leaf uses to turn a `[url|snap]`/`<rec>`
|
|
4
|
+
* argument into a validated on-disk artifact, read its collector files, and
|
|
5
|
+
* attach per-region nondeterminism caveats. No query leaf reads a snapshot
|
|
6
|
+
* or recording file directly — everything routes through here so path
|
|
7
|
+
* safety (via U03's `assertUnderCaptureRoot`), missing-artifact recovery
|
|
8
|
+
* messaging, and caveat attachment stay in one place.
|
|
9
|
+
*
|
|
10
|
+
* Contract this module fixes for upstream writers:
|
|
11
|
+
* - `geometry.json` is a JSON object shaped `{ elements: [...],
|
|
12
|
+
* unstableRegions?: UnstableRegion[] }`. `unstableRegions` is present
|
|
13
|
+
* only on a `--capture-unsettled` snapshot; `unstableRegionsFor()` reads
|
|
14
|
+
* it from here — nowhere else.
|
|
15
|
+
* - every per-element record collectors write (in `geometry.json` and
|
|
16
|
+
* elsewhere) should carry the identifying fields in `ElementRecord`
|
|
17
|
+
* (`src/output/selector.ts`) so selector lookups work uniformly across
|
|
18
|
+
* collector files.
|
|
19
|
+
* - `meta.json` always exists for any snapshot/recording directory this
|
|
20
|
+
* resolver is asked to resolve — including an unsettled, evidence-only
|
|
21
|
+
* `measure snap` capture — because it is what `session stop` reads to
|
|
22
|
+
* build the bundle manifest (U03) and what this resolver treats as the
|
|
23
|
+
* marker that a ref actually names something.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import * as fs from 'fs';
|
|
27
|
+
import * as path from 'path';
|
|
28
|
+
|
|
29
|
+
import { assertUnderCaptureRoot, type SnapMeta, type RecMeta } from '../session/artifacts.js';
|
|
30
|
+
import { getActiveSession } from '../session-context.js';
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Refs
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
export interface SnapRef {
|
|
37
|
+
readonly kind: 'snap';
|
|
38
|
+
readonly id: string;
|
|
39
|
+
/** Absolute path to `measure/snaps/{id}` (or the oneshot equivalent). */
|
|
40
|
+
readonly dir: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RecRef {
|
|
44
|
+
readonly kind: 'rec';
|
|
45
|
+
readonly id: string;
|
|
46
|
+
/** Absolute path to `motion/recs/{id}` (or the oneshot equivalent). */
|
|
47
|
+
readonly dir: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ArtifactRef = SnapRef | RecRef;
|
|
51
|
+
|
|
52
|
+
/** What a pluggable URL-snap callback must return. */
|
|
53
|
+
export interface SnapUrlResult {
|
|
54
|
+
readonly id: string;
|
|
55
|
+
readonly dir: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Invoked by {@link resolveSnapRef} when the ref is a URL. Query leaves that
|
|
60
|
+
* accept a URL target (per the design's `[url|snap]` contract) pass their
|
|
61
|
+
* own `measure snap` invocation wrapped as this callback; leaves that don't
|
|
62
|
+
* accept URL targets simply omit `onUrl`, and a URL ref throws. This
|
|
63
|
+
* indirection exists so this module never imports the `measure snap`
|
|
64
|
+
* command leaf directly — that would create `output/artifact.ts` ->
|
|
65
|
+
* `cdp/commands/measure/snap.ts` -> ... -> `output/artifact.ts` cycles once
|
|
66
|
+
* later units land.
|
|
67
|
+
*/
|
|
68
|
+
export type SnapUrlCallback = (url: string) => Promise<SnapUrlResult>;
|
|
69
|
+
|
|
70
|
+
export interface ResolveSnapOptions {
|
|
71
|
+
readonly onUrl?: SnapUrlCallback;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// Errors
|
|
76
|
+
// ============================================================================
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Every resolution or read failure in this module throws this. The message
|
|
80
|
+
* (and the structured fields) always say three things: what ref was
|
|
81
|
+
* received, where this module looked for it, and — when the answer is
|
|
82
|
+
* "nothing is there yet" — what command creates it.
|
|
83
|
+
*/
|
|
84
|
+
export class ArtifactResolutionError extends Error {
|
|
85
|
+
readonly ref: string;
|
|
86
|
+
readonly searched: readonly string[];
|
|
87
|
+
readonly creatingCommand?: string;
|
|
88
|
+
|
|
89
|
+
constructor(ref: string, searched: readonly string[], detail: string, creatingCommand?: string) {
|
|
90
|
+
const parts = [`could not resolve ref ${JSON.stringify(ref)}: ${detail}`];
|
|
91
|
+
if (searched.length) parts.push(`looked at: ${searched.join(', ')}`);
|
|
92
|
+
if (creatingCommand) parts.push(`create it with: ${creatingCommand}`);
|
|
93
|
+
super(parts.join(' — '));
|
|
94
|
+
this.name = 'ArtifactResolutionError';
|
|
95
|
+
this.ref = ref;
|
|
96
|
+
this.searched = searched;
|
|
97
|
+
this.creatingCommand = creatingCommand;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================================================
|
|
102
|
+
// Ref resolution
|
|
103
|
+
// ============================================================================
|
|
104
|
+
|
|
105
|
+
export function isUrlRef(ref: string): boolean {
|
|
106
|
+
return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(ref);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface RefKindConfig {
|
|
110
|
+
readonly kind: 'snap' | 'rec';
|
|
111
|
+
/** Path segments under a session dir an id resolves through, e.g.
|
|
112
|
+
* `['measure', 'snaps']`. */
|
|
113
|
+
readonly idSegments: readonly [string, string];
|
|
114
|
+
readonly creatingCommand: string;
|
|
115
|
+
readonly label: 'snapshot' | 'recording';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const SNAP_CONFIG: RefKindConfig = {
|
|
119
|
+
kind: 'snap',
|
|
120
|
+
idSegments: ['measure', 'snaps'],
|
|
121
|
+
creatingCommand: 'capture measure snap',
|
|
122
|
+
label: 'snapshot',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const REC_CONFIG: RefKindConfig = {
|
|
126
|
+
kind: 'rec',
|
|
127
|
+
idSegments: ['motion', 'recs'],
|
|
128
|
+
creatingCommand: 'capture motion rec',
|
|
129
|
+
label: 'recording',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
function stripTrailingSep(p: string): string {
|
|
133
|
+
return p.length > 1 && (p.endsWith('/') || p.endsWith(path.sep)) ? p.slice(0, -1) : p;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveRefFromPath(ref: string, config: RefKindConfig): ArtifactRef {
|
|
137
|
+
const resolved = assertUnderCaptureRoot(stripTrailingSep(ref));
|
|
138
|
+
const metaPath = path.join(resolved, 'meta.json');
|
|
139
|
+
if (!fs.existsSync(metaPath)) {
|
|
140
|
+
throw new ArtifactResolutionError(
|
|
141
|
+
ref,
|
|
142
|
+
[resolved],
|
|
143
|
+
`no ${config.label} found at this path (missing meta.json)`,
|
|
144
|
+
config.creatingCommand,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return { kind: config.kind, id: path.basename(resolved), dir: resolved } as ArtifactRef;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveRefFromId(ref: string, config: RefKindConfig): ArtifactRef {
|
|
151
|
+
const active = getActiveSession();
|
|
152
|
+
if (!active) {
|
|
153
|
+
throw new ArtifactResolutionError(
|
|
154
|
+
ref,
|
|
155
|
+
[],
|
|
156
|
+
`no active capture session — a bare id resolves against the active session's ${config.idSegments.join('/')} directory`,
|
|
157
|
+
`capture session start (then re-run), or pass the absolute ${config.label} path printed by ${config.creatingCommand}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
const dir = path.join(active.dir, ...config.idSegments, ref);
|
|
161
|
+
const resolved = assertUnderCaptureRoot(dir);
|
|
162
|
+
const metaPath = path.join(resolved, 'meta.json');
|
|
163
|
+
if (!fs.existsSync(metaPath)) {
|
|
164
|
+
throw new ArtifactResolutionError(
|
|
165
|
+
ref,
|
|
166
|
+
[resolved],
|
|
167
|
+
`no ${config.label} named ${JSON.stringify(ref)} found in the active session (${active.sessionId})`,
|
|
168
|
+
config.creatingCommand,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return { kind: config.kind, id: ref, dir: resolved } as ArtifactRef;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function rejectRelativePath(ref: string): void {
|
|
175
|
+
if (!path.isAbsolute(ref) && (ref.includes('/') || ref.includes(path.sep))) {
|
|
176
|
+
throw new ArtifactResolutionError(
|
|
177
|
+
ref,
|
|
178
|
+
[],
|
|
179
|
+
'relative paths are not accepted — pass an absolute artifact path (as printed by the capturing command) or a bare id',
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Resolves a `measure` leaf's `[url|snap]` target into a {@link SnapRef}:
|
|
186
|
+
* - a URL (`onUrl` must be supplied, or this throws — see
|
|
187
|
+
* {@link SnapUrlCallback});
|
|
188
|
+
* - an absolute path to a snapshot dir (validated under the capture root,
|
|
189
|
+
* must contain `meta.json`);
|
|
190
|
+
* - a bare id (`snap-a3f2`), resolved against the active session's
|
|
191
|
+
* `measure/snaps/` directory.
|
|
192
|
+
*/
|
|
193
|
+
export async function resolveSnapRef(ref: string, options: ResolveSnapOptions = {}): Promise<SnapRef> {
|
|
194
|
+
if (isUrlRef(ref)) {
|
|
195
|
+
if (!options.onUrl) {
|
|
196
|
+
throw new ArtifactResolutionError(ref, [], 'this command does not accept a URL target — pass a snapshot id or path instead');
|
|
197
|
+
}
|
|
198
|
+
const result = await options.onUrl(ref);
|
|
199
|
+
return { kind: 'snap', id: result.id, dir: assertUnderCaptureRoot(result.dir) };
|
|
200
|
+
}
|
|
201
|
+
if (path.isAbsolute(ref)) return resolveRefFromPath(ref, SNAP_CONFIG) as SnapRef;
|
|
202
|
+
rejectRelativePath(ref);
|
|
203
|
+
return resolveRefFromId(ref, SNAP_CONFIG) as SnapRef;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolves a `motion` leaf's `<rec>` target into a {@link RecRef}: an
|
|
208
|
+
* absolute path to a recording dir, or a bare id (`rec-9f31`) resolved
|
|
209
|
+
* against the active session's `motion/recs/` directory. Recordings are
|
|
210
|
+
* never created from a URL ref directly (`motion rec <url> --do ...` is the
|
|
211
|
+
* capture leaf, not a query resolver), so a URL ref always throws.
|
|
212
|
+
*/
|
|
213
|
+
export function resolveRecRef(ref: string): RecRef {
|
|
214
|
+
if (isUrlRef(ref)) {
|
|
215
|
+
throw new ArtifactResolutionError(
|
|
216
|
+
ref,
|
|
217
|
+
[],
|
|
218
|
+
'a recording ref cannot be a URL — record one first with `capture motion rec <url> --do <action>` or `--start`/`--stop`',
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (path.isAbsolute(ref)) return resolveRefFromPath(ref, REC_CONFIG) as RecRef;
|
|
222
|
+
rejectRelativePath(ref);
|
|
223
|
+
return resolveRefFromId(ref, REC_CONFIG) as RecRef;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ============================================================================
|
|
227
|
+
// Readers
|
|
228
|
+
// ============================================================================
|
|
229
|
+
|
|
230
|
+
const CREATING_COMMAND_BY_FILE: Record<string, string> = {
|
|
231
|
+
'geometry.json': 'capture measure snap',
|
|
232
|
+
'styles.json': 'capture measure snap',
|
|
233
|
+
'hittest.json': 'capture measure snap',
|
|
234
|
+
'text.json': 'capture measure snap',
|
|
235
|
+
'forms.json': 'capture measure snap',
|
|
236
|
+
'animation.json': 'capture measure snap',
|
|
237
|
+
'ax.json': 'capture measure snap',
|
|
238
|
+
'media.json': 'capture measure snap',
|
|
239
|
+
'queries.json': 'capture measure snap',
|
|
240
|
+
'focus.json': 'capture measure snap',
|
|
241
|
+
'scroll.json': 'capture measure snap',
|
|
242
|
+
'layers.json': 'capture measure snap',
|
|
243
|
+
'states.json': 'capture measure snap --state <state>',
|
|
244
|
+
'churn.json': 'capture measure snap (written only when the page did not settle)',
|
|
245
|
+
'pixels.json': 'capture measure snap --pixels',
|
|
246
|
+
'dom.html': 'capture measure snap',
|
|
247
|
+
'screenshot.png': 'capture measure snap',
|
|
248
|
+
'meta.json': 'capture measure snap (or capture motion rec for a recording)',
|
|
249
|
+
'rects.jsonl': 'capture motion rec',
|
|
250
|
+
'events.jsonl': 'capture motion rec',
|
|
251
|
+
'markers.json': 'capture motion rec',
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
function creatingCommandFor(filename: string): string | undefined {
|
|
255
|
+
return CREATING_COMMAND_BY_FILE[filename];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Resolves (and, by default, verifies existence of) one artifact file's
|
|
260
|
+
* absolute path within `ref`'s directory. Every reader below is built on
|
|
261
|
+
* this — a query leaf that needs a raw artifact path (a screenshot, a crop
|
|
262
|
+
* source) rather than parsed content can call this directly.
|
|
263
|
+
*/
|
|
264
|
+
export function artifactPath(ref: ArtifactRef, filename: string, opts: { mustExist?: boolean } = {}): string {
|
|
265
|
+
const filePath = assertUnderCaptureRoot(path.join(ref.dir, filename));
|
|
266
|
+
const mustExist = opts.mustExist ?? true;
|
|
267
|
+
if (mustExist && !fs.existsSync(filePath)) {
|
|
268
|
+
throw new ArtifactResolutionError(
|
|
269
|
+
ref.id,
|
|
270
|
+
[filePath],
|
|
271
|
+
`${filename} is not present in ${ref.kind === 'snap' ? 'snapshot' : 'recording'} ${ref.id}`,
|
|
272
|
+
creatingCommandFor(filename),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return filePath;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function artifactExists(ref: ArtifactRef, filename: string): boolean {
|
|
279
|
+
return fs.existsSync(assertUnderCaptureRoot(path.join(ref.dir, filename)));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function readJsonArtifactFile<T>(ref: ArtifactRef, filename: string): T {
|
|
283
|
+
const filePath = artifactPath(ref, filename);
|
|
284
|
+
let raw: string;
|
|
285
|
+
try {
|
|
286
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
287
|
+
} catch (err) {
|
|
288
|
+
throw new ArtifactResolutionError(
|
|
289
|
+
ref.id,
|
|
290
|
+
[filePath],
|
|
291
|
+
`could not read ${filename}: ${err instanceof Error ? err.message : String(err)}`,
|
|
292
|
+
creatingCommandFor(filename),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
return JSON.parse(raw) as T;
|
|
297
|
+
} catch (err) {
|
|
298
|
+
throw new ArtifactResolutionError(
|
|
299
|
+
ref.id,
|
|
300
|
+
[filePath],
|
|
301
|
+
`${filename} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
302
|
+
creatingCommandFor(filename),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function readNdjsonArtifactFile<T>(ref: ArtifactRef, filename: string): T[] {
|
|
308
|
+
const filePath = artifactPath(ref, filename);
|
|
309
|
+
let raw: string;
|
|
310
|
+
try {
|
|
311
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
312
|
+
} catch (err) {
|
|
313
|
+
throw new ArtifactResolutionError(
|
|
314
|
+
ref.id,
|
|
315
|
+
[filePath],
|
|
316
|
+
`could not read ${filename}: ${err instanceof Error ? err.message : String(err)}`,
|
|
317
|
+
creatingCommandFor(filename),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
const lines = raw.split('\n').filter((l) => l.trim().length > 0);
|
|
321
|
+
return lines.map((line, i) => {
|
|
322
|
+
try {
|
|
323
|
+
return JSON.parse(line) as T;
|
|
324
|
+
} catch (err) {
|
|
325
|
+
throw new ArtifactResolutionError(
|
|
326
|
+
ref.id,
|
|
327
|
+
[filePath],
|
|
328
|
+
`${filename} line ${i + 1} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
329
|
+
creatingCommandFor(filename),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function assertKind(ref: ArtifactRef, kind: 'snap' | 'rec', fnName: string): void {
|
|
336
|
+
if (ref.kind !== kind) {
|
|
337
|
+
throw new TypeError(`${fnName} expects a ${kind} ref, got a ${ref.kind} ref (${ref.id})`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** `meta.json` — the one file common to both a snapshot and a recording. */
|
|
342
|
+
export function readMeta<T = SnapMeta | RecMeta>(ref: ArtifactRef): T {
|
|
343
|
+
return readJsonArtifactFile<T>(ref, 'meta.json');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function readGeometry<T = unknown>(ref: SnapRef): T {
|
|
347
|
+
assertKind(ref, 'snap', 'readGeometry');
|
|
348
|
+
return readJsonArtifactFile<T>(ref, 'geometry.json');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function readStyles<T = unknown>(ref: SnapRef): T {
|
|
352
|
+
assertKind(ref, 'snap', 'readStyles');
|
|
353
|
+
return readJsonArtifactFile<T>(ref, 'styles.json');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function readHittest<T = unknown>(ref: SnapRef): T {
|
|
357
|
+
assertKind(ref, 'snap', 'readHittest');
|
|
358
|
+
return readJsonArtifactFile<T>(ref, 'hittest.json');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function readText<T = unknown>(ref: SnapRef): T {
|
|
362
|
+
assertKind(ref, 'snap', 'readText');
|
|
363
|
+
return readJsonArtifactFile<T>(ref, 'text.json');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function readForms<T = unknown>(ref: SnapRef): T {
|
|
367
|
+
assertKind(ref, 'snap', 'readForms');
|
|
368
|
+
return readJsonArtifactFile<T>(ref, 'forms.json');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function readAnimation<T = unknown>(ref: SnapRef): T {
|
|
372
|
+
assertKind(ref, 'snap', 'readAnimation');
|
|
373
|
+
return readJsonArtifactFile<T>(ref, 'animation.json');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function readAx<T = unknown>(ref: SnapRef): T {
|
|
377
|
+
assertKind(ref, 'snap', 'readAx');
|
|
378
|
+
return readJsonArtifactFile<T>(ref, 'ax.json');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function readQueries<T = unknown>(ref: SnapRef): T {
|
|
382
|
+
assertKind(ref, 'snap', 'readQueries');
|
|
383
|
+
return readJsonArtifactFile<T>(ref, 'queries.json');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function readFocus<T = unknown>(ref: SnapRef): T {
|
|
387
|
+
assertKind(ref, 'snap', 'readFocus');
|
|
388
|
+
return readJsonArtifactFile<T>(ref, 'focus.json');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function readScroll<T = unknown>(ref: SnapRef): T {
|
|
392
|
+
assertKind(ref, 'snap', 'readScroll');
|
|
393
|
+
return readJsonArtifactFile<T>(ref, 'scroll.json');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function readLayers<T = unknown>(ref: SnapRef): T {
|
|
397
|
+
assertKind(ref, 'snap', 'readLayers');
|
|
398
|
+
return readJsonArtifactFile<T>(ref, 'layers.json');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function readStates<T = unknown>(ref: SnapRef): T {
|
|
402
|
+
assertKind(ref, 'snap', 'readStates');
|
|
403
|
+
return readJsonArtifactFile<T>(ref, 'states.json');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function readChurn<T = unknown>(ref: SnapRef): T {
|
|
407
|
+
assertKind(ref, 'snap', 'readChurn');
|
|
408
|
+
return readJsonArtifactFile<T>(ref, 'churn.json');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function readPixels<T = unknown>(ref: SnapRef): T {
|
|
412
|
+
assertKind(ref, 'snap', 'readPixels');
|
|
413
|
+
return readJsonArtifactFile<T>(ref, 'pixels.json');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function readMedia<T = unknown>(ref: SnapRef): T {
|
|
417
|
+
assertKind(ref, 'snap', 'readMedia');
|
|
418
|
+
return readJsonArtifactFile<T>(ref, 'media.json');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function readRects<T = unknown>(ref: RecRef): T[] {
|
|
422
|
+
assertKind(ref, 'rec', 'readRects');
|
|
423
|
+
return readNdjsonArtifactFile<T>(ref, 'rects.jsonl');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function readEvents<T = unknown>(ref: RecRef): T[] {
|
|
427
|
+
assertKind(ref, 'rec', 'readEvents');
|
|
428
|
+
return readNdjsonArtifactFile<T>(ref, 'events.jsonl');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function readMarkers<T = unknown>(ref: RecRef): T {
|
|
432
|
+
assertKind(ref, 'rec', 'readMarkers');
|
|
433
|
+
return readJsonArtifactFile<T>(ref, 'markers.json');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ============================================================================
|
|
437
|
+
// Unstable-region caveats — the only sanctioned path to a nondeterminism
|
|
438
|
+
// caveat on a query fact.
|
|
439
|
+
// ============================================================================
|
|
440
|
+
|
|
441
|
+
export interface Rect {
|
|
442
|
+
readonly x: number;
|
|
443
|
+
readonly y: number;
|
|
444
|
+
readonly w: number;
|
|
445
|
+
readonly h: number;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export interface UnstableRegion {
|
|
449
|
+
/** Stable id for the region within this snapshot, e.g. matches the
|
|
450
|
+
* `regionId` a collector stamps onto affected `geometry.json` elements. */
|
|
451
|
+
readonly id: string;
|
|
452
|
+
/** Selector/description identifying the region, when known. */
|
|
453
|
+
readonly selector?: string;
|
|
454
|
+
/** Axis-aligned bounding rect in top-viewport space, when known. */
|
|
455
|
+
readonly rect?: Rect;
|
|
456
|
+
/** Element ids (geometry.json `ElementRecord.id`s) known to be inside
|
|
457
|
+
* this region, when known. */
|
|
458
|
+
readonly elementIds?: readonly string[];
|
|
459
|
+
/** Free-text evidence, e.g. "autoplay repaint every 3.2s". */
|
|
460
|
+
readonly reason?: string;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Reads the unstable regions marked on a `--capture-unsettled` snapshot's
|
|
465
|
+
* `geometry.json` (`{ unstableRegions: UnstableRegion[] }`). Returns `[]`
|
|
466
|
+
* for a fully-settled snapshot (no unstable regions were marked). Throws
|
|
467
|
+
* the normal reader failure if `geometry.json` itself is absent — which
|
|
468
|
+
* only happens for a snapshot that never got a queryable substrate at all
|
|
469
|
+
* (unsettled without `--capture-unsettled`), a state no query leaf should
|
|
470
|
+
* be reading facts from in the first place.
|
|
471
|
+
*/
|
|
472
|
+
export function unstableRegionsFor(ref: SnapRef): UnstableRegion[] {
|
|
473
|
+
const geometry = readGeometry<{ unstableRegions?: UnstableRegion[] }>(ref);
|
|
474
|
+
return geometry.unstableRegions ?? [];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export interface UnstableMatchable {
|
|
478
|
+
/** The element/backend-node id this fact is about, if any. Matched
|
|
479
|
+
* against a region's `elementIds`. */
|
|
480
|
+
readonly elementId?: string;
|
|
481
|
+
/** The fact's own rect in top-viewport space, if any. Matched against a
|
|
482
|
+
* region's `rect` by axis-aligned overlap. */
|
|
483
|
+
readonly rect?: Rect;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export interface UnstableCaveat {
|
|
487
|
+
readonly regionId: string;
|
|
488
|
+
readonly selector?: string;
|
|
489
|
+
readonly reason?: string;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export interface AnnotatedFact<T> {
|
|
493
|
+
readonly fact: T;
|
|
494
|
+
/** Zero or more caveats — empty when the fact touches no unstable
|
|
495
|
+
* region. A fact spanning multiple regions carries one caveat per
|
|
496
|
+
* region. */
|
|
497
|
+
readonly caveats: readonly UnstableCaveat[];
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function rectsOverlap(a: Rect, b: Rect): boolean {
|
|
501
|
+
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Attaches a per-region nondeterminism caveat to every fact whose
|
|
506
|
+
* `elementId` or `rect` falls inside an unstable region. This — together
|
|
507
|
+
* with {@link unstableRegionsFor} — is the only sanctioned way a query leaf
|
|
508
|
+
* marks a fact as derived from an unstable region; leaves must not
|
|
509
|
+
* hand-roll their own "unsettled" warning text.
|
|
510
|
+
*/
|
|
511
|
+
export function annotateUnstableFacts<T extends UnstableMatchable>(
|
|
512
|
+
facts: readonly T[],
|
|
513
|
+
regions: readonly UnstableRegion[],
|
|
514
|
+
): AnnotatedFact<T>[] {
|
|
515
|
+
return facts.map((fact) => {
|
|
516
|
+
const caveats: UnstableCaveat[] = [];
|
|
517
|
+
for (const region of regions) {
|
|
518
|
+
const byElement = fact.elementId !== undefined && (region.elementIds?.includes(fact.elementId) ?? false);
|
|
519
|
+
const byRect = fact.rect !== undefined && region.rect !== undefined && rectsOverlap(fact.rect, region.rect);
|
|
520
|
+
if (byElement || byRect) {
|
|
521
|
+
caveats.push({ regionId: region.id, selector: region.selector, reason: region.reason });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return { fact, caveats };
|
|
525
|
+
});
|
|
526
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raster PNG diff helper — shared by `measure diff --pixels`, snapshot crops,
|
|
3
|
+
* and `motion mask`. Wraps pixelmatch + pngjs (pure JS, esbuild-safe, no
|
|
4
|
+
* native/system binary dependency).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { PNG } from "pngjs";
|
|
10
|
+
import pixelmatch from "pixelmatch";
|
|
11
|
+
|
|
12
|
+
export interface DiffPngsOptions {
|
|
13
|
+
/** Matching threshold (0 to 1); smaller is more sensitive. Default 0.1. */
|
|
14
|
+
threshold?: number;
|
|
15
|
+
/** Whether to skip anti-aliasing detection. */
|
|
16
|
+
includeAA?: boolean;
|
|
17
|
+
/** Opacity of original image in diff output. */
|
|
18
|
+
alpha?: number;
|
|
19
|
+
/** Color of anti-aliased pixels in diff output. */
|
|
20
|
+
aaColor?: [number, number, number];
|
|
21
|
+
/** Color of different pixels in diff output. */
|
|
22
|
+
diffColor?: [number, number, number];
|
|
23
|
+
/** Alternate diff color for dark-on-light vs light-on-dark differences. */
|
|
24
|
+
diffColorAlt?: [number, number, number];
|
|
25
|
+
/** Draw the diff over a transparent background (a mask) instead of the alpha-blended original. */
|
|
26
|
+
diffMask?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DiffPngsResult {
|
|
30
|
+
ok: true;
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
diffPixelCount: number;
|
|
34
|
+
threshold: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DiffPngsDimensionMismatchError {
|
|
38
|
+
ok: false;
|
|
39
|
+
code: "dimension_mismatch";
|
|
40
|
+
message: string;
|
|
41
|
+
before: { width: number; height: number };
|
|
42
|
+
after: { width: number; height: number };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type DiffPngsOutcome = DiffPngsResult | DiffPngsDimensionMismatchError;
|
|
46
|
+
|
|
47
|
+
const DEFAULT_THRESHOLD = 0.1;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reads two PNGs from disk, pixel-diffs them, and writes a diff PNG to `out`.
|
|
51
|
+
*
|
|
52
|
+
* Returns a structured `{ ok: false, code: "dimension_mismatch", ... }` result
|
|
53
|
+
* (never a bare throw) when the two images have different dimensions, since
|
|
54
|
+
* pixelmatch requires equally sized images.
|
|
55
|
+
*/
|
|
56
|
+
export function diffPngs(
|
|
57
|
+
before: string,
|
|
58
|
+
after: string,
|
|
59
|
+
out: string,
|
|
60
|
+
options: DiffPngsOptions = {}
|
|
61
|
+
): DiffPngsOutcome {
|
|
62
|
+
const beforePng = readPng(before);
|
|
63
|
+
const afterPng = readPng(after);
|
|
64
|
+
|
|
65
|
+
if (beforePng.width !== afterPng.width || beforePng.height !== afterPng.height) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
code: "dimension_mismatch",
|
|
69
|
+
message:
|
|
70
|
+
`cannot diff PNGs with mismatched dimensions: ` +
|
|
71
|
+
`before is ${beforePng.width}x${beforePng.height} (${before}), ` +
|
|
72
|
+
`after is ${afterPng.width}x${afterPng.height} (${after})`,
|
|
73
|
+
before: { width: beforePng.width, height: beforePng.height },
|
|
74
|
+
after: { width: afterPng.width, height: afterPng.height },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { width, height } = beforePng;
|
|
79
|
+
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
|
80
|
+
const diff = new PNG({ width, height });
|
|
81
|
+
|
|
82
|
+
const diffPixelCount = pixelmatch(beforePng.data, afterPng.data, diff.data, width, height, {
|
|
83
|
+
threshold,
|
|
84
|
+
includeAA: options.includeAA,
|
|
85
|
+
alpha: options.alpha,
|
|
86
|
+
aaColor: options.aaColor,
|
|
87
|
+
diffColor: options.diffColor,
|
|
88
|
+
diffColorAlt: options.diffColorAlt,
|
|
89
|
+
diffMask: options.diffMask,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
writePngPrivate(out, PNG.sync.write(diff));
|
|
93
|
+
|
|
94
|
+
return { ok: true, width, height, diffPixelCount, threshold };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readPng(pngPath: string): PNG {
|
|
98
|
+
const buf = fs.readFileSync(pngPath);
|
|
99
|
+
return PNG.sync.read(buf);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Writes a PNG buffer to `outPath` with private permissions (dir 0700, file
|
|
104
|
+
* 0600). Callers that already write into a session/oneshot artifact tree
|
|
105
|
+
* created via the shared secure artifact filesystem helpers get private dirs
|
|
106
|
+
* for free; this is a defensive floor for standalone use of `diffPngs`.
|
|
107
|
+
*/
|
|
108
|
+
function writePngPrivate(outPath: string, data: Buffer): void {
|
|
109
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true, mode: 0o700 });
|
|
110
|
+
fs.writeFileSync(outPath, data, { mode: 0o600 });
|
|
111
|
+
}
|