@crouton-kit/capture 1.4.2 → 1.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +187 -0
  2. package/bin/capture +31278 -7312
  3. package/package.json +3 -1
  4. package/src/capture.ts +21 -18
  5. package/src/cdp/args.ts +269 -49
  6. package/src/cdp/bridge/server.ts +6 -5
  7. package/src/cdp/bridge/spawn.ts +41 -97
  8. package/src/cdp/commands/bridge-serve.ts +11 -21
  9. package/src/cdp/commands/cdp.ts +44 -22
  10. package/src/cdp/commands/heap/census.ts +61 -0
  11. package/src/cdp/commands/heap/common.ts +77 -0
  12. package/src/cdp/commands/heap/diff.ts +92 -0
  13. package/src/cdp/commands/heap/index.ts +38 -0
  14. package/src/cdp/commands/heap/objects.ts +42 -0
  15. package/src/cdp/commands/heap/retainers.ts +52 -0
  16. package/src/cdp/commands/heap/snapshot.ts +94 -0
  17. package/src/cdp/commands/lib.ts +9 -17
  18. package/src/cdp/commands/lighthouse.ts +414 -0
  19. package/src/cdp/commands/measure/census.ts +2 -1
  20. package/src/cdp/commands/measure/check.ts +70 -16
  21. package/src/cdp/commands/measure/diff.ts +1 -2
  22. package/src/cdp/commands/measure/explain.ts +44 -7
  23. package/src/cdp/commands/measure/index.ts +24 -32
  24. package/src/cdp/commands/measure/map-ax.ts +2 -1
  25. package/src/cdp/commands/measure/map-focus.ts +2 -1
  26. package/src/cdp/commands/measure/map-layers.ts +5 -3
  27. package/src/cdp/commands/measure/map-scroll.ts +2 -1
  28. package/src/cdp/commands/measure/snap.ts +5 -4
  29. package/src/cdp/commands/measure/sweep.ts +16 -11
  30. package/src/cdp/commands/measure/text.ts +147 -0
  31. package/src/cdp/commands/motion/index.ts +10 -18
  32. package/src/cdp/commands/motion/jank.ts +13 -22
  33. package/src/cdp/commands/motion/mask.ts +10 -9
  34. package/src/cdp/commands/motion/rec.ts +228 -26
  35. package/src/cdp/commands/motion/timeline.ts +1 -1
  36. package/src/cdp/commands/page/click.ts +15 -5
  37. package/src/cdp/commands/page/elements.ts +43 -11
  38. package/src/cdp/commands/page/exec.ts +53 -18
  39. package/src/cdp/commands/page/index.ts +20 -20
  40. package/src/cdp/commands/page/inspect.ts +338 -0
  41. package/src/cdp/commands/page/navigate.ts +83 -38
  42. package/src/cdp/commands/page/repeat.ts +176 -0
  43. package/src/cdp/commands/page/scroll.ts +10 -5
  44. package/src/cdp/commands/page/shot.ts +66 -31
  45. package/src/cdp/commands/page/type.ts +6 -2
  46. package/src/cdp/commands/perf/index.ts +32 -0
  47. package/src/cdp/commands/perf/insights.ts +214 -0
  48. package/src/cdp/commands/perf/trace.ts +235 -0
  49. package/src/cdp/commands/perf/vitals.ts +76 -0
  50. package/src/cdp/commands/session/collectors.ts +54 -0
  51. package/src/cdp/commands/stub.ts +10 -0
  52. package/src/cdp/commands/tab/close.ts +2 -2
  53. package/src/cdp/commands/tab/index.ts +16 -23
  54. package/src/cdp/commands/tab/launch.ts +1 -1
  55. package/src/cdp/commands/tab/mock/index.ts +24 -0
  56. package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
  57. package/src/cdp/commands/tab/mock/start.ts +65 -0
  58. package/src/cdp/commands/tab/mock/stop.ts +39 -0
  59. package/src/cdp/commands/tab/network.ts +22 -13
  60. package/src/cdp/commands/tab/reset.ts +11 -12
  61. package/src/cdp/connection.ts +19 -39
  62. package/src/cdp/detect.ts +27 -7
  63. package/src/cdp/dispatch.ts +25 -4
  64. package/src/cdp/domains.ts +2 -2
  65. package/src/cdp/har-recorder.ts +0 -1
  66. package/src/cdp/heap-snapshot.ts +647 -0
  67. package/src/cdp/host/client.ts +22 -0
  68. package/src/cdp/host/collector.ts +74 -0
  69. package/src/cdp/host/collectors/heap.ts +79 -0
  70. package/src/cdp/host/collectors/intercept.ts +247 -0
  71. package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
  72. package/src/cdp/host/collectors/trace.ts +108 -0
  73. package/src/cdp/host/core.ts +217 -0
  74. package/src/cdp/host/handle.ts +47 -0
  75. package/src/cdp/host/held-client.ts +91 -0
  76. package/src/cdp/host/kinds.ts +97 -0
  77. package/src/cdp/host/lifecycle.ts +123 -0
  78. package/src/cdp/host/server.ts +88 -0
  79. package/src/cdp/measure/check.ts +181 -16
  80. package/src/cdp/measure/collectors/hittest.ts +16 -10
  81. package/src/cdp/measure/collectors/scroll.ts +15 -7
  82. package/src/cdp/measure/collectors/style-provenance.ts +11 -0
  83. package/src/cdp/measure/collectors/styles.ts +4 -4
  84. package/src/cdp/measure/explain.ts +14 -2
  85. package/src/cdp/measure/map-layers.ts +52 -8
  86. package/src/cdp/measure/snapshot.ts +5 -3
  87. package/src/cdp/measure/sweep.ts +29 -6
  88. package/src/cdp/measure/text.ts +227 -0
  89. package/src/cdp/motion/jank.ts +29 -83
  90. package/src/cdp/motion/mask.ts +18 -9
  91. package/src/cdp/motion/recorder.ts +86 -731
  92. package/src/cdp/scope-lock.ts +9 -9
  93. package/src/cdp/screenshot.ts +51 -21
  94. package/src/cdp/timing.ts +5 -5
  95. package/src/cdp/trace-analysis.ts +320 -0
  96. package/src/cdp/types.ts +31 -1
  97. package/src/cdp.ts +4 -6
  98. package/src/har-manager.ts +2 -4
  99. package/src/interact.ts +50 -23
  100. package/src/output/artifact.ts +49 -5
  101. package/src/output/render.ts +60 -9
  102. package/src/output/selection.ts +6 -0
  103. package/src/session/artifacts.ts +122 -12
  104. package/src/session/commands.ts +348 -113
  105. package/src/session/coordinator.ts +0 -93
  106. package/src/session/har-redact.ts +29 -23
  107. package/src/session-context.ts +13 -9
  108. package/src/cdp/bridge/protocol.ts +0 -224
  109. package/src/cdp/recorder-client.ts +0 -326
  110. package/src/cdp/tab-lock.ts +0 -132
@@ -1,6 +1,8 @@
1
1
  import { type ParsedArgs } from '../../types.js';
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';
2
+ import { selectRecords } from '../../../output/selection.js';
3
+ import { resolveSnapRef, readGeometry, readMeta, type ArtifactResolutionError } from '../../../output/artifact.js';
4
+ import { resolveSelectorInput } from '../../../output/selector.js';
5
+ import { capped, data, emitResult, fact, line, text, formatCoordinate, formatFindings, type FactLine, type FindingInput, type RenderableResult } from '../../../output/render.js';
4
6
  import { captureMeasureSnap } from './snap.js';
5
7
  import { checkSnapshot, parseChecks, writeFindingCrop } from '../../measure/check.js';
6
8
 
@@ -9,10 +11,13 @@ const USAGE = `capture measure check [url|snap] — read threshold/fact measurem
9
11
  input:
10
12
  [url|snap] a URL creates one one-shot snapshot first; a snap id or absolute path is read without re-driving the browser
11
13
  --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)
14
+ --limit <n> return at most n representative findings in prose and JSON (default: 20)
15
+ --selector <s> limit measurements to recorded CSS/text/AX selector input or backend:<id>
16
+ --artifact-dir <path> root for the one-shot snapshot bundle when the target is a URL
13
17
  --gate exit 2 when the report contains findings (default: exit 0)
14
18
  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`;
19
+ boundary: measurements are local to the selected snapshot. \`capture lighthouse\` runs the independent audited sweep.
20
+ effects: read-only over an existing snapshot artifact; a URL target writes one one-shot snapshot bundle under --artifact-dir when supplied`;
16
21
 
17
22
  const DEFAULT_FINDING_LIMIT = 20;
18
23
 
@@ -49,6 +54,34 @@ function caveatLine(caveats: readonly { regionId: string; selector?: string; rea
49
54
  return fact`Nondeterminism caveat: unstable region ${caveats.map((c) => `${c.regionId}${c.selector ? ` (${c.selector})` : ''}${c.reason ? `: ${c.reason}` : ''}`).join('; ')}.`;
50
55
  }
51
56
 
57
+ /** Contrast colors are the measurement's subject, not incidental selector text. Keep each color as its own uncapped, escaped field so a long selector cannot elide either one. */
58
+ function contrastHeadline(detail: string): FactLine {
59
+ const ratioPrefix = 'contrast ratio ';
60
+ const divider = ' — ';
61
+ const foregroundMarker = ' foreground ';
62
+ const backgroundMarker = ' against composited background ';
63
+ const dividerAt = detail.indexOf(divider);
64
+ if (!detail.startsWith(ratioPrefix) || dividerAt < 0) return fact`${detail}`;
65
+ const ratio = detail.slice(ratioPrefix.length, dividerAt);
66
+ const subject = detail.slice(dividerAt + divider.length);
67
+ const foregroundAt = subject.lastIndexOf(foregroundMarker);
68
+ const backgroundAt = subject.indexOf(backgroundMarker, foregroundAt + foregroundMarker.length);
69
+ if (foregroundAt < 0 || backgroundAt < 0) return fact`${detail}`;
70
+ const selector = subject.slice(0, foregroundAt);
71
+ const foreground = subject.slice(foregroundAt + foregroundMarker.length, backgroundAt);
72
+ const background = subject.slice(backgroundAt + backgroundMarker.length);
73
+ return line(
74
+ text`contrast ratio `,
75
+ data(ratio),
76
+ text` — `,
77
+ data(selector),
78
+ text` foreground `,
79
+ data(capped(foreground, Number.MAX_SAFE_INTEGER)),
80
+ text` against composited background `,
81
+ data(capped(background, Number.MAX_SAFE_INTEGER)),
82
+ );
83
+ }
84
+
52
85
  export async function cmdMeasureCheck(parsed: ParsedArgs, _args: string[]): Promise<void> {
53
86
  if (parsed.help) { console.log(USAGE); return; }
54
87
  if (parsed.positional.length > 1) {
@@ -62,14 +95,34 @@ export async function cmdMeasureCheck(parsed: ParsedArgs, _args: string[]): Prom
62
95
  const checks = parseChecks(parsed.for);
63
96
  const ref = await resolveSnapRef(target, { onUrl: async (url) => captureMeasureSnap({ ...parsed, positional: [url] }, url) });
64
97
  const report = checkSnapshot(ref, checks);
65
- const proseFindings = representativeFindings(report.findings, parsed.limit ?? DEFAULT_FINDING_LIMIT);
66
- const findingsForOutput = parsed.json ? report.findings : proseFindings;
98
+ const scopedElements = parsed.selector
99
+ ? resolveSelectorInput(readGeometry<{ elements?: Array<{ id: string; selector?: string; backendNodeId?: number | null }> }>(ref).elements ?? [], parsed.selector)
100
+ : undefined;
101
+ if (scopedElements && !scopedElements.length) {
102
+ emitResult({
103
+ tag: 'error',
104
+ attrs: { command: 'measure check', status: 'missing_selector', selector: parsed.selector! },
105
+ summary: fact`No geometry record matched selector input ${parsed.selector}.`,
106
+ followUp: text`Use a recorded selector or backend:<id> from geometry.json.`,
107
+ }, { json: parsed.json });
108
+ process.exitCode = 1;
109
+ return;
110
+ }
111
+ const scopedIds = scopedElements ? new Set(scopedElements.map((element) => element.id)) : undefined;
112
+ const scopedBackendNodeIds = scopedElements ? new Set(scopedElements.map((element) => element.backendNodeId).filter((backendNodeId): backendNodeId is number => backendNodeId !== null && backendNodeId !== undefined)) : undefined;
113
+ const selectorCounts = new Map<string, number>();
114
+ for (const element of readGeometry<{ elements?: Array<{ selector?: string }> }>(ref).elements ?? []) if (element.selector !== undefined) selectorCounts.set(element.selector, (selectorCounts.get(element.selector) ?? 0) + 1);
115
+ const scopedSelectors = scopedElements ? new Set(scopedElements.map((element) => element.selector).filter((selector): selector is string => selector !== undefined && selectorCounts.get(selector) === 1)) : undefined;
116
+ const findings = scopedIds ? report.findings.filter((finding) => (finding.elementId !== undefined && scopedIds.has(finding.elementId)) || (finding.backendNodeId !== undefined && scopedBackendNodeIds!.has(finding.backendNodeId)) || (finding.selector !== undefined && scopedSelectors!.has(finding.selector))) : report.findings;
117
+ const findingsForOutput = selectRecords(representativeFindings(findings, parsed.limit ?? DEFAULT_FINDING_LIMIT), parsed, parsed.limit ?? DEFAULT_FINDING_LIMIT);
67
118
  const withCrops = findingsForOutput.map((finding, index) => ({ ...finding, crop: writeFindingCrop(ref, finding, index) }));
68
119
  const meta = readMeta<{ settled: boolean; capturedAt?: string }> (ref);
69
120
  const findingSections: FindingInput[] = withCrops.map((finding) => ({
70
121
  kind: finding.kind,
71
- headline: fact`${finding.detail}`,
122
+ headline: finding.kind === 'contrast' ? contrastHeadline(finding.detail) : fact`${finding.detail}`,
72
123
  detail: [
124
+ ...(finding.rect ? [line(text`Rect: `, formatCoordinate(finding.rect))] : []),
125
+ ...(finding.backendNodeId !== undefined ? [fact`Selector input: backend:${finding.backendNodeId}`] : []),
73
126
  ...(finding.provenance ? [fact`Provenance: ${finding.provenance}`] : []),
74
127
  ...(caveatLine(finding.caveats) ? [caveatLine(finding.caveats)!] : []),
75
128
  ],
@@ -81,20 +134,21 @@ export async function cmdMeasureCheck(parsed: ParsedArgs, _args: string[]): Prom
81
134
  kind: 'snapshot', id: ref.id, path: ref.dir,
82
135
  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
136
  },
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}.`,
137
+ attrs: { result: findings.length ? 'findings' : 'clean', checks: checks.join(','), elements: scopedElements?.length ?? report.elementCount, findings: findings.length, displayed: withCrops.length, settled: report.settled, ...(parsed.selector ? { selector: parsed.selector } : {}) },
138
+ summary: findings.length
139
+ ? fact`${findings.length} measured fact(s) matched the selected thresholds in viewport ${report.viewport.width}×${report.viewport.height}; ${withCrops.length} representative record(s) rendered.`
140
+ : fact`No selected measurement threshold was crossed among ${scopedElements?.length ?? report.elementCount} recorded elements in viewport ${report.viewport.width}×${report.viewport.height}.`,
88
141
  sections: [
89
- ...(report.findings.length ? [line(text`Finding counts: `, rollup(report.findings)!)] : []),
142
+ ...(findings.length ? [line(text`Finding counts: `, rollup(findings)!)] : []),
90
143
  ...formatFindings(findingSections),
144
+ fact`Measurements are local to snapshot ${ref.id}; \`capture lighthouse\` runs the independent audited sweep.`,
91
145
  ],
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.`,
146
+ followUp: findings.length > withCrops.length
147
+ ? fact`The full snapshot artifact at ${ref.dir} retains every measured finding input; this result is bounded by --limit.`
148
+ : undefined,
95
149
  };
96
150
  emitResult(result, { json: parsed.json });
97
- if (parsed.gate && report.findings.length) process.exitCode = 2;
151
+ if (parsed.gate && findings.length) process.exitCode = 2;
98
152
  } catch (err) {
99
153
  const resolution = err as Partial<ArtifactResolutionError>;
100
154
  const detail = err instanceof Error ? err.message : 'unknown artifact read failure';
@@ -50,8 +50,7 @@ export async function cmdMeasureDiff(parsed: ParsedArgs, _args: string[]): Promi
50
50
  const result: RenderableResult = {
51
51
  tag: 'error',
52
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.`,
53
+ summary: fact`received: ${parsed.positional.length} positional argument(s), --before=${parsed.before ?? '(missing)'}, --after=${parsed.after ?? '(missing)'}\nexpected: no positional target and both settled snapshot references\nfield: <positional>, --before, --after\nNext: Run \`capture measure diff -h\` and read the schema before re-issuing.`,
55
54
  };
56
55
  emitResult(result, { json: parsed.json });
57
56
  process.exitCode = 1;
@@ -1,5 +1,5 @@
1
1
  import { type ParsedArgs } from '../../types.js';
2
- import { explainSnapshot, type ExplainMissingSelector } from '../../measure/explain.js';
2
+ import { explainSnapshot, type ExplainAmbiguousSelector, type ExplainMissingSelector } from '../../measure/explain.js';
3
3
  import { ArtifactResolutionError, resolveSnapRef } from '../../../output/artifact.js';
4
4
  import { data, emitResult, fact, line, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
5
5
 
@@ -9,10 +9,11 @@ input:
9
9
  <snap> snapshot id in the active session or absolute artifact path (required)
10
10
  --selector <sel> element selector (required): CSS tag/#id/.class/[attr]/[attr="value"] compounds with descendant or child relations (or a verbatim recorded path), backend:<id>, axid:<id>, ax:<name>, or text:<text>
11
11
  --state <name> one recorded forced state; derives border/ink and clipping comparisons from its post-force target values
12
- --size include size/layout provenance (box/flex/grid/constraints)
13
- --text include text line/baseline/font/wrap metrics
14
- --form include form value/geometry/caret/selection/autofill facts
15
- output: <explain selector=… matches=…> — border/ink boxes with named paint contributors and rectangular overflow padding-box comparisons, each naming its base or forced-state provenance; unavailable forced-state or captured box evidence is stated explicitly; --json mirrors
12
+ --size include size/layout provenance (box/flex/grid/constraints) before general rendering context
13
+ --text include text line/baseline/font/wrap metrics before general rendering context
14
+ --form include form value/geometry/caret/selection/autofill facts before general rendering context
15
+ output: <explain selector=… matches=…> — requested size/text/form measurements lead after target identity; full cascade, border/ink, stacking, clipping, focus, scroll, query, and state context remains in the result; --json mirrors
16
+ boundary: measurements are local to the selected snapshot. \`capture lighthouse\` runs the independent audited sweep.
16
17
  effects: read-only — reads existing snapshot artifacts, never drives the browser`;
17
18
 
18
19
  function attestation(ref: { id: string; dir: string }, meta: { settled?: boolean; settleMs?: number }) {
@@ -60,6 +61,14 @@ function recoverySections(missing: ExplainMissingSelector): FactLine[] {
60
61
  ];
61
62
  }
62
63
 
64
+ function ambiguitySections(ambiguous: ExplainAmbiguousSelector): FactLine[] {
65
+ const rows = ambiguous.candidates.map((candidate, index) => fact`${index + 1}. ${candidate.selector ?? candidate.id}${typeof candidate.backendNodeId === 'number' ? ` — backend:${candidate.backendNodeId}` : ''}${candidate.axId ? ` — axid:${candidate.axId}` : ''}${candidate.axName ? ` — ax:${candidate.axName}` : ''}`);
66
+ return [lineList([
67
+ ...(ambiguous.matchCount > ambiguous.candidates.length ? [fact`candidates (first ${ambiguous.candidates.length} of ${ambiguous.matchCount}):`] : [text`candidates:`]),
68
+ ...rows,
69
+ ])];
70
+ }
71
+
63
72
  function invalidInput(message: FactLine): RenderableResult {
64
73
  return {
65
74
  tag: 'error',
@@ -69,6 +78,19 @@ function invalidInput(message: FactLine): RenderableResult {
69
78
  };
70
79
  }
71
80
 
81
+ function requestedSectionsFirst<T extends { kind: string }>(sections: readonly T[], parsed: ParsedArgs): readonly T[] {
82
+ const requested = new Set([
83
+ ...(parsed.size ? ['size'] : []),
84
+ ...(parsed.text ? ['text'] : []),
85
+ ...(parsed.form ? ['form'] : []),
86
+ ]);
87
+ if (!requested.size) return sections;
88
+ const element = sections.filter((section) => section.kind === 'element');
89
+ const detail = sections.filter((section) => requested.has(section.kind));
90
+ const context = sections.filter((section) => section.kind !== 'element' && !requested.has(section.kind));
91
+ return [...element, ...detail, ...context];
92
+ }
93
+
72
94
  export async function cmdMeasureExplain(parsed: ParsedArgs, _args: string[]): Promise<void> {
73
95
  if (parsed.help) {
74
96
  console.log(USAGE);
@@ -110,8 +132,20 @@ export async function cmdMeasureExplain(parsed: ParsedArgs, _args: string[]): Pr
110
132
  process.exitCode = 1;
111
133
  return;
112
134
  }
135
+ if (report.kind === 'ambiguous-selector') {
136
+ emitResult({
137
+ tag: 'error',
138
+ attestation: attestation(report.ref, report.meta),
139
+ attrs: { command: 'measure explain', status: 'ambiguous_selector', selector: report.selector, matches: report.matchCount, displayed: report.candidates.length },
140
+ summary: fact`Selector input ${report.selector} matched ${report.matchCount} recorded elements; no element was selected.`,
141
+ sections: ambiguitySections(report),
142
+ followUp: text`Retry with an exact CSS selector, axid, or numeric backend id from the candidate identity facts.`,
143
+ }, { json: parsed.json });
144
+ process.exitCode = 1;
145
+ return;
146
+ }
113
147
 
114
- const sections = report.sections.map((section) => lineList(section.facts.map(({ fact: item, caveats }) => {
148
+ const sections = requestedSectionsFirst(report.sections, parsed).map((section) => lineList(section.facts.map(({ fact: item, caveats }) => {
115
149
  const suffix = caveatSuffix(caveats);
116
150
  return suffix ? line(item.line, suffix) : item.line;
117
151
  })));
@@ -129,7 +163,10 @@ export async function cmdMeasureExplain(parsed: ParsedArgs, _args: string[]): Pr
129
163
  state: parsed.state?.[0] ?? 'base',
130
164
  },
131
165
  summary: fact`Recorded cascade, border/ink geometry, stacking, clipping, focus, scroll, query, and state context for ${targetSelector}${parsed.state?.[0] ? ` under requested state ${parsed.state[0]}` : ' from base-state artifacts'}.`,
132
- sections,
166
+ sections: [
167
+ ...sections,
168
+ fact`Measurements are local to snapshot ${report.ref.id}; \`capture lighthouse\` runs the independent audited sweep.`,
169
+ ],
133
170
  followUp: parsed.size || parsed.text || parsed.form
134
171
  ? text`Re-run against a new snapshot to compare the same recorded measurements after a page change.`
135
172
  : text`Add --size, --text, or --form to include only those optional detail sections.`,
@@ -14,6 +14,7 @@ import { cmdMeasureCheck } from './check.js';
14
14
  import { cmdMeasureDiff } from './diff.js';
15
15
  import { cmdMeasureCensus } from './census.js';
16
16
  import { cmdMeasureExplain } from './explain.js';
17
+ import { cmdMeasureText } from './text.js';
17
18
  import { cmdMeasureSweep } from './sweep.js';
18
19
  import { cmdMeasureMapFocus } from './map-focus.js';
19
20
  import { cmdMeasureMapScroll } from './map-scroll.js';
@@ -23,41 +24,30 @@ import { cmdMeasureMapPaint } from './map-paint.js';
23
24
 
24
25
  /** Root-help representation of this branch, assembled by `src/capture.ts`. */
25
26
  export const COMMAND_BLOCK = `<command name="measure">
26
- static facts over a settled snapshot — \`snap\` writes the substrate, every other leaf is a read-only query over it
27
- use when measuring layout/content/targetability facts, diffing snapshots, or reading one facet (focus, scroll, layers, ax, paint) of the substrate
28
- snap · check · diff · census · explain · sweep · map (focus|scroll|layers|ax|paint) — \`capture measure -h\`
27
+ the settled-snapshot substrate — static facts at one moment; \`snap\` writes the substrate, every other leaf is a read-only query over it
28
+ use for static rendered-page facts from a settled snapshot: layout, content, cascade provenance, targetability, and snapshot diffs
29
29
  </command>`;
30
30
 
31
- export const MEASURE_USAGE = `capture measure — enriched snapshot substrate + read-only queries over it.
32
-
33
- \`snap\` drives the page (or a base snapshot) and writes one settled artifact
34
- directory; every other leaf below is a cheap read over that artifact and
35
- never re-drives the browser unless its target is a URL (which snaps first).
36
- Every leaf defaults to rendered prose; --json mirrors the same result.
37
- Findings exit 0 — a report, not a failure.
38
- \`--gate\` (exit 2 on findings/changes) is accepted only by check and diff.
39
-
40
- <subcommand name="snap" args="[url|snap] [--freeze-animations] [--settle-timeout <ms>] [--capture-unsettled] [--pixels] [--state <state[:selector]>]... [--viewport <WxH>]... [--color-scheme <dark|light>]" whenToUse="drive + write the settled snapshot substrate every other leaf reads"/>
41
- <subcommand name="check" args="[url|snap] [--for <checks>] [--gate]" whenToUse="read threshold/fact measurements from one snapshot"/>
42
- <subcommand name="diff" args="--before <snap> --after <snap> [--pixels] [--full] [--gate]" whenToUse="structured before/after delta between two snapshots"/>
43
- <subcommand name="census" args="[--snap <id>]... [--url <url>]... [--set-file <path>] --axis <axis>" whenToUse="value distributions across one or more snapshots"/>
44
- <subcommand name="explain" args="<snap> --selector <sel> [--size] [--text] [--form]" whenToUse="per-element cascade/stacking/clipping/size/text/form explanation"/>
45
- <subcommand name="sweep" args="[url] --axis <axis> [--from <val>] [--to <val>] [--viewport-height <val>]" whenToUse="responsive/environment sampling across an axis"/>
46
- <subcommand name="map" args="focus|scroll|layers|ax [url|snap] | paint <snap> --selector <target> [--state <name>]" whenToUse="read one facet of a snapshot's substrate — capture measure map -h"/>
47
-
48
- capture measure <leaf> -h Per-leaf usage`;
49
-
50
- export const MEASURE_MAP_USAGE = `capture measure map — read one facet of a snapshot's substrate (no browser re-drive).
51
-
52
- Focus, scroll, layers, and ax accept a URL target and create a snap first; paint requires an existing snapshot and reads it without browser driving.
53
-
54
- <subcommand name="focus" args="[url|snap]" whenToUse="keyboard traversal order (focus.json)"/>
55
- <subcommand name="scroll" args="[url|snap]" whenToUse="scroll-container topology (scroll.json)"/>
56
- <subcommand name="layers" args="[url|snap]" whenToUse="paint/compositor layer map (layers.json)"/>
57
- <subcommand name="ax" args="[url|snap]" whenToUse="AX-tree ↔ layout map (ax.json + geometry.json)"/>
58
- <subcommand name="paint" args="<snap> --selector <target> [--state <name>]" whenToUse="elements painted above one target and their recorded AABB coverage (geometry.json + layers.json)"/>
31
+ export const MEASURE_USAGE = `<command name="measure" description="enriched snapshot substrate and read-only queries">
32
+ <model>\`snap\` drives the page (or a base snapshot) and writes one settled artifact directory; every other leaf below reads that artifact and never re-drives the browser unless its target is a URL, which first creates a snapshot. Findings exit 0 — a report, not a failure. \`--gate\` (exit 2 on findings or changes) is accepted only by check and diff.</model>
33
+ <subcommand name="snap" description="settled snapshot artifact" whenToUse="Use to capture the static rendered-page substrate that measure queries read."/>
34
+ <subcommand name="check" description="snapshot threshold measurements" whenToUse="Use to measure selected layout and content thresholds from one snapshot."/>
35
+ <subcommand name="diff" description="snapshot delta" whenToUse="Use to compare the measured facts from two snapshots."/>
36
+ <subcommand name="census" description="snapshot value distributions" whenToUse="Use to count values across one or more snapshots."/>
37
+ <subcommand name="explain" description="element rendering explanation" whenToUse="Use to inspect cascade, stacking, clipping, size, text, or form facts for one element."/>
38
+ <subcommand name="text" description="targeted DOM-content text measurement" whenToUse="Use to read one element's direct DOM content text, font, recorded color-model values, contrast ratio, and provenance from a snapshot."/>
39
+ <subcommand name="sweep" description="responsive environment sampling" whenToUse="Use to compare snapshot facts across values on one environment axis."/>
40
+ <subcommand name="map" description="one snapshot facet" whenToUse="Use to read focus, scroll, layer, accessibility, or paint facts from a snapshot."/>
41
+ </command>`;
59
42
 
60
- capture measure map <leaf> -h Per-leaf usage`;
43
+ export const MEASURE_MAP_USAGE = `<command name="map" description="one facet of a snapshot's substrate">
44
+ <model>Focus, scroll, layers, and ax accept a URL target and create a snapshot first; paint requires an existing snapshot and reads it without browser driving.</model>
45
+ <subcommand name="focus" description="keyboard traversal order" whenToUse="Use to inspect focus order from a snapshot."/>
46
+ <subcommand name="scroll" description="scroll-container topology" whenToUse="Use to inspect scroll containers and their relationships."/>
47
+ <subcommand name="layers" description="paint and compositor layers" whenToUse="Use to inspect layer allocation and paint order."/>
48
+ <subcommand name="ax" description="accessibility and layout map" whenToUse="Use to relate accessibility-tree nodes to layout geometry."/>
49
+ <subcommand name="paint" description="coverage above one target" whenToUse="Use to inspect elements painted above a target and their recorded coverage."/>
50
+ </command>`;
61
51
 
62
52
  export async function measureMain(parsed: ParsedArgs, args: string[]): Promise<void> {
63
53
  const leaf = parsed.positional[0];
@@ -74,6 +64,8 @@ export async function measureMain(parsed: ParsedArgs, args: string[]): Promise<v
74
64
  return cmdMeasureCensus(rest, args);
75
65
  case 'explain':
76
66
  return cmdMeasureExplain(rest, args);
67
+ case 'text':
68
+ return cmdMeasureText(rest, args);
77
69
  case 'sweep':
78
70
  return cmdMeasureSweep(rest, args);
79
71
  case 'map':
@@ -8,8 +8,9 @@ const USAGE = `capture measure map ax [url|snap] — accessibility-tree facts re
8
8
 
9
9
  input:
10
10
  [url|snap] required target: a URL creates a settled snapshot first; a snapshot id or absolute path is read without re-driving the browser
11
+ --artifact-dir <path> root for the one-shot snapshot bundle when the target is a URL
11
12
  output: <ax-map …> — non-ignored AX nodes with role, name, states, backendNodeId, and top-viewport rect; ignored AX nodes with their ignored-reasons; DOM elements with rendered boxes but no non-ignored AX node; and AX nodes whose rect is offscreen, clipped, or zero-size; --json mirrors
12
- effects: read-only over an existing snapshot artifact; a URL target writes one settled snapshot first`;
13
+ effects: read-only over an existing snapshot artifact; a URL target writes one settled snapshot bundle under --artifact-dir when supplied`;
13
14
 
14
15
  function recoveryResult(err: unknown): RenderableResult {
15
16
  const detail = err instanceof ArtifactResolutionError || err instanceof Error
@@ -8,8 +8,9 @@ const USAGE = `capture measure map focus [url|snap] — keyboard traversal facts
8
8
 
9
9
  input:
10
10
  [url|snap] required target: a URL creates a settled snapshot first; a snapshot id or absolute path is read without re-driving the browser
11
+ --artifact-dir <path> root for the one-shot snapshot bundle when the target is a URL
11
12
  output: <focus-map …> — forward and reverse Tab sequences, top-viewport rects, scroll jumps, focus-visible style facts, and unreached focusable elements; --json mirrors
12
- effects: read-only over an existing snapshot artifact; a URL target writes one settled snapshot first`;
13
+ effects: read-only over an existing snapshot artifact; a URL target writes one settled snapshot bundle under --artifact-dir when supplied`;
13
14
 
14
15
  function recoveryResult(err: unknown): RenderableResult {
15
16
  const detail = err instanceof ArtifactResolutionError || err instanceof Error
@@ -8,8 +8,10 @@ const USAGE = `capture measure map layers [url|snap] — paint/compositor facts
8
8
 
9
9
  input:
10
10
  [url|snap] required target: a URL creates a settled snapshot first; a snapshot id or absolute path is read without re-driving the browser
11
- output: <layer-map …> — layer bounds, compositing reasons, DOMSnapshot paint order, per-node membership, and available source provenance for layer-affecting declarations; --json mirrors
12
- effects: read-only over an existing snapshot artifact; a URL target writes one settled snapshot first`;
11
+ --artifact-dir <path> root for the one-shot snapshot bundle when the target is a URL
12
+ --limit <n> maximum final layer records in prose and JSON (default 25)
13
+ output: <layer-map …> — layer bounds, compositing reasons, painted-node counts, per-node membership, and available source provenance for layer-affecting declarations; backend-id corpora are limited to 50 per list with omitted counts, and --json mirrors the same cap
14
+ effects: read-only over an existing snapshot artifact; a URL target writes one settled snapshot bundle under --artifact-dir when supplied`;
13
15
 
14
16
  function errorResult(err: unknown): RenderableResult {
15
17
  const detail = err instanceof Error ? err.message : String(err);
@@ -51,7 +53,7 @@ export async function cmdMeasureMapLayers(parsed: ParsedArgs, _args: string[]):
51
53
  const ref = await resolveSnapRef(target, {
52
54
  onUrl: async (url) => captureMeasureSnap(parsed, url),
53
55
  });
54
- emitResult(buildMeasureMapLayersResult(ref), { json: parsed.json });
56
+ emitResult(buildMeasureMapLayersResult(ref, parsed.limit), { json: parsed.json });
55
57
  } catch (err) {
56
58
  emitResult(errorResult(err), { json: parsed.json });
57
59
  process.exitCode = 1;
@@ -8,8 +8,9 @@ const USAGE = `capture measure map scroll [url|snap] — scroll-container topolo
8
8
 
9
9
  input:
10
10
  [url|snap] required target: a URL creates a settled snapshot first; a snapshot id from the active session or an absolute snapshot artifact path is read without re-driving the browser
11
+ --artifact-dir <path> root for the one-shot snapshot bundle when the target is a URL
11
12
  output: <scroll-map …> — containers, ranges, current/max offsets, sticky/fixed occupancy, snap points, visual/layout viewport facts, and reachable-content samples; --json mirrors
12
- effects: read-only over an existing snapshot artifact; a URL target writes one settled snapshot first`;
13
+ effects: read-only over an existing snapshot artifact; a URL target writes one settled snapshot bundle under --artifact-dir when supplied`;
13
14
 
14
15
  type CaptureSnap = (parsed: ParsedArgs, target: string) => Promise<SnapUrlResult>;
15
16
  type Emit = typeof emitResult;
@@ -4,7 +4,7 @@ import { randomBytes } from 'crypto';
4
4
 
5
5
  import { type ParsedArgs } from '../../types.js';
6
6
  import { withConnection } from '../../connection.js';
7
- import { isRecorderHeldClient } from '../../recorder-client.js';
7
+ import { isMotionHeldClient } from '../../host/held-client.js';
8
8
  import { parseViewport, type Viewport } from '../../viewport.js';
9
9
  import { parseColorScheme, withAppliedColorScheme, type ColorScheme } from '../../color-scheme.js';
10
10
  import { captureSnapshotSubstrate, COLLECTOR_NAMES, SnapshotCaptureTimeout } from '../../measure/snapshot.js';
@@ -40,8 +40,9 @@ input:
40
40
  --state-padding <px> CSS-pixel padding around the target border box in each forced-state diff crop (default 8)
41
41
  --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
42
42
  --color-scheme <dark|light> temporarily capture under this prefers-color-scheme value; cleared afterward
43
+ --artifact-dir <path> root for this sessionless snapshot bundle; ignored when an active session owns the snapshot
43
44
  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
44
- effects: drives the target page to capture; writes one snapshot artifact directory under the active (or a private one-shot) session. --color-scheme transiently applies Emulation.setEmulatedMedia and clears it after capture.`;
45
+ effects: drives the target page to capture; writes one snapshot artifact directory under the active session or, when sessionless, a one-shot bundle under --artifact-dir. --color-scheme transiently applies Emulation.setEmulatedMedia and clears it after capture.`;
45
46
 
46
47
  interface SnapshotMetaForBase {
47
48
  readonly url?: string | null;
@@ -132,7 +133,7 @@ interface ScreenInfo {
132
133
  * never clears an override it cannot restore.
133
134
  */
134
135
  async function viewportOwnership(client: ViewportClient): Promise<ViewportOwnership> {
135
- if (isRecorderHeldClient(client)) return { owner: 'recorder' };
136
+ if (isMotionHeldClient(client)) return { owner: 'recorder' };
136
137
  try {
137
138
  const [page, screens] = await Promise.all([
138
139
  client.send('Runtime.evaluate', {
@@ -285,7 +286,7 @@ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.
285
286
  let artifacts: readonly string[] = [];
286
287
  await withConnection(connectionArgs, async (client, tab) => {
287
288
  await withAppliedColorScheme(client, colorScheme, async () => withAppliedViewport(client, viewport, async () => {
288
- const oneshot = active ? undefined : createOneshotSession('measure');
289
+ const oneshot = active ? undefined : createOneshotSession('measure', parsed.artifactDir);
289
290
  const destination = active ? path.join(active.dir, 'measure', 'snaps') : oneshot!.artifactsDir;
290
291
  snapDir = path.join(destination, snapId);
291
292
  allocatedRoot = active ? snapDir : oneshot!.dir;
@@ -7,7 +7,8 @@ import { withConnection } from '../../connection.js';
7
7
  import { captureSnapshotSubstrate } from '../../measure/snapshot.js';
8
8
  import { createOneshotSession } from '../../../session/commands.js';
9
9
  import { getActiveSession } from '../../../session-context.js';
10
- import { emitResult, fact, line, lineList, renderResult, text, type FactLine, type RenderableResult } from '../../../output/render.js';
10
+ import { capped, emitResult, fact, line, lineList, renderResult, text, type FactLine, type RenderableResult } from '../../../output/render.js';
11
+ import { normalizeFailure } from '../../../errors.js';
11
12
  import { assertSweepBounds, parsePositiveNumber } from '../../leaf-grammar.js';
12
13
  import {
13
14
  SWEEP_AXES,
@@ -30,13 +31,14 @@ import {
30
31
  const USAGE = `capture measure sweep [url] --axis <axis> — responsive/environment sampling with recursive bracketing of observed state changes
31
32
 
32
33
  input:
33
- [url] page to sweep; outside a session, snapshots and sweep.json are written under one private one-shot measure directory
34
+ [url] match an existing page tab by URL; this does not open a URL — run inside \`capture session start --url …\` when no matching tab exists
34
35
  --axis <axis> width|dpr|zoom|color-scheme|reduced-motion (required)
35
36
  --from <val> numeric range start, or a color-scheme/reduced-motion value
36
37
  --to <val> numeric range end, or a color-scheme/reduced-motion value
37
38
  --viewport-height <val> fixed viewport height for width/dpr sweeps
39
+ --artifact-dir <path> root for this sessionless sweep bundle; ignored when an active session owns the sweep
38
40
  output: <sweep axis=… samples=… transitions=…> — observed state facts per sampled point, each sampled snapshot path included; --json mirrors
39
- effects: drives the page repeatedly under CDP Emulation settings; writes one snapshot substrate per sampled point plus sweep.json`;
41
+ effects: drives the page repeatedly under CDP Emulation settings; writes one snapshot substrate per sampled point plus sweep.json under the active session or, when sessionless, a one-shot bundle under --artifact-dir`;
40
42
 
41
43
  const SAMPLE_LIMIT = 96;
42
44
 
@@ -80,9 +82,11 @@ function valuesForAxis(axis: SweepAxis, parsed: ParsedArgs): Array<number | stri
80
82
  }
81
83
 
82
84
  export function transitionSections(artifact: SweepArtifact): FactLine[] {
83
- const ranges = artifact.ranges.map((range) => line(fact` observed matching fingerprint ${range.from}–${range.to} → ${range.snapId} (${range.snapDir})`));
85
+ const ranges = artifact.ranges.map((range) => fact` observed matching fingerprint ${range.from}–${range.to} → ${range.snapId} (${range.snapDir})`);
84
86
  const transitions = artifact.transitions.flatMap((transition, index) => {
85
- const header = line(fact`${index + 1}. bracketed between ${transition.bracket.from} and ${transition.bracket.to} — ${transition.before} → ${transition.after}`);
87
+ const header = transition.uncertainty === 'sampling_limit'
88
+ ? fact`${index + 1}. observed difference unresolved between ${transition.bracket.from} and ${transition.bracket.to}: the ${artifact.sampleLimit} sample limit stopped refinement — ${transition.before} → ${transition.after}`
89
+ : fact`${index + 1}. bracketed between ${transition.bracket.from} and ${transition.bracket.to} — ${transition.before} → ${transition.after}`;
86
90
  const changes = transition.changes.slice(0, 12).map((change) => {
87
91
  const provenance = change.provenance?.source ? fact` (${change.provenance.selector ?? 'winning declaration'}${change.provenance.specificity ? `, specificity ${change.provenance.specificity}` : ''}, ${change.provenance.source})` : change.provenance?.selector ? fact` (${change.provenance.selector}${change.provenance.specificity ? `, specificity ${change.provenance.specificity}` : ''})` : text``;
88
92
  return line(fact` ${change.selector} ${change.property}: ${change.before ?? 'unset'} → ${change.after ?? 'unset'}`, provenance);
@@ -136,7 +140,7 @@ export async function runMeasureSweep(parsed: ParsedArgs, _args: string[], overr
136
140
 
137
141
  const axis = parsed.axis;
138
142
  const active = dependencies.getActiveSession();
139
- const oneShot = active ? undefined : dependencies.createOneshotSession('measure');
143
+ const oneShot = active ? undefined : dependencies.createOneshotSession('measure', parsed.artifactDir);
140
144
  const snapsDir = active ? path.join(active.dir, 'measure', 'snaps') : oneShot!.artifactsDir;
141
145
  const artifactDir = active ? path.join(active.dir, 'measure', 'sweeps', sweepId()) : path.join(oneShot!.dir, 'measure', 'sweeps', sweepId());
142
146
  const recoverySamples: Array<SweepRecovery['samples'][number]> = [];
@@ -204,7 +208,7 @@ export async function runMeasureSweep(parsed: ParsedArgs, _args: string[], overr
204
208
  const samples = (axis === 'width' || axis === 'dpr' || axis === 'zoom') ? [...samplesByValue.values()].sort((a, b) => Number(a.value) - Number(b.value)) : values.map((value) => samplesByValue.get(String(value))!).filter(Boolean);
205
209
  const from = values[0];
206
210
  const to = values[values.length - 1];
207
- const analyzed = analyzeSweepSamples(axis, from, to, samples);
211
+ const analyzed = analyzeSweepSamples(axis, from, to, samples, uncertainties);
208
212
  const artifact: SweepArtifact = { axis, from, to, capturedAt: new Date().toISOString(), samples, ...analyzed, uncertainties, sampleLimit: SAMPLE_LIMIT, environmentRestoration: environmentFacts };
209
213
  const artifactPath = writeSweepArtifact(artifactDir, artifact);
210
214
  const result: RenderableResult = {
@@ -214,13 +218,14 @@ export async function runMeasureSweep(parsed: ParsedArgs, _args: string[], overr
214
218
  followUp: samples.length > 1 ? fact`Compare two sampled substrates with \`capture measure diff --before ${samples[0].snapDir} --after ${samples[1].snapDir}\`.` : undefined,
215
219
  };
216
220
  dependencies.emitResult(result, { json: parsed.json });
217
- } catch {
221
+ } catch (error) {
218
222
  const evidenceOnly = recoverySamples.some((sample) => sample.status === 'evidence_only');
223
+ const failure = normalizeFailure(error).descriptor;
219
224
  const recoveryPath = writeSweepRecoveryArtifact(artifactDir, { axis, capturedAt: new Date().toISOString(), reason: evidenceOnly ? 'evidence_only' : 'capture_failed', environmentRestoration, samples: recoverySamples });
220
225
  dependencies.emitResult({
221
- tag: 'error', attrs: { command: 'measure sweep', status: evidenceOnly ? 'evidence_only' : 'sweep_failed', path: recoveryPath },
222
- summary: fact`Sweep did not produce a queryable sample set. Recovery artifact: ${recoveryPath}.`,
223
- sections: recoverySamples.length ? [text`Partial snapshot/recovery provenance:`, ...recoverySamples.map((sample) => fact`${sample.value}: ${sample.snapDir} status=${sample.status} captured=${sample.captured} settled=${sample.settled} artifacts=${sample.artifacts.join(', ') || 'none'} unstable=${sample.unstableRegions.map((region) => `${region.id}${region.reason ? `:${region.reason}` : ''}`).join(', ') || 'none'}`), fact`environment restoration: ${environmentRestoration}`] : undefined,
226
+ tag: 'error', attrs: { command: 'measure sweep', status: evidenceOnly ? 'evidence_only' : failure.code, path: recoveryPath },
227
+ summary: fact`Sweep did not produce a queryable sample set: ${capped(failure.message, 600)} Recovery artifact: ${recoveryPath}.`,
228
+ sections: recoverySamples.length ? [text`Partial snapshot/recovery provenance:`, ...recoverySamples.map((sample) => fact`${sample.value}: ${sample.snapDir} status=${sample.status} captured=${String(sample.captured)} settled=${String(sample.settled)} artifacts=${sample.artifacts.join(', ') || 'none'} unstable=${sample.unstableRegions.map((region) => `${region.id}${region.reason ? `:${region.reason}` : ''}`).join(', ') || 'none'}`), fact`environment restoration: ${environmentRestoration}`] : undefined,
224
229
  }, { json: parsed.json });
225
230
  process.exitCode = 1;
226
231
  }