@reticlehq/browser 2.7.0 → 2.9.0
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/NOTICE +7 -0
- package/README.md +2 -2
- package/dist/actions/actions-dom.js +23 -15
- package/dist/actions/actions.d.ts +17 -0
- package/dist/actions/actions.js +132 -29
- package/dist/actions/synthetic-input.d.ts +9 -0
- package/dist/actions/synthetic-input.js +40 -0
- package/dist/commands/commands.js +18 -1
- package/dist/connect-options.d.ts +9 -9
- package/dist/dom/a11y.d.ts +9 -0
- package/dist/dom/a11y.js +19 -0
- package/dist/dom/desktop-capture.js +20 -3
- package/dist/dom/dom-ignore.d.ts +19 -6
- package/dist/dom/dom-ignore.js +20 -13
- package/dist/dom/query.d.ts +2 -2
- package/dist/dom/query.js +85 -45
- package/dist/dom/refs.d.ts +28 -0
- package/dist/dom/refs.js +87 -0
- package/dist/dom/snapshot.d.ts +5 -0
- package/dist/dom/snapshot.js +34 -0
- package/dist/dom/source.d.ts +14 -0
- package/dist/dom/source.js +16 -0
- package/dist/dom/theme.d.ts +33 -10
- package/dist/dom/theme.js +109 -50
- package/dist/observers/console.js +5 -2
- package/dist/observers/install-all.js +4 -1
- package/dist/observers/ipc.d.ts +16 -0
- package/dist/observers/ipc.js +19 -1
- package/dist/observers/network.d.ts +8 -0
- package/dist/observers/network.js +29 -6
- package/dist/observers/state.js +21 -2
- package/dist/presenter/presenter-brand.d.ts +24 -7
- package/dist/presenter/presenter-brand.js +23 -6
- package/dist/presenter/presenter-config.d.ts +102 -9
- package/dist/presenter/presenter-config.js +94 -8
- package/dist/presenter/presenter-controls.d.ts +10 -10
- package/dist/presenter/presenter-controls.js +175 -85
- package/dist/presenter/presenter-dock-layout.d.ts +6 -0
- package/dist/presenter/presenter-dock-layout.js +170 -0
- package/dist/presenter/presenter-drag.d.ts +48 -0
- package/dist/presenter/presenter-drag.js +198 -0
- package/dist/presenter/presenter-effects.d.ts +1 -1
- package/dist/presenter/presenter-effects.js +1 -1
- package/dist/presenter/presenter-glow.d.ts +5 -5
- package/dist/presenter/presenter-glow.js +7 -7
- package/dist/presenter/presenter-heroicons-data.d.ts +41 -0
- package/dist/presenter/presenter-heroicons-data.js +38 -0
- package/dist/presenter/presenter-hud-chrome.d.ts +17 -0
- package/dist/presenter/presenter-hud-chrome.js +41 -0
- package/dist/presenter/presenter-icons.d.ts +44 -0
- package/dist/presenter/presenter-icons.js +100 -0
- package/dist/presenter/presenter-log.d.ts +27 -5
- package/dist/presenter/presenter-log.js +114 -28
- package/dist/presenter/presenter-report-copy.d.ts +83 -0
- package/dist/presenter/presenter-report-copy.js +127 -0
- package/dist/presenter/presenter-report-styles.d.ts +7 -0
- package/dist/presenter/presenter-report-styles.js +102 -0
- package/dist/presenter/presenter-report.d.ts +23 -0
- package/dist/presenter/presenter-report.js +217 -0
- package/dist/presenter/presenter-run-state.d.ts +1 -1
- package/dist/presenter/presenter-settings-styles.d.ts +2 -0
- package/dist/presenter/presenter-settings-styles.js +153 -0
- package/dist/presenter/presenter-settings.d.ts +82 -0
- package/dist/presenter/presenter-settings.js +494 -0
- package/dist/presenter/presenter-shell-styles.d.ts +1 -0
- package/dist/presenter/presenter-shell-styles.js +375 -0
- package/dist/presenter/presenter-shell.d.ts +48 -0
- package/dist/presenter/presenter-shell.js +408 -0
- package/dist/presenter/presenter-styles.d.ts +9 -4
- package/dist/presenter/presenter-styles.js +61 -125
- package/dist/presenter/presenter-tally.d.ts +8 -11
- package/dist/presenter/presenter-tally.js +48 -23
- package/dist/presenter/presenter-test-helpers.d.ts +1 -1
- package/dist/presenter/presenter-test-helpers.js +1 -1
- package/dist/presenter/presenter-verbs.js +2 -2
- package/dist/presenter/presenter-workspace.d.ts +15 -0
- package/dist/presenter/presenter-workspace.js +180 -0
- package/dist/presenter/presenter.d.ts +15 -10
- package/dist/presenter/presenter.js +194 -46
- package/dist/registry/emitter.d.ts +2 -2
- package/dist/reticle.d.ts +2 -2
- package/dist/reticle.js +27 -25
- package/dist/review/annotator-styles.d.ts +6 -0
- package/dist/review/annotator-styles.js +81 -0
- package/dist/review/annotator.d.ts +29 -17
- package/dist/review/annotator.js +477 -146
- package/dist/timers/clock.js +8 -5
- package/dist/transport/unreachable-message.d.ts +12 -0
- package/dist/transport/unreachable-message.js +20 -0
- package/dist/util/captured-method.d.ts +37 -0
- package/dist/util/captured-method.js +45 -0
- package/package.json +9 -6
package/dist/dom/dom-ignore.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Selectors for Reticle's own presenter overlay (cursor, HUD, glow) + the annotator's
|
|
3
|
-
* UI (`data-reticle-mark`)
|
|
4
|
-
* presenter, so omitting its selector here leaked
|
|
5
|
-
* snapshot and streamed its highlight-box mutations/transitions as if the APP had rendered them.
|
|
2
|
+
* Selectors for Reticle's own presenter overlay (cursor, HUD, glow) + the annotator's
|
|
3
|
+
* UI (`data-reticle-mark`) - never observed/snapshotted. The annotator mounts by DEFAULT with the
|
|
4
|
+
* presenter, so omitting its selector here leaked annotation chrome into every snapshot.
|
|
6
5
|
*/
|
|
7
|
-
export const RETICLE_OVERLAY = '[data-reticle-overlay],[data-reticle-cursor],[data-reticle-hud],[data-reticle-glow],[data-reticle-mark]';
|
|
6
|
+
export const RETICLE_OVERLAY = '[data-reticle-overlay],[data-reticle-cursor],[data-reticle-hud],[data-reticle-glow],[data-reticle-mark],[data-reticle-blocker]';
|
|
8
7
|
/** Known third-party dev overlays to keep out of snapshots (Agentation, Next dev UI). */
|
|
9
8
|
const DEV_OVERLAYS = '[data-agentation],#__next-build-watcher,nextjs-portal,[data-nextjs-dialog],[data-nextjs-toast]';
|
|
10
9
|
let extraIgnore = '';
|
|
@@ -16,15 +15,23 @@ export function setIgnoreSelectors(selectors) {
|
|
|
16
15
|
export function isReticleOverlay(el) {
|
|
17
16
|
return el.closest(RETICLE_OVERLAY) !== null;
|
|
18
17
|
}
|
|
19
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* True iff the element is part of Reticle's OWN UI - the presenter overlay, the HUD, the synthetic
|
|
20
|
+
* cursor, the glow, or the annotator's marks - or lives inside one of them.
|
|
21
|
+
*
|
|
22
|
+
* The rule used to be "any ancestor carries a data-reticle* attribute", which is wrong twice over.
|
|
23
|
+
* `data-reticle-mark-active` sits on <html> while annotate mode is live, so the whole document
|
|
24
|
+
* answered yes; and `data-reticle-source` is stamped by the Vite/Babel plugins on every element the
|
|
25
|
+
* APP renders, so in an instrumented app - which is the only kind there is - most of the page
|
|
26
|
+
* answered yes too. Those attributes describe page content; they do not make it ours.
|
|
27
|
+
*
|
|
28
|
+
* Two things read this and both failed silently. `pageElementAt` (annotator) skipped every stamped
|
|
29
|
+
* element and anchored the note to the outermost unstamped ancestor, i.e. the app shell instead of
|
|
30
|
+
* the control under the cursor. `occlusion.ts` reads a yes as "nothing to report", so occlusion
|
|
31
|
+
* detection - a bug class Reticle advertises catching - came back clean wherever the stamp reached.
|
|
32
|
+
*/
|
|
20
33
|
export function isReticleUi(node) {
|
|
21
|
-
|
|
22
|
-
for (const attr of Array.from(n.attributes)) {
|
|
23
|
-
if (attr.name.startsWith('data-reticle'))
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
return false;
|
|
34
|
+
return node !== null && node.closest(RETICLE_OVERLAY) !== null;
|
|
28
35
|
}
|
|
29
36
|
/** True if the element should be excluded from snapshots/queries (Reticle overlay or dev overlay). */
|
|
30
37
|
export function isIgnored(el) {
|
package/dist/dom/query.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export declare function matchQuery(query: ElementQuery, state?: ElementState, li
|
|
|
12
12
|
* Resolve a query to descriptors for the `query` MCP tool.
|
|
13
13
|
*
|
|
14
14
|
* `count` is carried through deliberately: the server reports match totals from it rather than from
|
|
15
|
-
* the array, because the array is capped in transit. Dropping it here
|
|
16
|
-
* do
|
|
15
|
+
* the array, because the array is capped in transit. Dropping it here - which this function used to
|
|
16
|
+
* do - silently put the server back to counting survivors and calling that the answer.
|
|
17
17
|
*/
|
|
18
18
|
export declare function runQuery(query: ElementQuery, limit?: number): QueryResult;
|
package/dist/dom/query.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { queryAllByRole, queryAllByText, queryAllByLabelText, queryAllByPlaceholderText, queryAllByTestId, queryAllByAltText, } from '@testing-library/dom';
|
|
1
|
+
import { queryAllByRole, queryAllByText, queryAllByLabelText, queryAllByPlaceholderText, queryAllByTestId, queryAllByAltText, getDefaultNormalizer, } from '@testing-library/dom';
|
|
2
2
|
import { DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
|
|
3
3
|
import { isFrame, isHtmlElement } from './realm.js';
|
|
4
4
|
import { capturedRootOf } from './shadow-registry.js';
|
|
5
|
-
import { getAccessibleName, getRole, describe, getStates, isVisible } from './a11y.js';
|
|
5
|
+
import { getAccessibleName, getRole, describe, getStates, isInViewport, isVisible, } from './a11y.js';
|
|
6
6
|
import { isIgnored } from './dom-ignore.js';
|
|
7
7
|
import { isSensitiveKey } from '../security/serialization.js';
|
|
8
8
|
import { getCapabilities } from '../registry/capabilities.js';
|
|
@@ -17,7 +17,7 @@ const MAX_COMPONENT_CANDIDATES = 2000;
|
|
|
17
17
|
const COMPONENT_CANDIDATE_SELECTOR = `[${SOURCE_ATTR}], [${TESTID_ATTR}], button, a, input, select, textarea, [role]`;
|
|
18
18
|
/**
|
|
19
19
|
* Resolve a scope to its container. A container of `null` means a scope was GIVEN but resolved to
|
|
20
|
-
* nothing (unmounted, or a selector that matches no element)
|
|
20
|
+
* nothing (unmounted, or a selector that matches no element) - the caller must NOT fall back to the
|
|
21
21
|
* whole page, or a scoped query silently widens into a phantom match. `scope === undefined` (no scope)
|
|
22
22
|
* legitimately searches the body.
|
|
23
23
|
*/
|
|
@@ -33,12 +33,12 @@ function resolveContainer(scope) {
|
|
|
33
33
|
return { container: found, scopeMissing: false };
|
|
34
34
|
}
|
|
35
35
|
catch {
|
|
36
|
-
// invalid selector
|
|
36
|
+
// invalid selector - treat as a missing scope, never a whole-page search
|
|
37
37
|
}
|
|
38
38
|
return { container: null, scopeMissing: true };
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
41
|
-
* Resolve an element by its SOURCE location
|
|
41
|
+
* Resolve an element by its SOURCE location - the precise, granular auto-anchor. The babel plugin
|
|
42
42
|
* stamps `data-reticle-source="file:line:column"` on host elements, so a line-level starts-with match
|
|
43
43
|
* pins the exact JSX element with a single fast attribute selector (no fiber walk). Column is
|
|
44
44
|
* ignored so a small column drift doesn't unbind the anchor.
|
|
@@ -55,7 +55,7 @@ function findBySource(container, source) {
|
|
|
55
55
|
/**
|
|
56
56
|
* Resolve by component display name when no source stamp is available: scan a bounded set of
|
|
57
57
|
* likely-actionable elements and keep those whose NEAREST enclosing component (via the registered
|
|
58
|
-
* framework adapter) matches. Coarser than source (one component renders many hosts)
|
|
58
|
+
* framework adapter) matches. Coarser than source (one component renders many hosts) - used as a
|
|
59
59
|
* fallback / for frameworks without a source plugin.
|
|
60
60
|
*/
|
|
61
61
|
function findByComponentName(container, component) {
|
|
@@ -84,7 +84,7 @@ function findByComponent(container, query) {
|
|
|
84
84
|
return [];
|
|
85
85
|
}
|
|
86
86
|
/**
|
|
87
|
-
* Role + name, matched the way Reticle REPORTS them
|
|
87
|
+
* Role + name, matched the way Reticle REPORTS them - not only the way Testing Library computes them.
|
|
88
88
|
*
|
|
89
89
|
* Two implementations were disagreeing, on both axes, for the same element:
|
|
90
90
|
*
|
|
@@ -94,7 +94,7 @@ function findByComponent(container, query) {
|
|
|
94
94
|
*
|
|
95
95
|
* `reticle_query`, `reticle_snapshot` and every act result report Reticle's values. The matcher used
|
|
96
96
|
* TL's. So Reticle printed `textbox "Search User"` and then could not find it by that exact
|
|
97
|
-
* role and name
|
|
97
|
+
* role and name - the same query call reporting an identity it cannot match.
|
|
98
98
|
*
|
|
99
99
|
* It surfaced through the flow recorder, which anchors a step to the reported role+name: `flow_save`
|
|
100
100
|
* graded the flow `asserted` with `degraded: 0`, a clean bill of health, and the step drifted on the
|
|
@@ -106,7 +106,7 @@ function findByComponent(container, query) {
|
|
|
106
106
|
* today keeps resolving exactly as before and this can never take a match away. Fixed on the
|
|
107
107
|
* matching side rather than by changing what is reported: dropping `placeholder` would leave an
|
|
108
108
|
* input whose only label is its placeholder nameless in every snapshot, and renaming the role would
|
|
109
|
-
* churn every stored anchor
|
|
109
|
+
* churn every stored anchor - both worse trades than a fallback that runs on the empty path.
|
|
110
110
|
*/
|
|
111
111
|
function queryByRoleAndName(container, role, name) {
|
|
112
112
|
if (name === undefined)
|
|
@@ -118,6 +118,35 @@ function queryByRoleAndName(container, role, name) {
|
|
|
118
118
|
// Reticle's own role AND name: the pair actually printed to the caller.
|
|
119
119
|
return [...container.querySelectorAll('*')].filter((el) => getRole(el) === role && getAccessibleName(el).trim() === wanted);
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* The same text, written two legal ways, did not match itself.
|
|
123
|
+
*
|
|
124
|
+
* Unicode lets `café` be one code point (`é`) or two (`e` + a combining acute). They render
|
|
125
|
+
* identically, they are canonically equivalent, and JavaScript string comparison says they differ —
|
|
126
|
+
* so a query typed one way against a DOM holding the other found nothing. That is a false RED: the
|
|
127
|
+
* agent asserts text that is visibly on screen, is told it is absent, and reports a working app as
|
|
128
|
+
* broken.
|
|
129
|
+
*
|
|
130
|
+
* It cannot happen in English, which is why it survived here. It is ordinary in French, Vietnamese
|
|
131
|
+
* and Korean, and the decomposed form is what macOS filesystems, several IMEs and some databases
|
|
132
|
+
* hand back.
|
|
133
|
+
*
|
|
134
|
+
* Normalising both sides can only ADD matches: normalisation is idempotent and
|
|
135
|
+
* canonical-equivalence-preserving, so two strings equal before are equal after and nothing that
|
|
136
|
+
* matched can stop matching. That property is why this is safe to apply to the matching core.
|
|
137
|
+
*
|
|
138
|
+
* Applied only to queries over USER-VISIBLE text. A testid is an attribute the developer typed on
|
|
139
|
+
* both sides and a component name is an identifier; normalising those would change what an exact
|
|
140
|
+
* match means, for no case anyone has.
|
|
141
|
+
*/
|
|
142
|
+
const toNfc = (value) => value.normalize('NFC');
|
|
143
|
+
/**
|
|
144
|
+
* Testing Library's own normaliser — whitespace trimming and collapsing — with NFC on top.
|
|
145
|
+
*
|
|
146
|
+
* Composed rather than replaced: writing a normaliser from scratch here would silently drop their
|
|
147
|
+
* whitespace handling, which every existing match depends on.
|
|
148
|
+
*/
|
|
149
|
+
const TEXT_NORMALIZER = { normalizer: (text) => toNfc(getDefaultNormalizer()(text)) };
|
|
121
150
|
/** Run the appropriate Testing-Library query against ONE root (light DOM or a shadow root). */
|
|
122
151
|
function findIn(container, query) {
|
|
123
152
|
const by = query.by;
|
|
@@ -128,62 +157,68 @@ function findIn(container, query) {
|
|
|
128
157
|
case QueryBy.ROLE:
|
|
129
158
|
return queryByRoleAndName(container, value, query.name);
|
|
130
159
|
case QueryBy.TEXT:
|
|
131
|
-
return queryAllByText(container, value, { exact: false });
|
|
160
|
+
return queryAllByText(container, toNfc(value), { exact: false, ...TEXT_NORMALIZER });
|
|
132
161
|
case QueryBy.LABEL:
|
|
133
|
-
return queryAllByLabelText(container, value, { exact: false });
|
|
162
|
+
return queryAllByLabelText(container, toNfc(value), { exact: false, ...TEXT_NORMALIZER });
|
|
134
163
|
case QueryBy.PLACEHOLDER:
|
|
135
|
-
return queryAllByPlaceholderText(container, value, {
|
|
164
|
+
return queryAllByPlaceholderText(container, toNfc(value), {
|
|
165
|
+
exact: false,
|
|
166
|
+
...TEXT_NORMALIZER,
|
|
167
|
+
});
|
|
136
168
|
case QueryBy.TESTID:
|
|
137
169
|
return queryAllByTestId(container, value, { exact: true });
|
|
138
170
|
case QueryBy.ALT:
|
|
139
|
-
return queryAllByAltText(container, value, { exact: false });
|
|
171
|
+
return queryAllByAltText(container, toNfc(value), { exact: false, ...TEXT_NORMALIZER });
|
|
140
172
|
case QueryBy.COMPONENT:
|
|
141
173
|
// value is the component name;.source (if present) still takes precedence inside.
|
|
142
174
|
return findByComponent(container, { ...query, component: query.component ?? value });
|
|
143
175
|
default:
|
|
144
176
|
// THROW, never `return []`. An unsupported strategy answering "no matches" is
|
|
145
|
-
// indistinguishable from the element genuinely being absent, so `by:'css'`
|
|
146
|
-
// anyone arriving from Playwright reaches for
|
|
177
|
+
// indistinguishable from the element genuinely being absent, so `by:'css'` - the first thing
|
|
178
|
+
// anyone arriving from Playwright reaches for - reported a page with a <body> as empty. The
|
|
147
179
|
// server now rejects unknown strategies at the schema; this is the same guarantee for every
|
|
148
180
|
// other path in (replay, reticle_run, an internal caller), because a false negative invented
|
|
149
181
|
// by the tool is the exact failure this product exists to prevent.
|
|
150
|
-
throw new Error(`unsupported query strategy '${String(by)}'
|
|
182
|
+
throw new Error(`unsupported query strategy '${String(by)}' - use one of: ${Object.values(QueryBy).join(', ')}`);
|
|
151
183
|
}
|
|
152
184
|
}
|
|
153
|
-
// Auto-anchor (component / source)
|
|
185
|
+
// Auto-anchor (component / source) - checked before the role/text fields so a query carrying
|
|
154
186
|
// both a component anchor and an incidental role resolves by the more durable anchor.
|
|
155
187
|
if (query.component !== undefined || query.source !== undefined) {
|
|
156
188
|
return findByComponent(container, query);
|
|
157
189
|
}
|
|
158
190
|
// Structured form (role+name, or any single field). Same round-trip guarantee as the by+value
|
|
159
|
-
// spelling above
|
|
191
|
+
// spelling above - the two forms must not disagree about what is findable.
|
|
160
192
|
if (query.role !== undefined) {
|
|
161
193
|
return queryByRoleAndName(container, query.role, query.name);
|
|
162
194
|
}
|
|
163
195
|
if (query.text !== undefined)
|
|
164
|
-
return queryAllByText(container, query.text, { exact: false });
|
|
196
|
+
return queryAllByText(container, toNfc(query.text), { exact: false, ...TEXT_NORMALIZER });
|
|
165
197
|
if (query.label !== undefined) {
|
|
166
|
-
return queryAllByLabelText(container, query.label, { exact: false });
|
|
198
|
+
return queryAllByLabelText(container, toNfc(query.label), { exact: false, ...TEXT_NORMALIZER });
|
|
167
199
|
}
|
|
168
200
|
if (query.placeholder !== undefined) {
|
|
169
|
-
return queryAllByPlaceholderText(container, query.placeholder, {
|
|
201
|
+
return queryAllByPlaceholderText(container, toNfc(query.placeholder), {
|
|
202
|
+
exact: false,
|
|
203
|
+
...TEXT_NORMALIZER,
|
|
204
|
+
});
|
|
170
205
|
}
|
|
171
206
|
if (query.testid !== undefined)
|
|
172
207
|
return queryAllByTestId(container, query.testid, { exact: true });
|
|
173
208
|
if (query.alt !== undefined)
|
|
174
|
-
return queryAllByAltText(container, query.alt, { exact: false });
|
|
209
|
+
return queryAllByAltText(container, toNfc(query.alt), { exact: false, ...TEXT_NORMALIZER });
|
|
175
210
|
return [];
|
|
176
211
|
}
|
|
177
212
|
/**
|
|
178
213
|
* Every open shadow root at or beneath `root`, in document order.
|
|
179
214
|
*
|
|
180
|
-
* A closed root is deliberately unreachable
|
|
215
|
+
* A closed root is deliberately unreachable - `element.shadowRoot` is null by design, and the SDK
|
|
181
216
|
* reports that as a blind spot rather than pretending to see through it.
|
|
182
217
|
*/
|
|
183
218
|
/**
|
|
184
219
|
* A same-origin `<iframe>`'s body, or null when the document is unreachable.
|
|
185
220
|
*
|
|
186
|
-
* Cross-origin access THROWS in some engines and yields null in others, so both are handled
|
|
221
|
+
* Cross-origin access THROWS in some engines and yields null in others, so both are handled - and
|
|
187
222
|
* both mean the same thing here: not ours to read. That case is not a silent skip, it is reported
|
|
188
223
|
* separately as a declared blind spot.
|
|
189
224
|
*/
|
|
@@ -202,7 +237,7 @@ const FRAME_DEPTH_MAX = 3;
|
|
|
202
237
|
* open shadow roots, and the documents of SAME-ORIGIN iframes.
|
|
203
238
|
*
|
|
204
239
|
* The iframe half was measured missing: a same-origin frame whose text the page could read straight
|
|
205
|
-
* off `contentDocument` returned zero matches from `query`, and coverage said nothing about it
|
|
240
|
+
* off `contentDocument` returned zero matches from `query`, and coverage said nothing about it - the
|
|
206
241
|
* worst combination, an absent-element answer for content that is right there. Cross-origin frames
|
|
207
242
|
* are a genuinely different case and stay unread; they are declared instead.
|
|
208
243
|
*/
|
|
@@ -224,7 +259,7 @@ function embeddedRootsUnder(root) {
|
|
|
224
259
|
continue;
|
|
225
260
|
const body = readableFrameBody(el);
|
|
226
261
|
if (null === body)
|
|
227
|
-
continue; // cross-origin
|
|
262
|
+
continue; // cross-origin - declared, not searched
|
|
228
263
|
found.push(body);
|
|
229
264
|
walk(body, depth + 1);
|
|
230
265
|
}
|
|
@@ -234,13 +269,13 @@ function embeddedRootsUnder(root) {
|
|
|
234
269
|
}
|
|
235
270
|
/**
|
|
236
271
|
* NOTE ON COST. The walk above is a `querySelectorAll('*')` over the container, and `reticle_query` is
|
|
237
|
-
* a hot-path tool, so every query on every app pays it
|
|
272
|
+
* a hot-path tool, so every query on every app pays it - including the overwhelming majority that hold
|
|
238
273
|
* no web components.
|
|
239
274
|
*
|
|
240
275
|
* A cache invalidated by a MutationObserver was written and then REMOVED, because it is not sound:
|
|
241
276
|
* `attachShadow` on an element that is already in the document mutates only the shadow tree, which an
|
|
242
277
|
* observer watching documentElement's subtree never sees. The cached "no shadow roots here" would
|
|
243
|
-
* persist and the content would be silently missed
|
|
278
|
+
* persist and the content would be silently missed - a false negative indistinguishable from a genuinely
|
|
244
279
|
* absent element, which is the exact bug the piercing was added to fix.
|
|
245
280
|
*
|
|
246
281
|
* Correctness wins until there is a measurement saying the walk actually matters. The allocation is
|
|
@@ -250,18 +285,18 @@ function embeddedRootsUnder(root) {
|
|
|
250
285
|
* Run the query against the light DOM AND every open shadow root beneath the scope.
|
|
251
286
|
*
|
|
252
287
|
* Testing Library only walks the light DOM, so a control inside a web component returned zero matches
|
|
253
|
-
* on a completely healthy page
|
|
288
|
+
* on a completely healthy page - a miss indistinguishable from a genuinely absent element. The
|
|
254
289
|
* snapshot has always pierced open roots; this makes `query` agree with it.
|
|
255
290
|
*/
|
|
256
291
|
function findCandidates(query) {
|
|
257
292
|
const { container, scopeMissing } = resolveContainer(query.scope);
|
|
258
|
-
// A given-but-missing scope searches NOTHING
|
|
293
|
+
// A given-but-missing scope searches NOTHING - never the whole page. The empty result plus the
|
|
259
294
|
// scopeMissing flag is what keeps "gone scope" distinct from "absent element".
|
|
260
295
|
if (null === container)
|
|
261
296
|
return { candidates: [], scopeMissing: true };
|
|
262
297
|
// `self: true` asks for the container ITSELF, which every other path excludes by construction.
|
|
263
298
|
// A layout element with no role, name, testid or own text is unreachable by any semantic locator,
|
|
264
|
-
// and it is routinely the element carrying the handler
|
|
299
|
+
// and it is routinely the element carrying the handler - "click the empty space in this row" is a
|
|
265
300
|
// real user action that was simply not expressible. Requires a scope: without one there is no root
|
|
266
301
|
// to return, and answering `document.body` would be a wrong answer wearing the shape of one.
|
|
267
302
|
if (true === query.self) {
|
|
@@ -274,11 +309,11 @@ function findCandidates(query) {
|
|
|
274
309
|
const collect = (els) => {
|
|
275
310
|
for (const el of els) {
|
|
276
311
|
if (seen.has(el))
|
|
277
|
-
continue; // reachable from both host and root
|
|
312
|
+
continue; // reachable from both host and root - count once
|
|
278
313
|
// Reticle's OWN UI is not part of the app under test. `snapshot` has always excluded it; query
|
|
279
|
-
// did not, so the presenter panel and the annotator answered `by: role` like app controls
|
|
314
|
+
// did not, so the presenter panel and the annotator answered `by: role` like app controls -
|
|
280
315
|
// measured on a real merchant dashboard, 7 of the 40 buttons an agent could see were ours
|
|
281
|
-
// ("Pause", "End", "
|
|
316
|
+
// ("Pause", "End", "Chat", "Export"). Two of those
|
|
282
317
|
// share a NAME with a real control on that page, so an agent resolving "Export" could drive the
|
|
283
318
|
// observer instead of the app and then reason about the result. Filtered here, at the one
|
|
284
319
|
// collection point, so every entry path (by role/text/testid, shadow roots, scoped) is covered.
|
|
@@ -295,14 +330,14 @@ function findCandidates(query) {
|
|
|
295
330
|
}
|
|
296
331
|
/** Longest attribute value returned; one enormous href must not blow the response budget. */
|
|
297
332
|
export const ATTR_VALUE_MAX = 512;
|
|
298
|
-
/** Most attributes projected per element
|
|
333
|
+
/** Most attributes projected per element - a guard against a caller asking for everything. */
|
|
299
334
|
const ATTR_KEYS_MAX = 12;
|
|
300
335
|
/**
|
|
301
336
|
* Read the requested attributes off an element.
|
|
302
337
|
*
|
|
303
338
|
* Absent attributes are OMITTED rather than returned empty, so "not present" and "present but blank"
|
|
304
339
|
* stay distinguishable. Credential-bearing names are redacted with the same rule the network and
|
|
305
|
-
* storage paths use
|
|
340
|
+
* storage paths use - a projection API must not become an exfiltration path.
|
|
306
341
|
*/
|
|
307
342
|
function projectAttrs(el, keys) {
|
|
308
343
|
const out = {};
|
|
@@ -315,6 +350,11 @@ function projectAttrs(el, keys) {
|
|
|
315
350
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
316
351
|
}
|
|
317
352
|
function inState(el, state, memo) {
|
|
353
|
+
// inViewport is computed on demand rather than listed in getStates, so it stays assertable without
|
|
354
|
+
// adding a state to every element in every snapshot (it would bloat the wire and churn every
|
|
355
|
+
// describe). The predicate path is the only consumer that needs it. (#398)
|
|
356
|
+
if (ElementState.IN_VIEWPORT === state)
|
|
357
|
+
return isInViewport(el, memo);
|
|
318
358
|
return getStates(el, isVisible(el, memo)).includes(state);
|
|
319
359
|
}
|
|
320
360
|
/**
|
|
@@ -322,7 +362,7 @@ function inState(el, state, memo) {
|
|
|
322
362
|
*
|
|
323
363
|
* describe() is the expensive part of a query: it resolves the accessible name and forces a style
|
|
324
364
|
* computation for every ancestor to decide visibility. Describing every match on a page with
|
|
325
|
-
* thousands of them freezes the main thread for seconds
|
|
365
|
+
* thousands of them freezes the main thread for seconds - and then the wire discards all but the
|
|
326
366
|
* first `MAX_COLLECTION_ITEMS` anyway, so the work past that point was never observable by anyone.
|
|
327
367
|
* Matching one to the other means the cost of a broad query is bounded by the transport rather than
|
|
328
368
|
* by the size of the page.
|
|
@@ -337,15 +377,15 @@ const MAX_DESCRIBED = TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS;
|
|
|
337
377
|
export function matchQuery(query, state, limit = MAX_DESCRIBED) {
|
|
338
378
|
// NO blanket try/catch here. It used to turn ANY exception during candidate-finding into
|
|
339
379
|
// `elements = []`, which is the same lie as the `default` arm above: a query that could not run
|
|
340
|
-
// reported that the element is not on the page. The one failure it was plausibly guarding
|
|
341
|
-
// a scope selector that matches nothing
|
|
380
|
+
// reported that the element is not on the page. The one failure it was plausibly guarding -
|
|
381
|
+
// a scope selector that matches nothing - is already handled explicitly and distinctly, as
|
|
342
382
|
// `scopeMissing`, so what remained was a net that could only convert real faults into false
|
|
343
383
|
// negatives.
|
|
344
384
|
const found = findCandidates(query);
|
|
345
385
|
const elements = found.candidates;
|
|
346
386
|
const scopeMissing = found.scopeMissing;
|
|
347
387
|
// One visibility cache for the whole (synchronous) query pass. isVisible is an O(depth) forced-style
|
|
348
|
-
// walk; the state filter runs it over EVERY candidate (the count must be exact)
|
|
388
|
+
// walk; the state filter runs it over EVERY candidate (the count must be exact) - on a match-heavy
|
|
349
389
|
// page (e.g. a 3k-row grid) that is tens of thousands of getComputedStyle calls on the host's main
|
|
350
390
|
// thread. The memo makes each element's ancestors resolve once, then short-circuit for every sibling.
|
|
351
391
|
const visMemo = new Map();
|
|
@@ -371,7 +411,7 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED) {
|
|
|
371
411
|
...(0 === filtered.length ? { hint: buildEmptyHint(query) } : {}),
|
|
372
412
|
};
|
|
373
413
|
}
|
|
374
|
-
/** Resolve `aria-labelledby` (one or more element IDs) to the referenced elements' text
|
|
414
|
+
/** Resolve `aria-labelledby` (one or more element IDs) to the referenced elements' text - a bare ID is
|
|
375
415
|
* not a human-readable name. Undefined when unset or nothing resolves. */
|
|
376
416
|
function resolveLabelledBy(el) {
|
|
377
417
|
const ids = el.getAttribute('aria-labelledby');
|
|
@@ -384,7 +424,7 @@ function resolveLabelledBy(el) {
|
|
|
384
424
|
.join(' ');
|
|
385
425
|
return text.length > 0 ? text : undefined;
|
|
386
426
|
}
|
|
387
|
-
/** Structural clusters of the page
|
|
427
|
+
/** Structural clusters of the page - the successor to the raw testid list in zero-match hints. */
|
|
388
428
|
function buildPresentRegions(query) {
|
|
389
429
|
// For the diagnostic hint we WANT the page's orientation even when the scope is gone, so fall back
|
|
390
430
|
// to the body here (the match path above already reported scopeMissing, so this can't mask it).
|
|
@@ -418,7 +458,7 @@ function buildPresentRegions(query) {
|
|
|
418
458
|
}
|
|
419
459
|
for (const el of containers) {
|
|
420
460
|
const name = el.getAttribute('aria-label') ??
|
|
421
|
-
resolveLabelledBy(el) ?? // aria-labelledby is an element ID
|
|
461
|
+
resolveLabelledBy(el) ?? // aria-labelledby is an element ID - resolve it to the referenced TEXT
|
|
422
462
|
el.getAttribute('data-testid') ??
|
|
423
463
|
undefined;
|
|
424
464
|
const children = el.querySelectorAll('[role]');
|
|
@@ -474,8 +514,8 @@ function buildEmptyHint(query) {
|
|
|
474
514
|
* Resolve a query to descriptors for the `query` MCP tool.
|
|
475
515
|
*
|
|
476
516
|
* `count` is carried through deliberately: the server reports match totals from it rather than from
|
|
477
|
-
* the array, because the array is capped in transit. Dropping it here
|
|
478
|
-
* do
|
|
517
|
+
* the array, because the array is capped in transit. Dropping it here - which this function used to
|
|
518
|
+
* do - silently put the server back to counting survivors and calling that the answer.
|
|
479
519
|
*/
|
|
480
520
|
export function runQuery(query, limit) {
|
|
481
521
|
const result = matchQuery(query, undefined, limit);
|
package/dist/dom/refs.d.ts
CHANGED
|
@@ -31,8 +31,36 @@ export declare const MAX_TRACKED_REFS = 10000;
|
|
|
31
31
|
* the message.
|
|
32
32
|
*/
|
|
33
33
|
export declare function echoRef(ref: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* How many numbers a document claims at once.
|
|
36
|
+
*
|
|
37
|
+
* The reservation is a synchronous storage write, so it cannot happen per mint — mints are frequent
|
|
38
|
+
* (every meaningful DOM addition, every transitionend, every scroll reveal). One write per this many
|
|
39
|
+
* mints is free.
|
|
40
|
+
*
|
|
41
|
+
* Sized to keep refs SHORT, and the first attempt at that reasoned about the wrong axis. It bounded
|
|
42
|
+
* how many navigations it took to reach seven digits and concluded "a hundred" — but the cost is not
|
|
43
|
+
* paid at seven digits, it is paid at every digit, on every ref, in every snapshot and every error
|
|
44
|
+
* message, on every turn. At 10,000 the SECOND document already mints five-digit refs.
|
|
45
|
+
*
|
|
46
|
+
* Measured on one unchanged three-element page across a session: 61 tokens on the first document,
|
|
47
|
+
* 64 after a reload, 65 later — +6.5% for the same page, entirely in ref width. The benchmark saw the
|
|
48
|
+
* same thing as a ~4% rise in average observation cost, which is what a benchmark is for.
|
|
49
|
+
*
|
|
50
|
+
* So the block is small. A document claims another one when it outmints this, which costs one more
|
|
51
|
+
* storage write and is exactly proportional: a page with thousands of elements has a long snapshot
|
|
52
|
+
* anyway, and a small page — the common case, and the one an agent drives repeatedly — keeps
|
|
53
|
+
* three-digit refs across dozens of navigations.
|
|
54
|
+
*/
|
|
55
|
+
export declare const REF_BLOCK = 100;
|
|
34
56
|
export declare class RefRegistry {
|
|
35
57
|
#private;
|
|
58
|
+
/**
|
|
59
|
+
* Storage is injected rather than read from the global for the same reason the clock is: it is an
|
|
60
|
+
* ambient dependency that decides behaviour, and the cross-document property is untestable if the
|
|
61
|
+
* only way to get a second document is to actually navigate.
|
|
62
|
+
*/
|
|
63
|
+
constructor(storage?: () => Storage | undefined);
|
|
36
64
|
/** How many reverse entries are currently retained. Exposed so the bound can be asserted. */
|
|
37
65
|
get size(): number;
|
|
38
66
|
/** Get the existing ref for an element, or mint a new one. */
|
package/dist/dom/refs.js
CHANGED
|
@@ -44,11 +44,96 @@ export function echoRef(ref) {
|
|
|
44
44
|
* over-cap mint, and the expensive dead-entry sweep runs once per this many mints (amortized O(1)).
|
|
45
45
|
*/
|
|
46
46
|
const SWEEP_EVERY_MINTS = 1000;
|
|
47
|
+
/**
|
|
48
|
+
* Where a document records the ref numbers it has claimed, so the next one starts past them.
|
|
49
|
+
*
|
|
50
|
+
* Refs are per-DOCUMENT: a reload or a cross-page link tears this module down and the next document
|
|
51
|
+
* used to start minting from `e1` again. So `e7` from page A was a valid, resolvable, DIFFERENT
|
|
52
|
+
* element on page B — nothing refused, the wrong element was acted on, and the result came back `ok`.
|
|
53
|
+
* The SPA case was always safe (WeakRef liveness + isConnected); this was the other half, and the
|
|
54
|
+
* more dangerous one, because it produced a false green instead of an error.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately NOT a change to the ref grammar. Stamping refs `e7@3` would have worked too, but every
|
|
57
|
+
* agent passes refs back verbatim and every saved flow on disk already holds bare ones, so it buys a
|
|
58
|
+
* wire-format migration for a property the sequence itself can carry. A number that never restarts
|
|
59
|
+
* means a stale ref simply misses the map, and the refusal that already exists — already worded for
|
|
60
|
+
* exactly this case — fires unchanged.
|
|
61
|
+
*
|
|
62
|
+
* Namespaced like the session key it sits beside; `sessionStorage` is the right scope for the same
|
|
63
|
+
* reason session continuity uses it (survives reloads and same-tab navigations, not shared with
|
|
64
|
+
* another tab, which is a different session with its own registry anyway).
|
|
65
|
+
*/
|
|
66
|
+
const REF_BASE_KEY = '__reticle_ref_base';
|
|
67
|
+
/**
|
|
68
|
+
* How many numbers a document claims at once.
|
|
69
|
+
*
|
|
70
|
+
* The reservation is a synchronous storage write, so it cannot happen per mint — mints are frequent
|
|
71
|
+
* (every meaningful DOM addition, every transitionend, every scroll reveal). One write per this many
|
|
72
|
+
* mints is free.
|
|
73
|
+
*
|
|
74
|
+
* Sized to keep refs SHORT, and the first attempt at that reasoned about the wrong axis. It bounded
|
|
75
|
+
* how many navigations it took to reach seven digits and concluded "a hundred" — but the cost is not
|
|
76
|
+
* paid at seven digits, it is paid at every digit, on every ref, in every snapshot and every error
|
|
77
|
+
* message, on every turn. At 10,000 the SECOND document already mints five-digit refs.
|
|
78
|
+
*
|
|
79
|
+
* Measured on one unchanged three-element page across a session: 61 tokens on the first document,
|
|
80
|
+
* 64 after a reload, 65 later — +6.5% for the same page, entirely in ref width. The benchmark saw the
|
|
81
|
+
* same thing as a ~4% rise in average observation cost, which is what a benchmark is for.
|
|
82
|
+
*
|
|
83
|
+
* So the block is small. A document claims another one when it outmints this, which costs one more
|
|
84
|
+
* storage write and is exactly proportional: a page with thousands of elements has a long snapshot
|
|
85
|
+
* anyway, and a small page — the common case, and the one an agent drives repeatedly — keeps
|
|
86
|
+
* three-digit refs across dozens of navigations.
|
|
87
|
+
*/
|
|
88
|
+
export const REF_BLOCK = 100;
|
|
89
|
+
/** The reserved-block high-water mark, or 0 when storage is unavailable or its value is junk. */
|
|
90
|
+
function readBase(store) {
|
|
91
|
+
try {
|
|
92
|
+
const raw = store?.getItem(REF_BASE_KEY);
|
|
93
|
+
const value = null === raw || undefined === raw ? Number.NaN : Number(raw);
|
|
94
|
+
return Number.isSafeInteger(value) && value > 0 ? value : 0;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return 0; // a sandboxed iframe throws on access; a session must still start
|
|
98
|
+
}
|
|
99
|
+
}
|
|
47
100
|
export class RefRegistry {
|
|
48
101
|
#toRef = new WeakMap();
|
|
49
102
|
#fromRef = new Map();
|
|
103
|
+
#storage;
|
|
50
104
|
#seq = 0;
|
|
105
|
+
/** The end of the block currently reserved. Minting past it claims the next one. */
|
|
106
|
+
#reserved = 0;
|
|
51
107
|
#mintsSinceSweep = 0;
|
|
108
|
+
/**
|
|
109
|
+
* Storage is injected rather than read from the global for the same reason the clock is: it is an
|
|
110
|
+
* ambient dependency that decides behaviour, and the cross-document property is untestable if the
|
|
111
|
+
* only way to get a second document is to actually navigate.
|
|
112
|
+
*/
|
|
113
|
+
constructor(storage = () => globalThis.sessionStorage) {
|
|
114
|
+
this.#storage = storage;
|
|
115
|
+
}
|
|
116
|
+
/** Read the tab's high-water mark and claim the next block, BEFORE any of it is handed out. */
|
|
117
|
+
#reserve() {
|
|
118
|
+
let store;
|
|
119
|
+
try {
|
|
120
|
+
store = this.#storage();
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
store = undefined; // hostile storage: no cross-document guarantee, but the session still starts
|
|
124
|
+
}
|
|
125
|
+
const base = Math.max(readBase(store), this.#seq);
|
|
126
|
+
this.#reserved = base + REF_BLOCK;
|
|
127
|
+
try {
|
|
128
|
+
store?.setItem(REF_BASE_KEY, String(this.#reserved));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* same: never block a mint on storage */
|
|
132
|
+
}
|
|
133
|
+
// Claimed on the FIRST mint, not at unload — a page that crashes, is killed, or navigates away
|
|
134
|
+
// without firing unload has still permanently spent its numbers.
|
|
135
|
+
this.#seq = base;
|
|
136
|
+
}
|
|
52
137
|
/** How many reverse entries are currently retained. Exposed so the bound can be asserted. */
|
|
53
138
|
get size() {
|
|
54
139
|
return this.#fromRef.size;
|
|
@@ -85,6 +170,8 @@ export class RefRegistry {
|
|
|
85
170
|
this.#fromRef.set(existing, weak ?? new WeakRef(el));
|
|
86
171
|
return existing;
|
|
87
172
|
}
|
|
173
|
+
if (this.#seq >= this.#reserved)
|
|
174
|
+
this.#reserve();
|
|
88
175
|
this.#seq += 1;
|
|
89
176
|
const ref = asRef(`e${String(this.#seq)}`);
|
|
90
177
|
this.#toRef.set(el, ref);
|
package/dist/dom/snapshot.d.ts
CHANGED
|
@@ -4,6 +4,11 @@ interface SnapshotStatus {
|
|
|
4
4
|
title: string;
|
|
5
5
|
/** Open dialogs, OMITTED when there are none — absence means "no dialog is up". */
|
|
6
6
|
visibleDialogs?: string[];
|
|
7
|
+
/**
|
|
8
|
+
* Present ONLY when a whole-page snapshot came back near-empty because an open overlay
|
|
9
|
+
* aria-hidden the rest of the page (#397). Absence means the page is not hidden behind an overlay.
|
|
10
|
+
*/
|
|
11
|
+
overlayHidingPage?: string;
|
|
7
12
|
}
|
|
8
13
|
export interface SnapshotResult {
|
|
9
14
|
tree: string;
|
package/dist/dom/snapshot.js
CHANGED
|
@@ -194,12 +194,46 @@ function collectDialogs(root) {
|
|
|
194
194
|
* case, so the field was almost pure repetition; a reader learns nothing from its presence and
|
|
195
195
|
* everything from it.
|
|
196
196
|
*/
|
|
197
|
+
/**
|
|
198
|
+
* A focus-trap modal (Radix and friends) marks every sibling of the portal root aria-hidden. The
|
|
199
|
+
* snapshot walk correctly skips aria-hidden subtrees, so a whole-page snapshot can return only the
|
|
200
|
+
* overlay's own nodes and nothing else -- indistinguishable from "this page is empty". If the
|
|
201
|
+
* overlay's cleanup is stuck (common on a throttled tab), every later action then dispatches with no
|
|
202
|
+
* effect because the overlay genuinely captures pointer events. Say so instead of returning a
|
|
203
|
+
* near-empty tree with no explanation. (#397)
|
|
204
|
+
*/
|
|
205
|
+
function overlayHidingPage(root) {
|
|
206
|
+
// Only meaningful for a whole-page snapshot; a scoped snapshot is the subtree the caller chose.
|
|
207
|
+
if (root !== document.body)
|
|
208
|
+
return undefined;
|
|
209
|
+
const dialogs = document.body.querySelectorAll('[role="dialog"], dialog[open], [aria-modal="true"]');
|
|
210
|
+
let modal;
|
|
211
|
+
for (const d of dialogs) {
|
|
212
|
+
if (isVisible(d)) {
|
|
213
|
+
modal = d;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (modal === undefined)
|
|
218
|
+
return undefined;
|
|
219
|
+
const modalEl = modal;
|
|
220
|
+
const outside = Array.from(document.body.children).filter((c) => !c.contains(modalEl));
|
|
221
|
+
if (0 === outside.length)
|
|
222
|
+
return undefined;
|
|
223
|
+
if (!outside.every((c) => 'true' === c.getAttribute('aria-hidden')))
|
|
224
|
+
return undefined;
|
|
225
|
+
return ('the rest of the page is aria-hidden behind an open overlay (a focus-trap modal), so this ' +
|
|
226
|
+
'snapshot shows only the overlay. If it will not close its cleanup may be stuck on a throttled ' +
|
|
227
|
+
'tab: dismiss the overlay, or snapshot with { scope } inside it.');
|
|
228
|
+
}
|
|
197
229
|
function buildStatus(root) {
|
|
198
230
|
const visibleDialogs = collectDialogs(root);
|
|
231
|
+
const overlay = overlayHidingPage(root);
|
|
199
232
|
return {
|
|
200
233
|
route: `${location.pathname}${location.search}${location.hash}`,
|
|
201
234
|
title: document.title,
|
|
202
235
|
...(visibleDialogs.length > 0 ? { visibleDialogs } : {}),
|
|
236
|
+
...(overlay !== undefined ? { overlayHidingPage: overlay } : {}),
|
|
203
237
|
};
|
|
204
238
|
}
|
|
205
239
|
/** Build the semantic accessibility snapshot of the page or a subtree. */
|
package/dist/dom/source.d.ts
CHANGED
|
@@ -29,6 +29,20 @@ export declare function parseSourceAttr(value: string | null): SourceLocation |
|
|
|
29
29
|
export declare function sourceFromDom(el: Element): SourceLocation | undefined;
|
|
30
30
|
/** Best source for the element: the framework adapter's, else the nearest babel-stamped attribute. */
|
|
31
31
|
export declare function sourceFor(el: Element, adapterSource: SourceLocation | undefined): SourceLocation | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Does ANY element in this document carry a source stamp?
|
|
34
|
+
*
|
|
35
|
+
* The difference between "this element has no stamp" and "the stamping loader is not running at
|
|
36
|
+
* all" — two answers that look identical from a single missing `source` field, and that call for
|
|
37
|
+
* completely different next actions. The first is ordinary: not every element sits under a stamped
|
|
38
|
+
* host. The second means "fix at file:line", the single most-cited reason to reach for Reticle over
|
|
39
|
+
* a generic DOM tool, is silently unavailable for the whole session — and it has been discovered by
|
|
40
|
+
* reading the adapter's source rather than from anything Reticle said.
|
|
41
|
+
*
|
|
42
|
+
* One `querySelector`, which stops at the first hit, so this is cheap enough to answer on a
|
|
43
|
+
* single-element path like inspect. It is only consulted when a source is already missing.
|
|
44
|
+
*/
|
|
45
|
+
export declare function documentHasSourceStamps(doc: Document): boolean;
|
|
32
46
|
/**
|
|
33
47
|
* `file:line` — the form an agent can act on directly.
|
|
34
48
|
*
|