@crouton-kit/capture 1.3.8 → 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 +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,31 +1,87 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
|
|
1
3
|
import { type ParsedArgs } from '../../types.js';
|
|
2
|
-
import {
|
|
4
|
+
import { captureMeasureSnap } from './snap.js';
|
|
5
|
+
import { ArtifactResolutionError, resolveSnapRef, type SnapRef } from '../../../output/artifact.js';
|
|
6
|
+
import { emitResult, fact, formatArtifactList, text, type RenderableResult } from '../../../output/render.js';
|
|
7
|
+
import { buildCensus, CENSUS_AXES, censusResultLines, type CensusAxis } from '../../measure/census.js';
|
|
3
8
|
|
|
4
|
-
const USAGE = `
|
|
9
|
+
const USAGE = `capture measure census --axis <axis> — value distributions and token-audit facts across one or more settled snapshots
|
|
5
10
|
|
|
6
|
-
|
|
7
|
-
--
|
|
8
|
-
|
|
11
|
+
input:
|
|
12
|
+
--axis <axis> color|font|spacing|radius|shadow|animation|geometry|media|queries (required)
|
|
13
|
+
--snap <id|path> existing snapshot id or absolute artifact path (repeatable)
|
|
14
|
+
--url <url> URL to snap first (repeatable)
|
|
15
|
+
--set-file <path> file listing one snapshot id/path or URL per line
|
|
16
|
+
output: <census axis=… snapshots=… distinct=…> — distributions with provenance where recorded, and per-region nondeterminism caveats for facts touching an unsettled capture; --json mirrors
|
|
17
|
+
effects: read-only over existing snapshot artifacts; each --url writes one one-shot snapshot first`;
|
|
9
18
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
--viewport <WxH> Viewport to snap at (repeatable)
|
|
15
|
-
--axis <axis> color|font|spacing|radius|shadow|animation|geometry|media|queries
|
|
19
|
+
function targetsFromFile(filePath: string): string[] {
|
|
20
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
21
|
+
return raw.split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
|
|
22
|
+
}
|
|
16
23
|
|
|
17
|
-
|
|
24
|
+
function isAxis(value: string | undefined): value is CensusAxis {
|
|
25
|
+
return value !== undefined && (CENSUS_AXES as readonly string[]).includes(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function resolveTargets(parsed: ParsedArgs): Promise<SnapRef[]> {
|
|
29
|
+
const targetRefs = [...(parsed.snap ?? []), ...(parsed.urls ?? [])];
|
|
30
|
+
if (parsed.setFile) targetRefs.push(...targetsFromFile(parsed.setFile));
|
|
31
|
+
if (!targetRefs.length) throw new Error('census needs at least one --snap, --url, or --set-file target');
|
|
32
|
+
|
|
33
|
+
const snaps: SnapRef[] = [];
|
|
34
|
+
for (const target of targetRefs) {
|
|
35
|
+
snaps.push(await resolveSnapRef(target, {
|
|
36
|
+
onUrl: async (url) => {
|
|
37
|
+
const captured = await captureMeasureSnap({ ...parsed, positional: [], url, target: undefined }, url);
|
|
38
|
+
return captured;
|
|
39
|
+
},
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
return snaps;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function errorResult(message: string, status: string): RenderableResult {
|
|
46
|
+
return {
|
|
47
|
+
tag: 'error',
|
|
48
|
+
attrs: { command: 'measure census', status },
|
|
49
|
+
summary: fact`Census could not read the requested measurement artifacts: ${message}`,
|
|
50
|
+
followUp: text`Create a settled snapshot with capture measure snap, then pass its id or absolute artifact path.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
18
53
|
|
|
19
54
|
export async function cmdMeasureCensus(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
20
55
|
if (parsed.help) {
|
|
21
56
|
console.log(USAGE);
|
|
22
|
-
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (parsed.positional.length) {
|
|
60
|
+
emitResult(errorResult('this leaf takes targets only through repeatable --snap and --url flags.', 'invalid_input'), { json: parsed.json });
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!isAxis(parsed.axis)) {
|
|
65
|
+
emitResult(errorResult(`--axis must be one of ${CENSUS_AXES.join('|')}; received ${parsed.axis ?? '(missing)'}.`, 'invalid_axis'), { json: parsed.json });
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const snapshots = await resolveTargets(parsed);
|
|
72
|
+
const report = buildCensus(parsed.axis, snapshots);
|
|
73
|
+
const result: RenderableResult = {
|
|
74
|
+
tag: 'census',
|
|
75
|
+
attrs: { axis: report.axis, snapshots: snapshots.length, distinct: report.distinct },
|
|
76
|
+
summary: fact`Distribution facts were measured from ${snapshots.length} snapshot artifact(s).`,
|
|
77
|
+
artifacts: formatArtifactList(snapshots.map((snap) => ({ name: snap.dir, note: snap.id }))),
|
|
78
|
+
sections: [censusResultLines(report)],
|
|
79
|
+
followUp: fact`Use capture measure explain ${snapshots[0].id} --selector <selector> for one element's recorded provenance.`,
|
|
80
|
+
};
|
|
81
|
+
emitResult(result, { json: parsed.json });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
const detail = err instanceof ArtifactResolutionError || err instanceof Error ? err.message : String(err);
|
|
84
|
+
emitResult(errorResult(detail, 'artifact_unavailable'), { json: parsed.json });
|
|
85
|
+
process.exitCode = 1;
|
|
23
86
|
}
|
|
24
|
-
const result: RenderableResult = {
|
|
25
|
-
tag: 'error',
|
|
26
|
-
attrs: { command: 'measure census', status: 'not_implemented' },
|
|
27
|
-
summary: fact`\`measure census\` is not implemented yet.`,
|
|
28
|
-
};
|
|
29
|
-
emitResult(result, { json: parsed.json });
|
|
30
|
-
process.exit(1);
|
|
31
87
|
}
|
|
@@ -1,35 +1,109 @@
|
|
|
1
1
|
import { type ParsedArgs } from '../../types.js';
|
|
2
|
-
import {
|
|
2
|
+
import { resolveSnapRef, readMeta, type ArtifactResolutionError } from '../../../output/artifact.js';
|
|
3
|
+
import { emitResult, fact, line, text, formatFindings, type FindingInput, type RenderableResult } from '../../../output/render.js';
|
|
4
|
+
import { captureMeasureSnap } from './snap.js';
|
|
5
|
+
import { checkSnapshot, parseChecks, writeFindingCrop } from '../../measure/check.js';
|
|
3
6
|
|
|
4
|
-
const USAGE = `
|
|
7
|
+
const USAGE = `capture measure check [url|snap] — read threshold/fact measurements from a settled snapshot
|
|
5
8
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
input:
|
|
10
|
+
[url|snap] a URL creates one one-shot snapshot first; a snap id or absolute path is read without re-driving the browser
|
|
11
|
+
--for <checks> geometry|content|targetability|forms|animation|all, or comma-separated overlap,offscreen,overflow,tap-targets,contrast,hit-test,truncation,forms,media,animation
|
|
12
|
+
--limit <n> render at most n representative findings in prose (default: 20; --json retains all findings)
|
|
13
|
+
--gate exit 2 when the report contains findings (default: exit 0)
|
|
14
|
+
output: <checks result=… findings=…> — findings with coordinates and collection provenance; measurements, not a pass/fail judgment; --json mirrors
|
|
15
|
+
effects: read-only over an existing snapshot artifact; a URL target writes one one-shot snapshot first`;
|
|
10
16
|
|
|
11
|
-
|
|
12
|
-
--for <checks> Comma-separated check names (e.g.
|
|
13
|
-
overlap,offscreen,overflow,tap-targets,contrast,
|
|
14
|
-
hit-test,truncation) or a category
|
|
15
|
-
(geometry|content|targetability|forms|animation|all).
|
|
16
|
-
Omit for every check.
|
|
17
|
-
--viewport <WxH> Viewport to check at (repeatable, for a discrete
|
|
18
|
-
multi-viewport sweep)
|
|
19
|
-
--gate Exit 2 if any check finds something (default: exit 0)
|
|
17
|
+
const DEFAULT_FINDING_LIMIT = 20;
|
|
20
18
|
|
|
21
|
-
|
|
19
|
+
function representativeFindings<T extends { kind: string }>(findings: readonly T[], limit: number): T[] {
|
|
20
|
+
const byKind = new Map<string, T[]>();
|
|
21
|
+
for (const finding of findings) {
|
|
22
|
+
const group = byKind.get(finding.kind) ?? [];
|
|
23
|
+
group.push(finding);
|
|
24
|
+
byKind.set(finding.kind, group);
|
|
25
|
+
}
|
|
26
|
+
const displayed: T[] = [];
|
|
27
|
+
for (let index = 0; displayed.length < limit; index++) {
|
|
28
|
+
let added = false;
|
|
29
|
+
for (const group of byKind.values()) {
|
|
30
|
+
const finding = group[index];
|
|
31
|
+
if (!finding) continue;
|
|
32
|
+
displayed.push(finding);
|
|
33
|
+
added = true;
|
|
34
|
+
if (displayed.length === limit) break;
|
|
35
|
+
}
|
|
36
|
+
if (!added) break;
|
|
37
|
+
}
|
|
38
|
+
return displayed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rollup(findings: readonly { kind: string }[]) {
|
|
42
|
+
const counts = new Map<string, number>();
|
|
43
|
+
for (const finding of findings) counts.set(finding.kind, (counts.get(finding.kind) ?? 0) + 1);
|
|
44
|
+
return [...counts.entries()].map(([kind, count]) => fact`${kind}=${count}`).reduce<ReturnType<typeof fact> | undefined>((line, entry) => line ? [...line, ...text`, `, ...entry] : entry, undefined);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function caveatLine(caveats: readonly { regionId: string; selector?: string; reason?: string }[]) {
|
|
48
|
+
if (!caveats.length) return undefined;
|
|
49
|
+
return fact`Nondeterminism caveat: unstable region ${caveats.map((c) => `${c.regionId}${c.selector ? ` (${c.selector})` : ''}${c.reason ? `: ${c.reason}` : ''}`).join('; ')}.`;
|
|
50
|
+
}
|
|
22
51
|
|
|
23
52
|
export async function cmdMeasureCheck(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
24
|
-
if (parsed.help) {
|
|
25
|
-
|
|
26
|
-
|
|
53
|
+
if (parsed.help) { console.log(USAGE); return; }
|
|
54
|
+
if (parsed.positional.length > 1) {
|
|
55
|
+
emitResult({ tag: 'error', attrs: { command: 'measure check', status: 'invalid_input' }, summary: text`measure check accepts at most one URL or snapshot reference.` }, { json: parsed.json });
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const target = parsed.positional[0];
|
|
61
|
+
if (!target) throw new Error('missing snapshot target; pass a snapshot id/path or URL');
|
|
62
|
+
const checks = parseChecks(parsed.for);
|
|
63
|
+
const ref = await resolveSnapRef(target, { onUrl: async (url) => captureMeasureSnap({ ...parsed, positional: [url] }, url) });
|
|
64
|
+
const report = checkSnapshot(ref, checks);
|
|
65
|
+
const proseFindings = representativeFindings(report.findings, parsed.limit ?? DEFAULT_FINDING_LIMIT);
|
|
66
|
+
const findingsForOutput = parsed.json ? report.findings : proseFindings;
|
|
67
|
+
const withCrops = findingsForOutput.map((finding, index) => ({ ...finding, crop: writeFindingCrop(ref, finding, index) }));
|
|
68
|
+
const meta = readMeta<{ settled: boolean; capturedAt?: string }> (ref);
|
|
69
|
+
const findingSections: FindingInput[] = withCrops.map((finding) => ({
|
|
70
|
+
kind: finding.kind,
|
|
71
|
+
headline: fact`${finding.detail}`,
|
|
72
|
+
detail: [
|
|
73
|
+
...(finding.provenance ? [fact`Provenance: ${finding.provenance}`] : []),
|
|
74
|
+
...(caveatLine(finding.caveats) ? [caveatLine(finding.caveats)!] : []),
|
|
75
|
+
],
|
|
76
|
+
artifactPath: finding.crop,
|
|
77
|
+
}));
|
|
78
|
+
const result: RenderableResult = {
|
|
79
|
+
tag: 'checks',
|
|
80
|
+
attestation: {
|
|
81
|
+
kind: 'snapshot', id: ref.id, path: ref.dir,
|
|
82
|
+
note: meta.settled ? text`Measurements read from a settled snapshot.` : text`Measurements read from a queryable unsettled snapshot; affected facts carry per-region nondeterminism caveats.`,
|
|
83
|
+
},
|
|
84
|
+
attrs: { result: report.findings.length ? 'findings' : 'clean', checks: checks.join(','), elements: report.elementCount, findings: report.findings.length, displayed: withCrops.length, settled: report.settled },
|
|
85
|
+
summary: report.findings.length
|
|
86
|
+
? fact`${report.findings.length} measured fact(s) matched the selected thresholds in viewport ${report.viewport.width}×${report.viewport.height}; ${withCrops.length} representative record(s) rendered.`
|
|
87
|
+
: fact`No selected measurement threshold was crossed among ${report.elementCount} recorded elements in viewport ${report.viewport.width}×${report.viewport.height}.`,
|
|
88
|
+
sections: [
|
|
89
|
+
...(report.findings.length ? [line(text`Finding counts: `, rollup(report.findings)!)] : []),
|
|
90
|
+
...formatFindings(findingSections),
|
|
91
|
+
],
|
|
92
|
+
followUp: parsed.json
|
|
93
|
+
? fact`All ${report.findings.length} measured finding record(s) are included in this JSON result.`
|
|
94
|
+
: fact`Use --json to read all ${report.findings.length} measured finding record(s) from this snapshot.`,
|
|
95
|
+
};
|
|
96
|
+
emitResult(result, { json: parsed.json });
|
|
97
|
+
if (parsed.gate && report.findings.length) process.exitCode = 2;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const resolution = err as Partial<ArtifactResolutionError>;
|
|
100
|
+
const detail = err instanceof Error ? err.message : 'unknown artifact read failure';
|
|
101
|
+
emitResult({
|
|
102
|
+
tag: 'error',
|
|
103
|
+
attrs: { command: 'measure check', status: resolution.name === 'ArtifactResolutionError' ? 'artifact_unavailable' : 'check_failed' },
|
|
104
|
+
summary: fact`Measure check could not read the requested artifact: ${detail}`,
|
|
105
|
+
followUp: text`Create a settled snapshot with capture measure snap <url>, then pass its id or absolute path.`,
|
|
106
|
+
}, { json: parsed.json });
|
|
107
|
+
process.exitCode = 1;
|
|
27
108
|
}
|
|
28
|
-
const result: RenderableResult = {
|
|
29
|
-
tag: 'error',
|
|
30
|
-
attrs: { command: 'measure check', status: 'not_implemented' },
|
|
31
|
-
summary: fact`\`measure check\` is not implemented yet.`,
|
|
32
|
-
};
|
|
33
|
-
emitResult(result, { json: parsed.json });
|
|
34
|
-
process.exit(1);
|
|
35
109
|
}
|
|
@@ -1,32 +1,120 @@
|
|
|
1
1
|
import { type ParsedArgs } from '../../types.js';
|
|
2
|
-
import {
|
|
2
|
+
import { resolveSnapRef, ArtifactResolutionError } from '../../../output/artifact.js';
|
|
3
|
+
import { diffSnapshots, type ElementDiff } from '../../measure/diff.js';
|
|
4
|
+
import { emitResult, fact, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
|
|
3
5
|
|
|
4
|
-
const USAGE = `
|
|
6
|
+
const USAGE = `capture measure diff — structured before/after diff over two settled snapshots
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
input:
|
|
9
|
+
--before <snap> earlier snapshot id or absolute path (required; no positional target — both snapshots must already exist)
|
|
10
|
+
--after <snap> later snapshot id or absolute path (required)
|
|
11
|
+
--pixels write and report a full-raster PNG diff
|
|
12
|
+
--full include state-matrix and unchanged per-element records
|
|
13
|
+
--gate exit 2 if a measured delta exists (default: exit 0)
|
|
14
|
+
output: <diff …> — style, geometry, text, form, and media deltas, cascade provenance, and geometry movement/size facts; --json mirrors
|
|
15
|
+
effects: read-only over the two snapshot artifacts; --pixels additionally writes the reported raster-diff PNG`;
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
function caveatLine(caveats: readonly { regionId: string; selector?: string; reason?: string; snapshot: 'before' | 'after' }[]): FactLine | undefined {
|
|
18
|
+
if (!caveats.length) return undefined;
|
|
19
|
+
return fact`nondeterminism caveat: ${caveats.map((caveat) => `${caveat.snapshot} snapshot ${caveat.regionId}${caveat.selector ? ` (${caveat.selector})` : ''}${caveat.reason ? `: ${caveat.reason}` : ''}`).join('; ')}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function recordLine(record: ElementDiff, full: boolean): FactLine {
|
|
23
|
+
const selector = record.selector ?? record.key;
|
|
24
|
+
const facts: string[] = [];
|
|
25
|
+
if (record.styleDeltas.length) facts.push(`style properties ${record.styleDeltas.map((delta) => `${delta.property} ${String(delta.before)}→${String(delta.after)}`).join(', ')}`);
|
|
26
|
+
if (record.geometryChanged) facts.push(`geometry changed${record.reflow ? ' (position/size delta)' : ''}`);
|
|
27
|
+
if (record.textChanged) facts.push('text facts changed');
|
|
28
|
+
if (record.formChanged) facts.push('form facts changed');
|
|
29
|
+
if (record.mediaChanged) facts.push('media facts changed');
|
|
30
|
+
if (!facts.length && full) facts.push('no compared delta');
|
|
31
|
+
return fact`${selector} — ${facts.join('; ')}`;
|
|
32
|
+
}
|
|
17
33
|
|
|
18
|
-
|
|
34
|
+
function provenanceLines(records: readonly ElementDiff[]): FactLine[] {
|
|
35
|
+
const lines: FactLine[] = [];
|
|
36
|
+
for (const record of records) {
|
|
37
|
+
for (const provenance of record.provenance) {
|
|
38
|
+
lines.push(fact`${record.selector ?? record.key} ${provenance.property}: cascade provenance before=${JSON.stringify(provenance.beforeProvenance ?? null)} after=${JSON.stringify(provenance.afterProvenance ?? null)}${provenance.changed ? '' : provenance.declarationChanged ? ' (unchanged computed value; winning declaration changed)' : ' (unchanged value)'}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return lines;
|
|
42
|
+
}
|
|
19
43
|
|
|
20
44
|
export async function cmdMeasureDiff(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
21
45
|
if (parsed.help) {
|
|
22
46
|
console.log(USAGE);
|
|
23
|
-
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (parsed.positional.length > 0 || !parsed.before || !parsed.after) {
|
|
50
|
+
const result: RenderableResult = {
|
|
51
|
+
tag: 'error',
|
|
52
|
+
attrs: { command: 'measure diff', status: 'invalid_input' },
|
|
53
|
+
summary: text`This command requires --before SNAP and --after SNAP and accepts no positional target.`,
|
|
54
|
+
followUp: text`Use capture measure diff --before SNAP --after SNAP.`,
|
|
55
|
+
};
|
|
56
|
+
emitResult(result, { json: parsed.json });
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const before = await resolveSnapRef(parsed.before);
|
|
63
|
+
const after = await resolveSnapRef(parsed.after);
|
|
64
|
+
const report = diffSnapshots(before, after, { pixels: parsed.pixels, full: parsed.full });
|
|
65
|
+
const changedRecords = report.changes.filter((record) => record.geometryChanged || record.textChanged || record.formChanged || record.mediaChanged || record.styleDeltas.length > 0);
|
|
66
|
+
const recordLines = report.changes
|
|
67
|
+
.filter((record) => Boolean(parsed.full) || record.geometryChanged || record.textChanged || record.formChanged || record.mediaChanged || record.styleDeltas.length > 0)
|
|
68
|
+
.map((record) => recordLine(record, Boolean(parsed.full)));
|
|
69
|
+
const caveats = report.changes.flatMap((record) => record.caveats);
|
|
70
|
+
const sections: FactLine[] = [];
|
|
71
|
+
if (recordLines.length) sections.push(lineList(recordLines));
|
|
72
|
+
const provenance = provenanceLines(report.changes);
|
|
73
|
+
if (provenance.length) sections.push(lineList(provenance));
|
|
74
|
+
if (parsed.full) {
|
|
75
|
+
sections.push(fact`state-matrix records: ${report.stateDeltas.length}; changed state records: ${report.stateDeltas.filter((state) => state.changed).length}.`);
|
|
76
|
+
if (report.stateDeltas.length) sections.push(lineList(report.stateDeltas.map((state) => fact`${state.key} — ${state.changed ? 'state delta' : 'no state delta'}`)));
|
|
77
|
+
}
|
|
78
|
+
if (report.raster) {
|
|
79
|
+
const raster = report.raster.outcome;
|
|
80
|
+
sections.push(raster.ok
|
|
81
|
+
? fact`Raster diff: ${raster.diffPixelCount} changed pixels across ${raster.width}×${raster.height}; diff artifact ${report.raster.path}. Raster regions without a geometry delta: ${report.raster.unexplainedRegions}.`
|
|
82
|
+
: fact`Raster diff was not computed: ${raster.message}.`);
|
|
83
|
+
if (raster.ok && report.raster.regions.length) {
|
|
84
|
+
sections.push(lineList(report.raster.regions.map((region) => fact`raster region x=${region.x} y=${region.y} w=${region.w} h=${region.h} changed-pixels=${region.changedPixels} explained-by-geometry=${String(region.explainedByGeometry)}`)));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const caveat = caveatLine(caveats);
|
|
88
|
+
if (caveat) sections.push(caveat);
|
|
89
|
+
|
|
90
|
+
const result: RenderableResult = {
|
|
91
|
+
tag: 'diff',
|
|
92
|
+
attestation: {
|
|
93
|
+
kind: 'snapshot',
|
|
94
|
+
id: after.id,
|
|
95
|
+
path: after.dir,
|
|
96
|
+
note: fact`Compared before snapshot ${before.id} (settled=${String(report.beforeMeta.settled ?? 'unknown')}) with after snapshot ${after.id} (settled=${String(report.afterMeta.settled ?? 'unknown')}).`,
|
|
97
|
+
},
|
|
98
|
+
attrs: { before: before.id, after: after.id, 'changed-elements': changedRecords.length, ...(report.raster?.outcome.ok ? { 'raster-regions': report.raster.unexplainedRegions } : {}) },
|
|
99
|
+
summary: report.changed
|
|
100
|
+
? fact`${changedRecords.length} element record(s) contain measured style, geometry, text, form, or media deltas.`
|
|
101
|
+
: text`No measured style, geometry, text, form, media, state, or requested raster delta was recorded.`,
|
|
102
|
+
sections,
|
|
103
|
+
followUp: parsed.full
|
|
104
|
+
? fact`Inspect a changed element with capture measure explain ${after.id} --selector SELECTOR.`
|
|
105
|
+
: fact`Use --full for state-matrix and unchanged per-element provenance records.`,
|
|
106
|
+
};
|
|
107
|
+
emitResult(result, { json: parsed.json });
|
|
108
|
+
if (parsed.gate && report.changed) process.exitCode = 2;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const detail = error instanceof ArtifactResolutionError ? error.message : error instanceof Error ? error.message : String(error);
|
|
111
|
+
const result: RenderableResult = {
|
|
112
|
+
tag: 'error',
|
|
113
|
+
attrs: { command: 'measure diff', status: 'artifact_unavailable' },
|
|
114
|
+
summary: fact`The requested snapshot comparison could not be read: ${detail}`,
|
|
115
|
+
followUp: text`Create settled snapshots with capture measure snap, then pass their ids or absolute artifact paths.`,
|
|
116
|
+
};
|
|
117
|
+
emitResult(result, { json: parsed.json });
|
|
118
|
+
process.exitCode = 1;
|
|
24
119
|
}
|
|
25
|
-
const result: RenderableResult = {
|
|
26
|
-
tag: 'error',
|
|
27
|
-
attrs: { command: 'measure diff', status: 'not_implemented' },
|
|
28
|
-
summary: fact`\`measure diff\` is not implemented yet.`,
|
|
29
|
-
};
|
|
30
|
-
emitResult(result, { json: parsed.json });
|
|
31
|
-
process.exit(1);
|
|
32
120
|
}
|
|
@@ -1,31 +1,133 @@
|
|
|
1
1
|
import { type ParsedArgs } from '../../types.js';
|
|
2
|
-
import {
|
|
2
|
+
import { explainSnapshot, type ExplainMissingSelector } from '../../measure/explain.js';
|
|
3
|
+
import { ArtifactResolutionError, resolveSnapRef } from '../../../output/artifact.js';
|
|
4
|
+
import { data, emitResult, fact, line, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
|
|
3
5
|
|
|
4
|
-
const USAGE = `
|
|
6
|
+
const USAGE = `capture measure explain <snap> --selector <sel> — per-element cascade/stacking/clipping explanation over one snapshot
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
input:
|
|
9
|
+
<snap> snapshot id in the active session or absolute artifact path (required)
|
|
10
|
+
--selector <sel> element selector (required): CSS, backend:<id>, axid:<id>, ax:<name>, or text:<text>
|
|
11
|
+
--size include size/layout provenance (box/flex/grid/constraints)
|
|
12
|
+
--text include text line/baseline/font/wrap metrics
|
|
13
|
+
--form include form value/geometry/caret/selection/autofill facts
|
|
14
|
+
output: <explain selector=… matches=…> — the compact element explanation, plus one section per requested detail flag; --json mirrors
|
|
15
|
+
effects: read-only — reads existing snapshot artifacts, never drives the browser`;
|
|
8
16
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
function attestation(ref: { id: string; dir: string }, meta: { settled?: boolean; settleMs?: number }) {
|
|
18
|
+
return {
|
|
19
|
+
kind: 'snapshot' as const,
|
|
20
|
+
id: ref.id,
|
|
21
|
+
path: ref.dir,
|
|
22
|
+
note: meta.settled === false
|
|
23
|
+
? text`Snapshot was captured unsettled; each explanation fact intersecting an unstable region carries its own nondeterminism caveat.`
|
|
24
|
+
: meta.settled === true
|
|
25
|
+
? fact`Snapshot settled${meta.settleMs === undefined ? '' : ` after ${meta.settleMs}ms`}.`
|
|
26
|
+
: text`Snapshot settledness was not recorded.`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function caveatSuffix(caveats: readonly { regionId: string; selector?: string; reason?: string }[]): FactLine | undefined {
|
|
31
|
+
if (!caveats.length) return undefined;
|
|
32
|
+
const entries = caveats.map((caveat) => {
|
|
33
|
+
const parts: FactLine[] = [data(caveat.regionId)];
|
|
34
|
+
if (caveat.selector) parts.push(line(text` (`, data(caveat.selector), text`)`));
|
|
35
|
+
if (caveat.reason) parts.push(line(text`: `, data(caveat.reason)));
|
|
36
|
+
return line(...parts);
|
|
37
|
+
});
|
|
38
|
+
const joined: FactLine[] = [];
|
|
39
|
+
entries.forEach((entry, index) => {
|
|
40
|
+
if (index) joined.push(text`; `);
|
|
41
|
+
joined.push(entry);
|
|
42
|
+
});
|
|
43
|
+
return line(text` — nondeterminism caveat: `, ...joined);
|
|
44
|
+
}
|
|
14
45
|
|
|
15
|
-
|
|
46
|
+
function recoverySections(missing: ExplainMissingSelector): FactLine[] {
|
|
47
|
+
const { candidates, recordCount, kind } = missing.available;
|
|
48
|
+
const label = kind === 'css' ? 'CSS selectors' : `${kind}: selector inputs`;
|
|
49
|
+
const rows = candidates.length
|
|
50
|
+
? candidates.map((candidate, index) => fact`${index + 1}. ${candidate}`)
|
|
51
|
+
: [fact`No recorded ${label} were available from geometry records.`];
|
|
52
|
+
return [
|
|
53
|
+
fact`Nearest recorded ${label}: ${candidates.length} shown from ${recordCount} geometry record(s), ranked by identifier similarity then string distance from the requested selector.`,
|
|
54
|
+
lineList(rows),
|
|
55
|
+
text`All recorded selector and identity facts remain in this snapshot's geometry.json, ax.json, and text.json artifacts.`,
|
|
56
|
+
];
|
|
57
|
+
}
|
|
16
58
|
|
|
17
|
-
|
|
59
|
+
function invalidInput(message: FactLine): RenderableResult {
|
|
60
|
+
return {
|
|
61
|
+
tag: 'error',
|
|
62
|
+
attrs: { command: 'measure explain', status: 'invalid_input' },
|
|
63
|
+
summary: message,
|
|
64
|
+
followUp: text`Run capture measure explain <snap> --selector <CSS|backend:id|axid:id|ax:name|text:value>.`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
18
67
|
|
|
19
68
|
export async function cmdMeasureExplain(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
20
69
|
if (parsed.help) {
|
|
21
70
|
console.log(USAGE);
|
|
22
|
-
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (parsed.positional.length !== 1) {
|
|
74
|
+
emitResult(invalidInput(fact`Expected exactly one snapshot target; received ${parsed.positional.length}.`), { json: parsed.json });
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!parsed.selector) {
|
|
79
|
+
emitResult(invalidInput(text`The --selector flag is required.`), { json: parsed.json });
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const ref = await resolveSnapRef(parsed.positional[0]!);
|
|
86
|
+
const report = explainSnapshot(ref, parsed.selector, { size: parsed.size, text: parsed.text, form: parsed.form });
|
|
87
|
+
if (report.kind === 'missing-selector') {
|
|
88
|
+
emitResult({
|
|
89
|
+
tag: 'error',
|
|
90
|
+
attestation: attestation(report.ref, report.meta),
|
|
91
|
+
attrs: { command: 'measure explain', status: 'missing_selector', selector: report.selector },
|
|
92
|
+
summary: fact`No geometry element matched selector input ${report.selector}. Bounded recovery candidates from this snapshot follow.`,
|
|
93
|
+
sections: recoverySections(report),
|
|
94
|
+
followUp: text`The selector input also accepts CSS, backend:, axid:, ax:, and text: forms.`,
|
|
95
|
+
}, { json: parsed.json });
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const sections = report.sections.map((section) => lineList(section.facts.map(({ fact: item, caveats }) => {
|
|
101
|
+
const suffix = caveatSuffix(caveats);
|
|
102
|
+
return suffix ? line(item.line, suffix) : item.line;
|
|
103
|
+
})));
|
|
104
|
+
const targetSelector = report.element.selector ?? report.element.id;
|
|
105
|
+
const result: RenderableResult = {
|
|
106
|
+
tag: 'explain',
|
|
107
|
+
attestation: attestation(report.ref, report.meta),
|
|
108
|
+
attrs: {
|
|
109
|
+
selector: targetSelector,
|
|
110
|
+
matches: report.matchCount,
|
|
111
|
+
settled: report.meta.settled ?? 'unknown',
|
|
112
|
+
size: Boolean(parsed.size),
|
|
113
|
+
text: Boolean(parsed.text),
|
|
114
|
+
form: Boolean(parsed.form),
|
|
115
|
+
},
|
|
116
|
+
summary: fact`Recorded cascade, stacking, clipping, focus, scroll, query, and state context for ${targetSelector}.`,
|
|
117
|
+
sections,
|
|
118
|
+
followUp: parsed.size || parsed.text || parsed.form
|
|
119
|
+
? text`Re-run against a new snapshot to compare the same recorded measurements after a page change.`
|
|
120
|
+
: text`Add --size, --text, or --form to include only those optional detail sections.`,
|
|
121
|
+
};
|
|
122
|
+
emitResult(result, { json: parsed.json });
|
|
123
|
+
} catch (error) {
|
|
124
|
+
const detail = error instanceof ArtifactResolutionError || error instanceof Error ? error.message : String(error);
|
|
125
|
+
emitResult({
|
|
126
|
+
tag: 'error',
|
|
127
|
+
attrs: { command: 'measure explain', status: 'artifact_unavailable' },
|
|
128
|
+
summary: fact`The requested snapshot explanation could not be read: ${detail}`,
|
|
129
|
+
followUp: text`Create a settled snapshot with capture measure snap, then pass its id or absolute artifact path.`,
|
|
130
|
+
}, { json: parsed.json });
|
|
131
|
+
process.exitCode = 1;
|
|
23
132
|
}
|
|
24
|
-
const result: RenderableResult = {
|
|
25
|
-
tag: 'error',
|
|
26
|
-
attrs: { command: 'measure explain', status: 'not_implemented' },
|
|
27
|
-
summary: fact`\`measure explain\` is not implemented yet.`,
|
|
28
|
-
};
|
|
29
|
-
emitResult(result, { json: parsed.json });
|
|
30
|
-
process.exit(1);
|
|
31
133
|
}
|