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