@crouton-kit/capture 1.3.7 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/bin/capture +32786 -9989
  2. package/package.json +9 -3
  3. package/src/capture.ts +86 -163
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -116
  6. package/src/cdp/bridge/protocol.ts +187 -1
  7. package/src/cdp/bridge/server.ts +211 -84
  8. package/src/cdp/bridge/spawn.ts +110 -1
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +35 -4
  11. package/src/cdp/commands/cdp.ts +266 -69
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +87 -0
  15. package/src/cdp/commands/measure/check.ts +109 -0
  16. package/src/cdp/commands/measure/diff.ts +120 -0
  17. package/src/cdp/commands/measure/explain.ts +133 -0
  18. package/src/cdp/commands/measure/index.ts +106 -0
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +52 -0
  21. package/src/cdp/commands/measure/map-layers.ts +59 -0
  22. package/src/cdp/commands/measure/map-scroll.ts +94 -0
  23. package/src/cdp/commands/measure/snap.ts +416 -0
  24. package/src/cdp/commands/measure/sweep.ts +227 -0
  25. package/src/cdp/commands/motion/index.ts +61 -0
  26. package/src/cdp/commands/motion/jank.ts +132 -0
  27. package/src/cdp/commands/motion/mask.ts +92 -0
  28. package/src/cdp/commands/motion/rec.ts +534 -0
  29. package/src/cdp/commands/motion/response.ts +76 -0
  30. package/src/cdp/commands/motion/timeline.ts +152 -0
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/coordinates.ts +158 -0
  46. package/src/cdp/detect.ts +42 -13
  47. package/src/cdp/dispatch.ts +76 -26
  48. package/src/cdp/domains.ts +75 -0
  49. package/src/cdp/har-recorder.ts +648 -115
  50. package/src/cdp/leaf-grammar.ts +99 -0
  51. package/src/cdp/measure/census.ts +205 -0
  52. package/src/cdp/measure/check.ts +318 -0
  53. package/src/cdp/measure/collectors/animation.ts +385 -0
  54. package/src/cdp/measure/collectors/ax.ts +278 -0
  55. package/src/cdp/measure/collectors/focus.ts +1041 -0
  56. package/src/cdp/measure/collectors/forms.ts +905 -0
  57. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  58. package/src/cdp/measure/collectors/hittest.ts +835 -0
  59. package/src/cdp/measure/collectors/layers.ts +697 -0
  60. package/src/cdp/measure/collectors/media.ts +522 -0
  61. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  62. package/src/cdp/measure/collectors/queries.ts +464 -0
  63. package/src/cdp/measure/collectors/scroll.ts +599 -0
  64. package/src/cdp/measure/collectors/states.ts +992 -0
  65. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  66. package/src/cdp/measure/collectors/styles.ts +355 -0
  67. package/src/cdp/measure/collectors/text.ts +713 -0
  68. package/src/cdp/measure/diff.ts +470 -0
  69. package/src/cdp/measure/explain.ts +498 -0
  70. package/src/cdp/measure/map-ax.ts +633 -0
  71. package/src/cdp/measure/map-focus.ts +188 -0
  72. package/src/cdp/measure/map-layers.ts +177 -0
  73. package/src/cdp/measure/map-scroll.ts +317 -0
  74. package/src/cdp/measure/redaction.ts +51 -0
  75. package/src/cdp/measure/settle.ts +1051 -0
  76. package/src/cdp/measure/snapshot.ts +390 -0
  77. package/src/cdp/measure/sweep.ts +330 -0
  78. package/src/cdp/measure/types.ts +220 -0
  79. package/src/cdp/measure/v2/collectors.ts +19 -0
  80. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  81. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  82. package/src/cdp/measure/v2/target-lock.ts +15 -0
  83. package/src/cdp/measure/v2/target.ts +16 -0
  84. package/src/cdp/motion/jank.ts +433 -0
  85. package/src/cdp/motion/mask.ts +421 -0
  86. package/src/cdp/motion/recorder.ts +764 -0
  87. package/src/cdp/motion/response.ts +356 -0
  88. package/src/cdp/motion/timeline.ts +215 -0
  89. package/src/cdp/record.ts +5 -212
  90. package/src/cdp/recorder-bridge.ts +1894 -0
  91. package/src/cdp/recorder-client.ts +326 -0
  92. package/src/cdp/scope-lock.ts +73 -0
  93. package/src/cdp/screenshot.ts +199 -66
  94. package/src/cdp/source-map.ts +309 -0
  95. package/src/cdp/targets.ts +21 -1
  96. package/src/cdp/timing.ts +87 -0
  97. package/src/cdp/types.ts +87 -6
  98. package/src/cdp/viewport.ts +21 -0
  99. package/src/cdp.ts +12 -58
  100. package/src/command-help.ts +29 -0
  101. package/src/command-resolution.ts +22 -0
  102. package/src/command-walker.ts +35 -0
  103. package/src/contracts/a11y.ts +33 -0
  104. package/src/contracts/cursor.ts +35 -0
  105. package/src/contracts/index.ts +15 -0
  106. package/src/contracts/motion.ts +19 -0
  107. package/src/contracts/neutral.ts +56 -0
  108. package/src/contracts/primitives.ts +205 -0
  109. package/src/contracts/release.ts +17 -0
  110. package/src/contracts/results.ts +208 -0
  111. package/src/contracts/routes.ts +140 -0
  112. package/src/contracts/snapshot.ts +92 -0
  113. package/src/cursor/index.ts +13 -0
  114. package/src/cursor/service.ts +310 -0
  115. package/src/errors.ts +71 -0
  116. package/src/har-manager.ts +992 -41
  117. package/src/interact.ts +410 -98
  118. package/src/output/artifact-lifecycle.ts +371 -0
  119. package/src/output/artifact.ts +526 -0
  120. package/src/output/diff.ts +111 -0
  121. package/src/output/exact-raw.ts +114 -0
  122. package/src/output/render.ts +987 -0
  123. package/src/output/selector.ts +185 -0
  124. package/src/output/structured.ts +211 -0
  125. package/src/registry.ts +208 -0
  126. package/src/session/artifacts.ts +481 -0
  127. package/src/session/commands.ts +1237 -368
  128. package/src/session/coordinator.ts +251 -0
  129. package/src/session/har-filter.ts +57 -0
  130. package/src/session/log-tailer.ts +653 -0
  131. package/src/session-context.ts +302 -34
  132. package/src/vault/bundle.ts +32 -17
  133. package/src/cdp/commands/exec.ts +0 -105
  134. package/src/cdp/commands/har.ts +0 -128
  135. package/src/cdp/commands/tabs.ts +0 -183
  136. package/src/cdp/commands/traffic.ts +0 -121
  137. package/src/cdp/commands/ui.ts +0 -172
  138. package/src/cdp/execute.ts +0 -113
  139. package/src/cdp/har-output.ts +0 -47
  140. package/src/cdp/usage.ts +0 -77
  141. package/src/tab-session.ts +0 -91
@@ -0,0 +1,992 @@
1
+ /**
2
+ * `states.json` collector — the interaction-state matrix substrate behind
3
+ * `snap --state` (`diff --full`/`explain` read this file for the per-state
4
+ * style/geometry/hittest deltas; there is no separate top-level command).
5
+ * Written ONLY when at least one `--state` was requested (`ctx.state.length
6
+ * > 0`); otherwise this is a no-op (no file written). Owned by U12.
7
+ *
8
+ * Independent of the other 13 collectors (they all fan out via
9
+ * `Promise.all` in `snapshot.ts` — there is no cross-collector read
10
+ * ordering, and `geometry.json`/`styles.json`/`hittest.json` may not exist
11
+ * yet when this runs), so this collector drives its own minimal
12
+ * geometry/style/hittest re-capture per forced element rather than reading
13
+ * those files. Scope: top-frame light DOM only — elements inside an
14
+ * iframe or a shadow root are out of reach of `DOM.querySelectorAll`'s
15
+ * plain (non-piercing) subtree walk and are not visited here; frame/shadow
16
+ * stitching is `geometry.json`'s concern, not this collector's.
17
+ *
18
+ * ## `--state` grammar (`ctx.state`, each entry `<name>` or `<name>:<selector>`)
19
+ * - `name` is one of `normal|hover|focus|active|checked|open|disabled|invalid|all`.
20
+ * - With a `:<selector>` suffix, the state is scoped to the (capped) CSS
21
+ * matches of that selector. Without one, it's applied to a capped,
22
+ * independently-derived "eligible" element set per state (interactive
23
+ * elements for hover/focus/active/normal; native checkbox/radio inputs
24
+ * for checked; `details`/`dialog` for open; native disable-able form
25
+ * controls for disabled; constraint-validation-capable controls for
26
+ * invalid).
27
+ * - `all` expands to all 7 concrete states (not `normal`) against the
28
+ * same selector (or each state's own eligible set, if bare).
29
+ * - An unrecognized `name` is reported as one unsupported fact — never
30
+ * thrown.
31
+ *
32
+ * ## Forcing mechanism
33
+ * - `hover`/`focus`/`active` use `CSS.forcePseudoState` (U05's
34
+ * `forcePseudoStateForNode`) — `focus` forces both `focus` and
35
+ * `focus-visible` together so pages that key off either selector show
36
+ * the state change. Restored by forcing `[]` afterward.
37
+ * - `checked`/`disabled`/`open`/`invalid` are not real CSS pseudo-classes
38
+ * in this sense; they're forced via reversible, capped IDL-property
39
+ * writes in-page (`el.checked`, `el.disabled`, `el.open`,
40
+ * `el.setCustomValidity(...)`), gated on the element actually exposing
41
+ * that property/method live (never assumed from tag name alone) and
42
+ * restored to the pre-forced value afterward. An element that doesn't
43
+ * expose the property is an unsupported fact, not an error.
44
+ * - Every forced element's original value is restored in a `finally`
45
+ * block, even when the post-force capture throws. The native-state force
46
+ * expression itself is ALSO self-contained: it records what it is about
47
+ * to mutate (a marker id, a radio peer, an IDL property's previous value)
48
+ * BEFORE the call that applies it, then wraps the mutation in an in-page
49
+ * `try`/`catch` whose rollback is built entirely out of non-throwing
50
+ * (best-effort) primitives — so a throw partway through (e.g. a hostile
51
+ * element whose `setAttribute` or property setter mutates and THEN
52
+ * throws, or whose rollback call itself throws) can never leave a marker
53
+ * or a flipped property behind unrecorded, and can never let the
54
+ * rollback failure itself escape the expression uncaught. The response
55
+ * always resolves to `{ supported: false, reason }` on that path (`reason`
56
+ * says "rollback incomplete" if a rollback step itself failed) — so the
57
+ * Node-side `finally` never needs to guess: it only installs a
58
+ * `restoreFn` when the force response reports `supported: true`.
59
+ *
60
+ * ## `states.json` shape
61
+ * ```
62
+ * {
63
+ * requested: string[]; // ctx.state, verbatim
64
+ * truncatedRequests: Array<{ // one entry per --state request whose match count exceeded its cap (MAX_SELECTOR_MATCHES/MAX_AUTO_ELEMENTS) — never silent (I-5)
65
+ * state: string; selector?: string; matched: number; kept: number;
66
+ * }>;
67
+ * elements: Array<{
68
+ * id: string; // "state-<n>", sequential within this file
69
+ * state: string; // concrete state name, or "normal", or the raw unrecognized name
70
+ * selector?: string; // requested selector, or a best-effort tag#id.class for an auto-discovered element
71
+ * backendNodeId?: number | null; // present (never omitted) once an element was resolved: the real id, or null when DOM.describeNode failed — see identityUnresolved
72
+ * identityUnresolved?: true; // true only alongside a null backendNodeId
73
+ * axName?: string; // aria-label, when present (capped)
74
+ * text?: string; // trimmed textContent (capped)
75
+ * supported: boolean;
76
+ * reason?: string; // present when supported is false
77
+ * forced?: { applied: boolean; restored?: boolean; restorationUnknown?: true }; // restorationUnknown true only when applied:false alongside forceReadUnavailable — a mutation may have partially applied before the force's own result became unreadable
78
+ * geometry?: { before: Rect; after: Rect; delta: {dx,dy,dwidth,dheight}; changed: boolean };
79
+ * style?: { changed: string[]; before: Record<string,string>; after: Record<string,string> };
80
+ * hittest?: { before: {isTarget,topTag}; after: {isTarget,topTag}; changed: boolean };
81
+ * factsUnavailable?: true; // the pre/post evalFacts read itself failed — distinct from a genuine not-found
82
+ * factsUnavailableReason?: 'facts-evaluate-threw' | 'facts-evaluate-returned-no-value';
83
+ * resolutionUnavailable?: true; // `resolveNodeIds` itself threw for this request — distinct from a genuine zero-match selector (I-5)
84
+ * forceReadUnavailable?: true; // the IDL-state force Runtime.evaluate resolved with no/malformed result.value — distinct from a genuine in-page `{supported:false}` (I-5)
85
+ * forceReadUnavailableReason?: 'force-evaluate-returned-no-value' | 'force-evaluate-returned-malformed-value';
86
+ * }>;
87
+ * }
88
+ * ```
89
+ * `normal` entries always have `supported: true` and a zero delta (before
90
+ * === after) — a baseline row for the matrix, not a forced capture.
91
+ */
92
+
93
+ import type { CDPClient } from '../../client.js';
94
+ import type { Rect } from '../../coordinates.js';
95
+ import { forcePseudoStateForNode, type ForcedPseudoClass } from '../../domains.js';
96
+ import type { Collector } from '../types.js';
97
+ import { sanitizeString } from '../redaction.js';
98
+
99
+ // ============================================================================
100
+ // Constants
101
+ // ============================================================================
102
+
103
+ /** Defensive cap on how many elements a single explicit `state:selector` request will process. */
104
+ const MAX_SELECTOR_MATCHES = 10;
105
+ /** Defensive cap on how many auto-discovered elements a bare (no-selector) state request will process — smaller than the selector cap since it has a wider blast radius. */
106
+ const MAX_AUTO_ELEMENTS = 8;
107
+ /** Per-field length cap applied NODE-SIDE to page-controlled strings (text, aria-label). Passed to {@link sanitizeString} as `{ max }` — a tighter bound than the shared default. */
108
+ const MAX_STRING_LEN = 200;
109
+
110
+ const CONCRETE_STATES = ['hover', 'focus', 'active', 'checked', 'open', 'disabled', 'invalid'] as const;
111
+ type ConcreteState = (typeof CONCRETE_STATES)[number];
112
+ type RequestedStateName = 'normal' | ConcreteState | 'all';
113
+
114
+ const CONCRETE_STATE_SET: ReadonlySet<string> = new Set(CONCRETE_STATES);
115
+
116
+ const INTERACTIVE_SELECTOR =
117
+ 'a[href], button, input, select, textarea, summary, [tabindex], [role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="tab"], [role="menuitem"], [contenteditable="true"]';
118
+
119
+ /** Eligible-element CSS selector per state, used only when a request has no `:selector` suffix. */
120
+ const ELIGIBLE_SELECTORS: Record<Exclude<RequestedStateName, 'all'>, string> = {
121
+ normal: INTERACTIVE_SELECTOR,
122
+ hover: INTERACTIVE_SELECTOR,
123
+ focus: INTERACTIVE_SELECTOR,
124
+ active: INTERACTIVE_SELECTOR,
125
+ checked: 'input[type="checkbox"], input[type="radio"]',
126
+ open: 'details, dialog',
127
+ disabled: 'button, input, select, textarea, fieldset, optgroup, option',
128
+ invalid: 'input, select, textarea',
129
+ };
130
+
131
+ /** CSS pseudo-classes forced per pseudo-state — `focus` forces both `focus` and `focus-visible` so either selector form shows the delta. */
132
+ const PSEUDO_CLASS_MAP: Record<'hover' | 'focus' | 'active', ForcedPseudoClass[]> = {
133
+ hover: ['hover'],
134
+ focus: ['focus', 'focus-visible'],
135
+ active: ['active'],
136
+ };
137
+
138
+ /** Curated computed-style properties most commonly affected by interaction states. */
139
+ const STYLE_PROPS = [
140
+ 'color',
141
+ 'background-color',
142
+ 'border-color',
143
+ 'border-width',
144
+ 'border-style',
145
+ 'outline-color',
146
+ 'outline-width',
147
+ 'outline-style',
148
+ 'outline-offset',
149
+ 'box-shadow',
150
+ 'opacity',
151
+ 'cursor',
152
+ 'text-decoration-line',
153
+ 'transform',
154
+ 'filter',
155
+ 'font-weight',
156
+ 'visibility',
157
+ 'display',
158
+ 'pointer-events',
159
+ ] as const;
160
+
161
+ // ============================================================================
162
+ // Types
163
+ // ============================================================================
164
+
165
+ interface HitFacts {
166
+ readonly isTarget: boolean;
167
+ readonly topTag: string | null;
168
+ }
169
+
170
+ /** Fixed, factual reason a facts read itself could not be obtained (never a raw exception message, which is unbounded/page-influenced) — present only when {@link ElementFacts.factsUnavailable} is `true`. */
171
+ export type FactsUnavailableReason = 'facts-evaluate-threw' | 'facts-evaluate-returned-no-value';
172
+
173
+ /**
174
+ * Fixed, factual reason the IDL-state FORCE `Runtime.evaluate` call's own
175
+ * result could not be read — distinct from `Runtime.evaluate` throwing
176
+ * (handled by the outer `catch` as a generic capture error) and distinct
177
+ * from a genuine in-page `{ supported: false, reason }` the force expression
178
+ * itself returned (that IS a real, honest determination — never marked with
179
+ * this reason). Present only when a record's `forceReadUnavailable` is `true`.
180
+ */
181
+ export type ForceReadUnavailableReason = 'force-evaluate-returned-no-value' | 'force-evaluate-returned-malformed-value';
182
+
183
+ interface ElementFacts {
184
+ readonly exists: boolean;
185
+ readonly tag?: string;
186
+ readonly rect?: Rect;
187
+ readonly style?: Record<string, string>;
188
+ readonly hit?: HitFacts;
189
+ readonly text?: string | null;
190
+ readonly axName?: string | null;
191
+ /** `true` when this facts read itself failed (`Runtime.evaluate` threw, or resolved with no `value`) — set only by {@link evalFacts}'s own failure wrapping, never by the in-page expression. Distinguishes "could not read" from a genuine `exists:false` measured value (I-5); `exists` is always `false` alongside this marker but must not be read as a measured not-found. */
192
+ readonly factsUnavailable?: true;
193
+ readonly factsUnavailableReason?: FactsUnavailableReason;
194
+ }
195
+
196
+ interface GeometryDelta {
197
+ readonly before: Rect;
198
+ readonly after: Rect;
199
+ readonly delta: { dx: number; dy: number; dwidth: number; dheight: number };
200
+ readonly changed: boolean;
201
+ }
202
+
203
+ interface StyleDelta {
204
+ readonly changed: string[];
205
+ readonly before: Record<string, string>;
206
+ readonly after: Record<string, string>;
207
+ }
208
+
209
+ interface HittestDelta {
210
+ readonly before: HitFacts;
211
+ readonly after: HitFacts;
212
+ readonly changed: boolean;
213
+ }
214
+
215
+ interface StateElementRecord {
216
+ readonly id: string;
217
+ readonly state: string;
218
+ readonly selector?: string;
219
+ /** `number | null` (never omitted) on every record emitted from {@link captureOneElement} — i.e. every record backed by a `DOM.querySelectorAll`-resolved node. `null` when identity resolution itself failed (`DOM.describeNode` threw or returned no `backendNodeId`) — see {@link identityUnresolved}. Omitted entirely on the two record shapes that precede element resolution (an unrecognized `--state` name, or a request whose selector/eligible-set matched zero elements), since no element was ever identified there. */
220
+ readonly backendNodeId?: number | null;
221
+ /** `true` when {@link backendNodeId} is `null` because identity resolution failed — never omit this alongside a `null` backendNodeId. Absent (not `false`) when identity resolved or the record predates element resolution. */
222
+ readonly identityUnresolved?: true;
223
+ readonly axName?: string;
224
+ readonly text?: string;
225
+ readonly supported: boolean;
226
+ readonly reason?: string;
227
+ /** `true` when this request's `resolveNodeIds` call itself threw (`DOM.getDocument`/`DOM.querySelectorAll` failed) — distinguishes "the selector could not even be resolved" from a genuine `reason: 'selector matched no elements'`/`'no eligible element found for this state'` result, which implies resolution succeeded and simply found zero matches (I-5). Never set alongside a resolved `nodeIds` list. */
228
+ readonly resolutionUnavailable?: true;
229
+ /** `true` when the IDL-state FORCE `Runtime.evaluate` resolved with no `result.value` at all, or with a value missing/mistyping the `supported` field — i.e. the force expression's own outcome could never be read, distinct from a genuine in-page `{ supported: false }` determination (I-5). Never set alongside a genuinely-read `value.supported`. */
230
+ readonly forceReadUnavailable?: true;
231
+ readonly forceReadUnavailableReason?: ForceReadUnavailableReason;
232
+ /** Restoration fact for a non-`normal` state: whether a reversible force was applied, and (when it was) whether the page was restored afterward. Present on unsupported/error branches too so restoration is never silently omitted. When {@link forceReadUnavailable} is `true`, whether the in-page force script actually mutated the element before its result became unreadable cannot be determined — `applied` is reported as the conservative `false` but `restorationUnknown: true` marks that this is NOT a confirmed non-mutation (I-6): a genuinely-uncertain restoration state must never read identically to a confirmed no-op. */
233
+ readonly forced?: { readonly applied: boolean; readonly restored?: boolean; readonly restorationUnknown?: true };
234
+ readonly geometry?: GeometryDelta;
235
+ readonly style?: StyleDelta;
236
+ readonly hittest?: HittestDelta;
237
+ /** `true` when the pre- or post-force `evalFacts` read itself failed (evaluate threw, or resolved with no `value`) — distinguishes "could not read" from a genuine `{exists:false}`/"not found" measured outcome (I-5). Present only on that failure path. */
238
+ readonly factsUnavailable?: true;
239
+ readonly factsUnavailableReason?: FactsUnavailableReason;
240
+ }
241
+
242
+ interface ParsedSpec {
243
+ readonly name: RequestedStateName;
244
+ readonly selector: string | null;
245
+ }
246
+
247
+ /** One `--state` request that matched more elements than its cap allowed — emitted so the cap's drop is never silent (I-5). One entry per over-capped request, not per dropped element. */
248
+ interface StatesTruncatedRequest {
249
+ readonly state: string;
250
+ readonly selector?: string;
251
+ readonly matched: number;
252
+ readonly kept: number;
253
+ }
254
+
255
+ interface WorkItem {
256
+ readonly state: Exclude<RequestedStateName, 'all'>;
257
+ readonly selector: string | null;
258
+ }
259
+
260
+ // ============================================================================
261
+ // Spec parsing / expansion
262
+ // ============================================================================
263
+
264
+ function parseStateSpec(raw: string): ParsedSpec | null {
265
+ const idx = raw.indexOf(':');
266
+ const name = (idx === -1 ? raw : raw.slice(0, idx)).trim();
267
+ const selector = idx === -1 ? null : raw.slice(idx + 1).trim() || null;
268
+ if (name !== 'normal' && name !== 'all' && !CONCRETE_STATE_SET.has(name)) {
269
+ return null;
270
+ }
271
+ return { name: name as RequestedStateName, selector };
272
+ }
273
+
274
+ function expandSpecs(raw: readonly string[]): { items: WorkItem[]; invalidRaw: string[] } {
275
+ const items: WorkItem[] = [];
276
+ const invalidRaw: string[] = [];
277
+ for (const entry of raw) {
278
+ const parsed = parseStateSpec(entry);
279
+ if (!parsed) {
280
+ invalidRaw.push(entry);
281
+ continue;
282
+ }
283
+ if (parsed.name === 'all') {
284
+ for (const state of CONCRETE_STATES) items.push({ state, selector: parsed.selector });
285
+ } else {
286
+ items.push({ state: parsed.name, selector: parsed.selector });
287
+ }
288
+ }
289
+ return { items, invalidRaw };
290
+ }
291
+
292
+ // ============================================================================
293
+ // Small page-controlled-string hygiene — `sanitizeToken` keeps a best-effort
294
+ // selector CSS-safe; the assembled selector (and every other page-controlled
295
+ // string this collector emits) is then routed through the shared
296
+ // `sanitizeString` length cap.
297
+ // ============================================================================
298
+
299
+ function sanitizeToken(value: string): string {
300
+ return value.replace(/[^a-zA-Z0-9_-]/g, '');
301
+ }
302
+
303
+ /**
304
+ * Caps every emitted computed-style VALUE (page-controlled — `cursor` and
305
+ * `filter` carry author-controlled `url(...)` of unbounded length).
306
+ * Property NAMES come from the fixed {@link STYLE_PROPS} list and are never
307
+ * page-controlled, so they pass through unchanged; only the values are
308
+ * routed through the shared {@link sanitizeString} cap.
309
+ */
310
+ function sanitizeStyleValues(style: Record<string, string>): Record<string, string> {
311
+ const out: Record<string, string> = {};
312
+ for (const [prop, value] of Object.entries(style)) {
313
+ out[prop] = sanitizeString(value);
314
+ }
315
+ return out;
316
+ }
317
+
318
+ function flattenAttributes(attributes: readonly string[] | undefined): Map<string, string> {
319
+ const map = new Map<string, string>();
320
+ if (!attributes) return map;
321
+ for (let i = 0; i + 1 < attributes.length; i += 2) {
322
+ map.set(attributes[i], attributes[i + 1]);
323
+ }
324
+ return map;
325
+ }
326
+
327
+ function buildSelector(nodeName: string | undefined, attributes: readonly string[] | undefined): string | undefined {
328
+ if (!nodeName) return undefined;
329
+ const tag = nodeName.toLowerCase();
330
+ const attrs = flattenAttributes(attributes);
331
+ let selector = tag;
332
+ const idAttr = attrs.get('id');
333
+ if (idAttr) {
334
+ const cleaned = sanitizeToken(idAttr);
335
+ if (cleaned) selector += `#${cleaned}`;
336
+ }
337
+ const classAttr = attrs.get('class');
338
+ if (classAttr) {
339
+ for (const cls of classAttr.split(/\s+/).filter(Boolean).slice(0, 3)) {
340
+ const cleaned = sanitizeToken(cls);
341
+ if (cleaned) selector += `.${cleaned}`;
342
+ }
343
+ }
344
+ return sanitizeString(selector);
345
+ }
346
+
347
+ // ============================================================================
348
+ // In-page expression builders — every expression is self-contained and
349
+ // re-selects `document.querySelectorAll(selector)[index]` fresh each call.
350
+ // That re-selection assumes the DOM was not mutated structurally between
351
+ // calls within one element's capture — an assumption that is NOT trusted
352
+ // blindly: `identityStillMatches` (below) independently re-resolves the
353
+ // element's `backendNodeId` after the post-force facts capture and compares
354
+ // it to the identity resolved before forcing, catching a synchronous
355
+ // reorder/replace that would otherwise let `selector[index]` silently
356
+ // reselect a different element. Every expression also carries a
357
+ // `__captureState*` marker in its function name so tests can pattern-match
358
+ // reliably, mirroring `settle.ts`'s `__captureSettle*` markers.
359
+ // ============================================================================
360
+
361
+ function buildFactsExpression(selector: string, index: number): string {
362
+ return `(function __captureStateFacts() {
363
+ const el = document.querySelectorAll(${JSON.stringify(selector)})[${index}];
364
+ if (!el) return { exists: false };
365
+ const rect = el.getBoundingClientRect();
366
+ const cs = getComputedStyle(el);
367
+ const style = {};
368
+ for (const p of ${JSON.stringify(STYLE_PROPS)}) { style[p] = cs.getPropertyValue(p); }
369
+ let topTag = null;
370
+ let isTarget = false;
371
+ const cx = rect.left + rect.width / 2;
372
+ const cy = rect.top + rect.height / 2;
373
+ if (rect.width > 0 && rect.height > 0 && cx >= 0 && cy >= 0 && cx <= window.innerWidth && cy <= window.innerHeight) {
374
+ const stack = document.elementsFromPoint(cx, cy);
375
+ const top = stack.length > 0 ? stack[0] : null;
376
+ topTag = top ? top.tagName : null;
377
+ isTarget = top === el;
378
+ }
379
+ return {
380
+ exists: true,
381
+ tag: el.tagName,
382
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
383
+ style: style,
384
+ hit: { isTarget: isTarget, topTag: topTag },
385
+ text: (el.textContent || '').trim(),
386
+ axName: el.getAttribute('aria-label'),
387
+ };
388
+ })()`;
389
+ }
390
+
391
+ type IdlState = Exclude<ConcreteState, 'hover' | 'focus' | 'active'>;
392
+
393
+ /**
394
+ * Forces one reversible IDL-property state. The element is located by
395
+ * `selector[index]` and immediately tagged with a unique
396
+ * `data-capture-state-id` marker (its record id) so restoration can
397
+ * re-find THAT element by a stable handle rather than a positional
398
+ * `selector[index]` that could resolve differently if the force triggered
399
+ * a re-render. The captured `prev` carries everything restoration needs to
400
+ * put the page back exactly: for `checked` radios, each peer of the radio
401
+ * group is tagged with a stable `data-capture-state-radio-id` handle and its
402
+ * pre-force checked value is recorded (forcing one radio unchecks its
403
+ * peers), so restoration reapplies each peer's state by that stable handle
404
+ * rather than a fragile `querySelectorAll` re-query order that a
405
+ * force-triggered DOM insert/reorder could misalign; for `invalid`, whether
406
+ * a pre-existing custom-validity message was set and its text (so it is
407
+ * preserved rather than wiped to '').
408
+ *
409
+ * Every case wraps its mutation in an in-page `try`/`catch`. The record of
410
+ * WHAT to roll back is written BEFORE the call that might apply it — e.g.
411
+ * `tagged = true` is set before the marker `setAttribute` call, and each
412
+ * radio peer is pushed onto `radioGroup` before that peer's own
413
+ * `setAttribute` call — so a page-hostile setter/method that applies its
414
+ * mutation and THEN throws still leaves an accurate rollback record
415
+ * behind, rather than an unrecorded (and therefore un-rolled-back) mutation.
416
+ * The `catch` itself is then built ENTIRELY out of non-throwing rollback
417
+ * primitives (`safeRemoveAttr`/`safeRestore`, both defined per-case via the
418
+ * shared `helpers` string): every individual rollback step — attribute
419
+ * removal, radio-peer restoration, the IDL property/validity restore — is
420
+ * independently guarded, so one rollback step throwing (e.g. a hostile
421
+ * `removeAttribute`) can never abort the rest of the rollback or escape the
422
+ * IIFE. The `catch` therefore ALWAYS returns `{ supported: false, reason }`
423
+ * — never lets an exception reach `Runtime.evaluate` — and when a rollback
424
+ * step itself failed, `reason` says so ("rollback incomplete: ...") instead
425
+ * of silently under-reporting a best-effort (not perfect) cleanup. The
426
+ * calling `Runtime.evaluate` therefore never needs a Node-side `restoreFn`
427
+ * for the failure branch: by the time the response comes back, the page is
428
+ * already back to how it was (or as close as a hostile page allowed), or
429
+ * the force fully succeeded and `prev` carries what restoration needs. This
430
+ * is what makes `captureOneElement`'s pattern of "only install `restoreFn`
431
+ * when `supported: true`" safe.
432
+ */
433
+ function buildForceExpression(selector: string, index: number, state: IdlState, markerId: string): string {
434
+ const base = `const el = document.querySelectorAll(${JSON.stringify(selector)})[${index}]; if (!el) return { supported: false, reason: 'element no longer present' };`;
435
+ const tag = `el.setAttribute('data-capture-state-id', ${JSON.stringify(markerId)});`;
436
+ // Shared in-page rollback machinery, textually inlined into every case
437
+ // below (each `buildForceExpression` call produces one self-contained IIFE
438
+ // string with its own scope — there is no runtime module to share this
439
+ // through, so it is duplicated per-case at the TS-source level instead).
440
+ // `rollbackErrors` collects the message of any THROW a rollback operation
441
+ // itself raises; `safeRemoveAttr`/`safeRestore` never let such a throw
442
+ // propagate, so the surrounding `catch` body — and therefore the whole
443
+ // IIFE — can never itself throw, no matter which individual rollback step
444
+ // fails. `describeError` additionally guards against a hostile
445
+ // `Error`-like object whose own `.message` getter throws.
446
+ const helpers = `
447
+ function describeError(e) { try { return (e && e.message) ? String(e.message) : String(e); } catch (de) { return 'unknown error'; } }
448
+ var rollbackErrors = [];
449
+ function safeRemoveAttr(node, name) { try { node.removeAttribute(name); } catch (re) { rollbackErrors.push(describeError(re)); } }
450
+ function safeRestore(fn) { try { fn(); } catch (re) { rollbackErrors.push(describeError(re)); } }
451
+ function rolledBackReason(e) { return 'force failed (rolled back' + (rollbackErrors.length ? ', rollback incomplete: ' + rollbackErrors.join('; ') : '') + '): ' + describeError(e); }`;
452
+ switch (state) {
453
+ case 'checked':
454
+ // `tagged` is set to true BEFORE the marker `setAttribute` call (not
455
+ // after it returns), and each radio peer is pushed onto `radioGroup`
456
+ // BEFORE that peer's own `setAttribute` call — so a hostile
457
+ // setter/method that applies its mutation and THEN throws still leaves
458
+ // an accurate rollback record behind: the `catch` below always knows
459
+ // what to (best-effort) undo, even for the exact call that threw.
460
+ return `(function __captureStateForce_checked() { ${base}
461
+ if (!('checked' in el)) return { supported: false, reason: 'element has no checked property' };${helpers}
462
+ var tagged = false;
463
+ var radioGroup = [];
464
+ var prevChecked = el.checked;
465
+ try {
466
+ tagged = true;
467
+ ${tag}
468
+ if (el.type === 'radio' && el.name) {
469
+ var scope = el.form || document;
470
+ var peers = Array.prototype.slice.call(scope.querySelectorAll('input[type="radio"]')).filter(function(r){ return r.name === el.name; });
471
+ for (var ri = 0; ri < peers.length; ri++) {
472
+ var p = peers[ri];
473
+ var rid = ${JSON.stringify(markerId)} + '-radio-' + ri;
474
+ radioGroup.push({ rid: rid, checked: !!p.checked, el: p });
475
+ p.setAttribute('data-capture-state-radio-id', rid);
476
+ }
477
+ }
478
+ el.checked = true;
479
+ return { supported: true, prev: { checked: !!prevChecked, radioGroup: radioGroup.length ? radioGroup.map(function(r){ return { rid: r.rid, checked: r.checked }; }) : null } };
480
+ } catch (e) {
481
+ if (tagged) safeRemoveAttr(el, 'data-capture-state-id');
482
+ for (var rj = 0; rj < radioGroup.length; rj++) {
483
+ var peerEntry = radioGroup[rj];
484
+ safeRemoveAttr(peerEntry.el, 'data-capture-state-radio-id');
485
+ safeRestore(function() { peerEntry.el.checked = peerEntry.checked; });
486
+ }
487
+ safeRestore(function() { el.checked = prevChecked; });
488
+ return { supported: false, reason: rolledBackReason(e) };
489
+ }
490
+ })()`;
491
+ case 'disabled':
492
+ return `(function __captureStateForce_disabled() { ${base}
493
+ if (!('disabled' in el)) return { supported: false, reason: 'element has no disabled property' };${helpers}
494
+ var tagged = false;
495
+ var prev = el.disabled;
496
+ try {
497
+ tagged = true;
498
+ ${tag}
499
+ el.disabled = true;
500
+ return { supported: true, prev: { value: !!prev } };
501
+ } catch (e) {
502
+ if (tagged) safeRemoveAttr(el, 'data-capture-state-id');
503
+ safeRestore(function() { el.disabled = prev; });
504
+ return { supported: false, reason: rolledBackReason(e) };
505
+ }
506
+ })()`;
507
+ case 'open':
508
+ return `(function __captureStateForce_open() { ${base}
509
+ if (!('open' in el) || typeof el.open !== 'boolean') return { supported: false, reason: 'element has no boolean open property' };${helpers}
510
+ var tagged = false;
511
+ var prev = el.open;
512
+ try {
513
+ tagged = true;
514
+ ${tag}
515
+ el.open = true;
516
+ return { supported: true, prev: { value: !!prev } };
517
+ } catch (e) {
518
+ if (tagged) safeRemoveAttr(el, 'data-capture-state-id');
519
+ safeRestore(function() { el.open = prev; });
520
+ return { supported: false, reason: rolledBackReason(e) };
521
+ }
522
+ })()`;
523
+ case 'invalid':
524
+ return `(function __captureStateForce_invalid() { ${base}
525
+ if (typeof el.setCustomValidity !== 'function') return { supported: false, reason: 'element has no constraint-validation API' };${helpers}
526
+ var tagged = false;
527
+ var hadCustom = !!(el.validity && el.validity.customError);
528
+ var prevMsg = hadCustom ? (el.validationMessage || '') : '';
529
+ try {
530
+ tagged = true;
531
+ ${tag}
532
+ el.setCustomValidity('capture-forced-invalid');
533
+ return { supported: true, prev: { hadCustom: hadCustom, prevMsg: prevMsg } };
534
+ } catch (e) {
535
+ if (tagged) safeRemoveAttr(el, 'data-capture-state-id');
536
+ safeRestore(function() { el.setCustomValidity(hadCustom ? prevMsg : ''); });
537
+ return { supported: false, reason: rolledBackReason(e) };
538
+ }
539
+ })()`;
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Restores one forced IDL-property state, re-finding the element by its
545
+ * stable `data-capture-state-id` marker (NOT `selector[index]`). Every
546
+ * mutation step — the primary property write, each radio peer's `checked`
547
+ * write and its own marker removal, and the final `data-capture-state-id`
548
+ * removal — is wrapped in a non-throwing `__safe(fn)` guard, so a hostile
549
+ * setter/`removeAttribute` throwing on ANY one step does not abort the rest:
550
+ * restoration is best-effort PER STEP, not all-or-nothing. `__restoreOk`
551
+ * starts `true` and flips to `false` the instant any `__safe` call catches,
552
+ * so the returned `{ restored: boolean }` is `true` only when every step
553
+ * genuinely succeeded — never a false claim of clean restoration when one
554
+ * step silently failed. `checked` restores every radio-group peer's
555
+ * original checked state by its stable `data-capture-state-radio-id` handle
556
+ * (NOT re-query order). The recorded `prev.radioGroup` (the `rid` handles
557
+ * tagged at force time) is the sole authority for which peers to restore —
558
+ * the loop runs whenever `prev.radioGroup` is present, INDEPENDENT of the
559
+ * restore-time target's current `type`/`name` (which the page may have
560
+ * mutated between force and restore); a recorded peer that cannot be found,
561
+ * have its `checked` restored, or have its marker cleared flips
562
+ * `__restoreOk` to `false` rather than being silently skipped. `invalid`
563
+ * restores the pre-existing custom-validity message exactly (or clears it
564
+ * if there was none).
565
+ */
566
+ function buildRestoreExpression(state: IdlState, prev: unknown, markerId: string): string {
567
+ const find = `var el = document.querySelector(${JSON.stringify(`[data-capture-state-id="${markerId}"]`)}); if (!el) return { restored: false, reason: 'element no longer present' };`;
568
+ const prevLit = JSON.stringify(prev ?? null);
569
+ const helpers = `var __restoreOk = true; function __safe(fn) { try { fn(); } catch (re) { __restoreOk = false; } }`;
570
+ switch (state) {
571
+ case 'checked':
572
+ return `(function __captureStateRestore_checked() { ${find}
573
+ var prev = ${prevLit}; ${helpers}
574
+ __safe(function() { el.checked = !!(prev && prev.checked); });
575
+ if (prev && prev.radioGroup) {
576
+ for (var i = 0; i < prev.radioGroup.length; i++) {
577
+ var entry = prev.radioGroup[i];
578
+ if (!entry || !entry.rid) { __restoreOk = false; continue; }
579
+ var peer = document.querySelector('[data-capture-state-radio-id="' + entry.rid + '"]');
580
+ if (peer) {
581
+ __safe(function() { peer.checked = !!entry.checked; });
582
+ __safe(function() { peer.removeAttribute('data-capture-state-radio-id'); });
583
+ } else {
584
+ __restoreOk = false;
585
+ }
586
+ }
587
+ }
588
+ __safe(function() { el.removeAttribute('data-capture-state-id'); });
589
+ return { restored: __restoreOk };
590
+ })()`;
591
+ case 'disabled':
592
+ return `(function __captureStateRestore_disabled() { ${find}
593
+ var prev = ${prevLit}; ${helpers}
594
+ __safe(function() { el.disabled = !!(prev && prev.value); });
595
+ __safe(function() { el.removeAttribute('data-capture-state-id'); });
596
+ return { restored: __restoreOk };
597
+ })()`;
598
+ case 'open':
599
+ return `(function __captureStateRestore_open() { ${find}
600
+ var prev = ${prevLit}; ${helpers}
601
+ __safe(function() { el.open = !!(prev && prev.value); });
602
+ __safe(function() { el.removeAttribute('data-capture-state-id'); });
603
+ return { restored: __restoreOk };
604
+ })()`;
605
+ case 'invalid':
606
+ return `(function __captureStateRestore_invalid() { ${find}
607
+ var prev = ${prevLit}; ${helpers}
608
+ __safe(function() { el.setCustomValidity(prev && prev.hadCustom ? prev.prevMsg : ''); });
609
+ __safe(function() { el.removeAttribute('data-capture-state-id'); });
610
+ return { restored: __restoreOk };
611
+ })()`;
612
+ }
613
+ }
614
+
615
+ // ============================================================================
616
+ // CDP-facing helpers
617
+ // ============================================================================
618
+
619
+ async function resolveNodeIds(client: CDPClient, selector: string, limit: number): Promise<{ nodeIds: number[]; total: number }> {
620
+ const doc = (await client.send('DOM.getDocument', { depth: -1, pierce: false })) as { root: { nodeId: number } };
621
+ const result = (await client.send('DOM.querySelectorAll', { nodeId: doc.root.nodeId, selector })) as { nodeIds: number[] };
622
+ return { nodeIds: result.nodeIds.slice(0, limit), total: result.nodeIds.length };
623
+ }
624
+
625
+ async function describeNode(client: CDPClient, nodeId: number): Promise<{ backendNodeId?: number; selector?: string }> {
626
+ try {
627
+ const described = (await client.send('DOM.describeNode', { nodeId })) as {
628
+ node?: { nodeName?: string; backendNodeId?: number; attributes?: string[] };
629
+ };
630
+ return {
631
+ backendNodeId: described.node?.backendNodeId,
632
+ selector: buildSelector(described.node?.nodeName, described.node?.attributes),
633
+ };
634
+ } catch {
635
+ return {};
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Re-resolves `selector[index]`'s CURRENT `backendNodeId` via a fresh
641
+ * `DOM.querySelectorAll` + `DOM.describeNode` round trip and compares it to
642
+ * the `backendNodeId` this element was identified by BEFORE forcing —
643
+ * catching the case where a synchronous DOM reorder/replace between the
644
+ * pre-force and post-force capture makes the page-side
645
+ * `document.querySelectorAll(selector)[index]` reselection resolve to a
646
+ * DIFFERENT element than the one `before`/force operated on (I-3). Returns
647
+ * `true` when nothing can be verified (`expectedBackendNodeId` was never
648
+ * resolved in the first place) — best-effort, never a block on identity
649
+ * that was already missing elsewhere in the pipeline; `false` on any CDP
650
+ * failure or an actual mismatch.
651
+ */
652
+ async function identityStillMatches(
653
+ client: CDPClient,
654
+ selector: string,
655
+ index: number,
656
+ expectedBackendNodeId: number | undefined,
657
+ ): Promise<boolean> {
658
+ if (expectedBackendNodeId === undefined) return true;
659
+ try {
660
+ const { nodeIds } = await resolveNodeIds(client, selector, index + 1);
661
+ const nodeId = nodeIds[index];
662
+ if (nodeId === undefined) return false;
663
+ const described = await describeNode(client, nodeId);
664
+ return described.backendNodeId === expectedBackendNodeId;
665
+ } catch {
666
+ return false;
667
+ }
668
+ }
669
+
670
+ /** Reads pre/post-force facts for one element. A FAILED read — the evaluate throws, or resolves without a `value` — is wrapped as an explicit `factsUnavailable` fact rather than coerced to `{exists:false}` (I-5): a genuinely-absent element and a read that could not happen must stay distinguishable to every caller. */
671
+ async function evalFacts(client: CDPClient, selector: string, index: number): Promise<ElementFacts> {
672
+ let response: { result?: { value?: ElementFacts } };
673
+ try {
674
+ response = (await client.send('Runtime.evaluate', {
675
+ expression: buildFactsExpression(selector, index),
676
+ returnByValue: true,
677
+ })) as { result?: { value?: ElementFacts } };
678
+ } catch {
679
+ return { exists: false, factsUnavailable: true, factsUnavailableReason: 'facts-evaluate-threw' };
680
+ }
681
+ const value = response.result?.value;
682
+ if (value === undefined) {
683
+ return { exists: false, factsUnavailable: true, factsUnavailableReason: 'facts-evaluate-returned-no-value' };
684
+ }
685
+ return value;
686
+ }
687
+
688
+ function zeroDelta(before: Rect): GeometryDelta {
689
+ return { before, after: before, delta: { dx: 0, dy: 0, dwidth: 0, dheight: 0 }, changed: false };
690
+ }
691
+
692
+ /** Captures one requested state on one already-resolved node: baseline facts, force (when not `normal`), post-force facts, delta, and — always, even on error — restoration. */
693
+ async function captureOneElement(
694
+ client: CDPClient,
695
+ selector: string,
696
+ index: number,
697
+ nodeId: number,
698
+ identity: { backendNodeId?: number; selector?: string },
699
+ state: Exclude<RequestedStateName, 'all'>,
700
+ id: string,
701
+ ): Promise<StateElementRecord> {
702
+ const resolvedSelector = identity.selector ?? undefined;
703
+ // `identity.backendNodeId` came from `describeNode`, which returns `{}` on
704
+ // failure — `undefined` here means identity resolution failed, never that
705
+ // resolution wasn't attempted (every `captureOneElement` caller resolves a
706
+ // node first). Emit `null` + `identityUnresolved: true` per I-3, never omit.
707
+ const backendNodeId = identity.backendNodeId ?? null;
708
+ const identityUnresolved: true | undefined = identity.backendNodeId === undefined ? true : undefined;
709
+
710
+ const before = await evalFacts(client, selector, index);
711
+ if (before.factsUnavailable) {
712
+ return {
713
+ id,
714
+ state,
715
+ selector: resolvedSelector,
716
+ backendNodeId,
717
+ identityUnresolved,
718
+ supported: false,
719
+ reason: 'facts read failed at capture time',
720
+ factsUnavailable: true,
721
+ factsUnavailableReason: before.factsUnavailableReason,
722
+ };
723
+ }
724
+ if (!before.exists) {
725
+ return { id, state, selector: resolvedSelector, backendNodeId, identityUnresolved, supported: false, reason: 'element not found at capture time' };
726
+ }
727
+
728
+ const axName = before.axName ? sanitizeString(before.axName, { max: MAX_STRING_LEN }) : undefined;
729
+ const text = before.text ? sanitizeString(before.text, { max: MAX_STRING_LEN }) : undefined;
730
+
731
+ if (state === 'normal') {
732
+ const rect = before.rect!;
733
+ const style = sanitizeStyleValues(before.style!);
734
+ const hit = before.hit!;
735
+ return {
736
+ id,
737
+ state,
738
+ selector: resolvedSelector,
739
+ backendNodeId,
740
+ identityUnresolved,
741
+ axName,
742
+ text,
743
+ supported: true,
744
+ geometry: zeroDelta(rect),
745
+ style: { changed: [], before: style, after: style },
746
+ hittest: { before: hit, after: hit, changed: false },
747
+ };
748
+ }
749
+
750
+ const isPseudo = state === 'hover' || state === 'focus' || state === 'active';
751
+ let restoreFn: (() => Promise<void>) | null = null;
752
+ let supported = true;
753
+ let didForce = false;
754
+ let reason: string | undefined;
755
+ let after: ElementFacts | undefined;
756
+ let captureErrorReason: string | undefined;
757
+ let restored = true;
758
+ let afterFactsUnavailableReason: FactsUnavailableReason | undefined;
759
+ let forceReadUnavailableReason: ForceReadUnavailableReason | undefined;
760
+
761
+ try {
762
+ if (isPseudo) {
763
+ const classes = PSEUDO_CLASS_MAP[state];
764
+ await forcePseudoStateForNode(client, nodeId, classes);
765
+ didForce = true;
766
+ restoreFn = () => forcePseudoStateForNode(client, nodeId, []);
767
+ } else {
768
+ const forceResponse = (await client.send('Runtime.evaluate', {
769
+ expression: buildForceExpression(selector, index, state, id),
770
+ returnByValue: true,
771
+ })) as { result?: { value?: { supported: unknown; reason?: string; prev?: unknown } } };
772
+ const value = forceResponse.result?.value;
773
+ // A missing `result.value` (evaluate resolved without ever producing a
774
+ // value) or a value that doesn't even carry a boolean `supported` field
775
+ // (malformed) is a FAILED READ of the force outcome — never the same fact
776
+ // as the in-page expression genuinely determining `{ supported: false }`.
777
+ // The in-page expression (`buildForceExpression`) is self-contained and
778
+ // ALWAYS resolves to a well-formed `{ supported, reason? }`/`{ supported,
779
+ // prev }` on every path it controls, including its own rollback failures
780
+ // (see the builder's doc comment) — so a value that fails this shape
781
+ // check did not come from that contract being exercised honestly; it came
782
+ // from the read itself failing. And because the force expression may have
783
+ // partially executed a page mutation before the response became
784
+ // unreadable, whether anything was actually applied cannot be determined
785
+ // either (I-6) — that uncertainty is carried through to the returned
786
+ // record below via `forced.restorationUnknown`, never silently reported
787
+ // as a confirmed `{ applied: false }`.
788
+ if (value === undefined) {
789
+ supported = false;
790
+ forceReadUnavailableReason = 'force-evaluate-returned-no-value';
791
+ reason = 'force read failed: Runtime.evaluate returned no result value';
792
+ } else if (typeof value.supported !== 'boolean') {
793
+ supported = false;
794
+ forceReadUnavailableReason = 'force-evaluate-returned-malformed-value';
795
+ reason = 'force read failed: Runtime.evaluate returned a malformed value';
796
+ } else if (!value.supported) {
797
+ supported = false;
798
+ reason = value.reason ? sanitizeString(value.reason) : 'unsupported';
799
+ } else {
800
+ didForce = true;
801
+ const prev = value.prev;
802
+ restoreFn = async () => {
803
+ const restoreResponse = (await client.send('Runtime.evaluate', {
804
+ expression: buildRestoreExpression(state, prev, id),
805
+ returnByValue: true,
806
+ })) as { result?: { value?: { restored?: boolean } } };
807
+ if (restoreResponse.result?.value?.restored !== true) restored = false;
808
+ };
809
+ }
810
+ }
811
+
812
+ if (supported) {
813
+ after = await evalFacts(client, selector, index);
814
+ if (after.factsUnavailable) {
815
+ supported = false;
816
+ afterFactsUnavailableReason = after.factsUnavailableReason;
817
+ reason = 'facts read failed after forcing state';
818
+ } else if (!after.exists) {
819
+ supported = false;
820
+ reason = 'element no longer present after forcing state';
821
+ } else if (!(await identityStillMatches(client, selector, index, identity.backendNodeId))) {
822
+ supported = false;
823
+ reason = 'post-force facts no longer resolve to the original element (identity check failed)';
824
+ }
825
+ }
826
+ } catch (err) {
827
+ supported = false;
828
+ captureErrorReason = sanitizeString(`capture error: ${err instanceof Error ? err.message : String(err)}`);
829
+ } finally {
830
+ if (restoreFn) {
831
+ try {
832
+ await restoreFn();
833
+ } catch {
834
+ restored = false;
835
+ }
836
+ }
837
+ }
838
+
839
+ if (!supported) {
840
+ return {
841
+ id,
842
+ state,
843
+ selector: resolvedSelector,
844
+ backendNodeId,
845
+ identityUnresolved,
846
+ axName,
847
+ text,
848
+ supported: false,
849
+ reason: captureErrorReason ?? reason,
850
+ forced: didForce
851
+ ? { applied: true, restored }
852
+ : forceReadUnavailableReason
853
+ ? { applied: false, restorationUnknown: true as const }
854
+ : { applied: false },
855
+ ...(afterFactsUnavailableReason ? { factsUnavailable: true as const, factsUnavailableReason: afterFactsUnavailableReason } : {}),
856
+ ...(forceReadUnavailableReason ? { forceReadUnavailable: true as const, forceReadUnavailableReason } : {}),
857
+ };
858
+ }
859
+
860
+ const beforeRect = before.rect!;
861
+ const afterRect = after!.rect!;
862
+ const rawBeforeStyle = before.style!;
863
+ const rawAfterStyle = after!.style!;
864
+ const beforeStyle = sanitizeStyleValues(rawBeforeStyle);
865
+ const afterStyle = sanitizeStyleValues(rawAfterStyle);
866
+ const beforeHit = before.hit!;
867
+ const afterHit = after!.hit!;
868
+
869
+ const geomChanged =
870
+ beforeRect.x !== afterRect.x || beforeRect.y !== afterRect.y || beforeRect.width !== afterRect.width || beforeRect.height !== afterRect.height;
871
+ const changedStyleProps = STYLE_PROPS.filter((p) => rawBeforeStyle[p] !== rawAfterStyle[p]);
872
+ const hitChanged = beforeHit.isTarget !== afterHit.isTarget || beforeHit.topTag !== afterHit.topTag;
873
+
874
+ return {
875
+ id,
876
+ state,
877
+ selector: resolvedSelector,
878
+ backendNodeId,
879
+ identityUnresolved,
880
+ axName,
881
+ text,
882
+ supported: true,
883
+ forced: { applied: true, restored },
884
+ geometry: {
885
+ before: beforeRect,
886
+ after: afterRect,
887
+ delta: {
888
+ dx: afterRect.x - beforeRect.x,
889
+ dy: afterRect.y - beforeRect.y,
890
+ dwidth: afterRect.width - beforeRect.width,
891
+ dheight: afterRect.height - beforeRect.height,
892
+ },
893
+ changed: geomChanged,
894
+ },
895
+ style: { changed: changedStyleProps, before: beforeStyle, after: afterStyle },
896
+ hittest: { before: beforeHit, after: afterHit, changed: hitChanged },
897
+ };
898
+ }
899
+
900
+ // ============================================================================
901
+ // Collector entry point
902
+ // ============================================================================
903
+
904
+ export const collectStates: Collector = async (ctx) => {
905
+ if (ctx.state.length === 0) return;
906
+ const { client } = ctx;
907
+
908
+ const { items, invalidRaw } = expandSpecs(ctx.state);
909
+ const elements: StateElementRecord[] = [];
910
+ const truncatedRequests: StatesTruncatedRequest[] = [];
911
+ let seq = 0;
912
+
913
+ for (const raw of invalidRaw) {
914
+ elements.push({
915
+ id: `state-${seq}`,
916
+ state: sanitizeString(raw),
917
+ supported: false,
918
+ reason: `unrecognized state name (expected one of ${CONCRETE_STATES.join('|')}, normal, or all)`,
919
+ });
920
+ seq += 1;
921
+ }
922
+
923
+ for (const item of items) {
924
+ const selector = item.selector ?? ELIGIBLE_SELECTORS[item.state];
925
+ const limit = item.selector ? MAX_SELECTOR_MATCHES : MAX_AUTO_ELEMENTS;
926
+
927
+ let nodeIds: number[];
928
+ let total = 0;
929
+ // I-5: a `resolveNodeIds` THROW (DOM.getDocument/DOM.querySelectorAll failed) must stay
930
+ // distinguishable from a genuine zero-match resolve — both would otherwise collapse to the
931
+ // same empty `nodeIds`/`total:0` and read as "the selector matched nothing", silently
932
+ // coercing a failed read into a benign no-match result.
933
+ let resolutionFailed = false;
934
+ try {
935
+ const resolved = await resolveNodeIds(client, selector, limit);
936
+ nodeIds = resolved.nodeIds;
937
+ total = resolved.total;
938
+ } catch {
939
+ nodeIds = [];
940
+ resolutionFailed = true;
941
+ }
942
+
943
+ if (total > nodeIds.length) {
944
+ truncatedRequests.push({
945
+ state: item.state,
946
+ selector: item.selector ? sanitizeString(item.selector) : undefined,
947
+ matched: total,
948
+ kept: nodeIds.length,
949
+ });
950
+ }
951
+
952
+ if (nodeIds.length === 0) {
953
+ elements.push({
954
+ id: `state-${seq}`,
955
+ state: item.state,
956
+ selector: item.selector ? sanitizeString(item.selector) : undefined,
957
+ supported: false,
958
+ reason: resolutionFailed
959
+ ? 'selector resolution failed'
960
+ : item.selector
961
+ ? 'selector matched no elements'
962
+ : 'no eligible element found for this state',
963
+ ...(resolutionFailed ? { resolutionUnavailable: true as const } : {}),
964
+ });
965
+ seq += 1;
966
+ continue;
967
+ }
968
+
969
+ for (let i = 0; i < nodeIds.length; i += 1) {
970
+ const nodeId = nodeIds[i];
971
+ const identity = await describeNode(client, nodeId);
972
+ const record = await captureOneElement(
973
+ client,
974
+ selector,
975
+ i,
976
+ nodeId,
977
+ { backendNodeId: identity.backendNodeId, selector: identity.selector ?? (item.selector ? sanitizeString(item.selector) : undefined) },
978
+ item.state,
979
+ `state-${seq}`,
980
+ );
981
+ elements.push(record);
982
+ seq += 1;
983
+ }
984
+ }
985
+
986
+ ctx.write.json('states.json', {
987
+ requested: ctx.state.map((s) => sanitizeString(s)),
988
+ scope: { root: 'top-document', shadowDom: 'light-only' },
989
+ elements,
990
+ truncatedRequests,
991
+ });
992
+ };