@reticlehq/browser 2.11.0 → 2.13.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/dist/actions/actions-dom.js +41 -3
- package/dist/actions/actions.js +43 -11
- package/dist/commands/commands.js +6 -2
- package/dist/connect-options.d.ts +18 -1
- package/dist/dom/a11y.js +92 -9
- package/dist/dom/dom-ignore.d.ts +9 -0
- package/dist/dom/dom-ignore.js +32 -3
- package/dist/dom/query.js +168 -97
- package/dist/dom/refs.d.ts +11 -0
- package/dist/dom/refs.js +30 -1
- package/dist/dom/snapshot.d.ts +37 -0
- package/dist/dom/snapshot.js +141 -37
- package/dist/edit-epoch.d.ts +32 -0
- package/dist/edit-epoch.js +108 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -1
- package/dist/observers/animation.js +6 -8
- package/dist/observers/context-open.d.ts +12 -0
- package/dist/observers/context-open.js +25 -0
- package/dist/observers/focus.js +5 -6
- package/dist/observers/health.js +6 -9
- package/dist/observers/install-all.js +2 -0
- package/dist/observers/network-redact.d.ts +13 -0
- package/dist/observers/network-redact.js +12 -1
- package/dist/observers/network.d.ts +2 -2
- package/dist/observers/network.js +24 -17
- package/dist/observers/scroll.js +4 -2
- package/dist/observers/storage.js +1 -1
- package/dist/presenter/presenter-brand.d.ts +4 -18
- package/dist/presenter/presenter-brand.js +6 -18
- package/dist/presenter/presenter-config.d.ts +11 -8
- package/dist/presenter/presenter-config.js +9 -3
- package/dist/presenter/presenter-controls.d.ts +0 -3
- package/dist/presenter/presenter-controls.js +0 -3
- package/dist/presenter/presenter-hud-chrome.d.ts +1 -1
- package/dist/presenter/presenter-report-copy.d.ts +30 -1
- package/dist/presenter/presenter-report-copy.js +31 -2
- package/dist/presenter/presenter-report-styles.js +24 -0
- package/dist/presenter/presenter-report.d.ts +2 -2
- package/dist/presenter/presenter-report.js +95 -3
- package/dist/presenter/presenter-settings.js +10 -2
- package/dist/presenter/presenter-shell-styles.js +20 -1
- package/dist/presenter/presenter-shell.d.ts +0 -2
- package/dist/presenter/presenter-shell.js +1 -6
- package/dist/presenter/presenter.d.ts +13 -0
- package/dist/presenter/presenter.js +37 -2
- package/dist/registry/auto-testids.d.ts +21 -0
- package/dist/registry/auto-testids.js +48 -0
- package/dist/registry/capabilities.d.ts +28 -1
- package/dist/registry/capabilities.js +59 -7
- package/dist/registry/store-adapters.d.ts +1 -1
- package/dist/registry/store-adapters.js +6 -1
- package/dist/registry/stores.d.ts +4 -0
- package/dist/registry/stores.js +35 -0
- package/dist/reticle.d.ts +12 -0
- package/dist/reticle.js +40 -1
- package/dist/transport/unreachable-message.d.ts +7 -0
- package/dist/transport/unreachable-message.js +11 -1
- package/package.json +4 -7
- package/dist/dom/blind-spots.d.ts +0 -13
- package/dist/dom/blind-spots.js +0 -29
- package/dist/observers/subresources.d.ts +0 -10
- package/dist/observers/subresources.js +0 -63
package/dist/dom/query.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { queryAllByRole, queryAllByText, queryAllByLabelText, queryAllByPlaceholderText, queryAllByTestId, queryAllByAltText, getDefaultNormalizer, } from '@testing-library/dom';
|
|
2
1
|
import { DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
|
|
3
|
-
import { isFrame, isHtmlElement } from './realm.js';
|
|
2
|
+
import { isFrame, isHtmlElement, isInput, isSelect, isTextArea } from './realm.js';
|
|
4
3
|
import { capturedRootOf } from './shadow-registry.js';
|
|
5
4
|
import { getAccessibleName, getRole, describe, getStates, isInViewport, isVisible, } from './a11y.js';
|
|
6
5
|
import { isIgnored } from './dom-ignore.js';
|
|
7
6
|
import { isSensitiveKey } from '../security/serialization.js';
|
|
8
|
-
import {
|
|
7
|
+
import { declaredTestids } from '../registry/capabilities.js';
|
|
9
8
|
import { identifyComponent } from '../registry/adapters.js';
|
|
10
9
|
import { refs } from './refs.js';
|
|
11
10
|
const TESTID_ATTR = 'data-testid';
|
|
@@ -15,6 +14,72 @@ const MAX_PRESENT_TESTIDS = 12;
|
|
|
15
14
|
const MAX_COMPONENT_CANDIDATES = 2000;
|
|
16
15
|
/** Likely-actionable elements considered when resolving a component anchor without a source stamp. */
|
|
17
16
|
const COMPONENT_CANDIDATE_SELECTOR = `[${SOURCE_ATTR}], [${TESTID_ATTR}], button, a, input, select, textarea, [role]`;
|
|
17
|
+
/**
|
|
18
|
+
* Every candidate a semantic locator may match: the container ITSELF first, then its descendants.
|
|
19
|
+
*
|
|
20
|
+
* The container's own text, role or name was matched by every engine this replaces - a scoped
|
|
21
|
+
* `{ text: "Saved" }` against `<div id="status">Saved</div>` found #status itself - so dropping
|
|
22
|
+
* the root here would turn exactly those scoped queries into silent zero-match answers: the worst
|
|
23
|
+
* failure mode, indistinguishable from the element being absent. `self: true` stays the way to
|
|
24
|
+
* reach an UNLABELLED root (it skips the predicate entirely); this keeps a root that DOES satisfy
|
|
25
|
+
* the predicate findable without a second spelling.
|
|
26
|
+
*/
|
|
27
|
+
function elementsUnder(container) {
|
|
28
|
+
// Embedded roots include ShadowRoots, which are DocumentFragments: they have no attributes or
|
|
29
|
+
// tag to match on, so only a true Element root joins the candidates.
|
|
30
|
+
const self = Node.ELEMENT_NODE === container.nodeType ? [container] : [];
|
|
31
|
+
return [...self, ...Array.from(container.querySelectorAll('*'))];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Match user-visible prose the way the public query API has always promised: trim and collapse
|
|
35
|
+
* whitespace, compare canonically equivalent Unicode in NFC, and keep fuzzy text queries
|
|
36
|
+
* case-insensitive. Attribute identifiers such as testid stay exact and do not pass through here.
|
|
37
|
+
*/
|
|
38
|
+
function normaliseVisibleText(value) {
|
|
39
|
+
return value.replace(/\s+/g, ' ').trim().normalize('NFC');
|
|
40
|
+
}
|
|
41
|
+
function fuzzyVisibleText(actual, expected) {
|
|
42
|
+
return normaliseVisibleText(actual)
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.includes(normaliseVisibleText(expected).toLowerCase());
|
|
45
|
+
}
|
|
46
|
+
function exactVisibleText(actual, expected) {
|
|
47
|
+
return normaliseVisibleText(actual) === normaliseVisibleText(expected);
|
|
48
|
+
}
|
|
49
|
+
function directText(el) {
|
|
50
|
+
if (isInput(el)) {
|
|
51
|
+
const type = el.type.toLowerCase();
|
|
52
|
+
if ('submit' === type || 'button' === type || 'reset' === type)
|
|
53
|
+
return el.value;
|
|
54
|
+
}
|
|
55
|
+
const tag = el.tagName.toLowerCase();
|
|
56
|
+
if ('script' === tag || 'style' === tag)
|
|
57
|
+
return '';
|
|
58
|
+
return Array.from(el.childNodes)
|
|
59
|
+
.filter((node) => Node.TEXT_NODE === node.nodeType)
|
|
60
|
+
.map((node) => node.textContent ?? '')
|
|
61
|
+
.join('');
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Elements whose accessible name comes from author-supplied naming rather than free subtree
|
|
65
|
+
* content - the set `by: label` addresses.
|
|
66
|
+
*
|
|
67
|
+
* Form controls plus anything explicitly named with aria-label/aria-labelledby covers the common
|
|
68
|
+
* case. `button`, `meter`, `output` and `progress` are included because their names arrive on
|
|
69
|
+
* attributes (`value`) or from their caption, which is precisely what a label query is looking
|
|
70
|
+
* for; leaving them out would silently drop buttons from label searches.
|
|
71
|
+
*/
|
|
72
|
+
function semanticNameTarget(el) {
|
|
73
|
+
if (isInput(el) || isTextArea(el) || isSelect(el))
|
|
74
|
+
return true;
|
|
75
|
+
const tag = el.tagName.toLowerCase();
|
|
76
|
+
return ('button' === tag ||
|
|
77
|
+
'meter' === tag ||
|
|
78
|
+
'output' === tag ||
|
|
79
|
+
'progress' === tag ||
|
|
80
|
+
el.hasAttribute('aria-label') ||
|
|
81
|
+
el.hasAttribute('aria-labelledby'));
|
|
82
|
+
}
|
|
18
83
|
/**
|
|
19
84
|
* Resolve a scope to its container. A container of `null` means a scope was GIVEN but resolved to
|
|
20
85
|
* nothing (unmounted, or a selector that matches no element) - the caller must NOT fall back to the
|
|
@@ -84,70 +149,37 @@ function findByComponent(container, query) {
|
|
|
84
149
|
return [];
|
|
85
150
|
}
|
|
86
151
|
/**
|
|
87
|
-
* Role + name, matched the
|
|
88
|
-
*
|
|
89
|
-
* Two implementations were disagreeing, on both axes, for the same element:
|
|
90
|
-
*
|
|
91
|
-
* <input type="search" placeholder="Search User">
|
|
92
|
-
* Reticle getRole -> "textbox" TL/dom-accessibility-api -> "searchbox"
|
|
93
|
-
* Reticle getAccessibleName -> "Search User" TL -> "" (no placeholder)
|
|
94
|
-
*
|
|
95
|
-
* `reticle_query`, `reticle_snapshot` and every act result report Reticle's values. The matcher used
|
|
96
|
-
* TL's. So Reticle printed `textbox "Search User"` and then could not find it by that exact
|
|
97
|
-
* role and name - the same query call reporting an identity it cannot match.
|
|
152
|
+
* Role + name, matched with the same local accessibility engine used to describe results.
|
|
98
153
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* names differently; it is narrower and worse, because no caller could ever have closed the round
|
|
103
|
-
* trip.
|
|
104
|
-
*
|
|
105
|
-
* The fallback runs ONLY when the spec-accurate query finds nothing, so every name that resolves
|
|
106
|
-
* today keeps resolving exactly as before and this can never take a match away. Fixed on the
|
|
107
|
-
* matching side rather than by changing what is reported: dropping `placeholder` would leave an
|
|
108
|
-
* input whose only label is its placeholder nameless in every snapshot, and renaming the role would
|
|
109
|
-
* churn every stored anchor - both worse trades than a fallback that runs on the empty path.
|
|
154
|
+
* This intentionally makes Reticle's reported role and name the source of truth. If an element is
|
|
155
|
+
* described as `textbox "Search User"`, the resolver must accept that exact pair, not a different
|
|
156
|
+
* role or name from a second library.
|
|
110
157
|
*/
|
|
111
158
|
function queryByRoleAndName(container, role, name) {
|
|
112
|
-
|
|
113
|
-
return queryAllByRole(container, role, { hidden: true });
|
|
114
|
-
const spec = queryAllByRole(container, role, { hidden: true, name });
|
|
115
|
-
if (spec.length > 0)
|
|
116
|
-
return spec;
|
|
117
|
-
const wanted = name.trim();
|
|
118
|
-
// Reticle's own role AND name: the pair actually printed to the caller.
|
|
119
|
-
return [...container.querySelectorAll('*')].filter((el) => getRole(el) === role && getAccessibleName(el).trim() === wanted);
|
|
159
|
+
return elementsUnder(container).filter((el) => getRole(el) === role && (name === undefined || exactVisibleText(getAccessibleName(el), name)));
|
|
120
160
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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)) };
|
|
150
|
-
/** Run the appropriate Testing-Library query against ONE root (light DOM or a shadow root). */
|
|
161
|
+
function queryByText(container, value) {
|
|
162
|
+
return elementsUnder(container).filter((el) => fuzzyVisibleText(directText(el), value));
|
|
163
|
+
}
|
|
164
|
+
function queryByLabel(container, value) {
|
|
165
|
+
return elementsUnder(container).filter((el) => semanticNameTarget(el) && fuzzyVisibleText(getAccessibleName(el), value));
|
|
166
|
+
}
|
|
167
|
+
function queryByPlaceholder(container, value) {
|
|
168
|
+
return elementsUnder(container).filter((el) => {
|
|
169
|
+
const placeholder = el.getAttribute('placeholder');
|
|
170
|
+
return placeholder !== null && fuzzyVisibleText(placeholder, value);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function queryByTestId(container, value) {
|
|
174
|
+
return elementsUnder(container).filter((el) => el.getAttribute(TESTID_ATTR) === value);
|
|
175
|
+
}
|
|
176
|
+
function queryByAlt(container, value) {
|
|
177
|
+
return elementsUnder(container).filter((el) => {
|
|
178
|
+
const alt = el.getAttribute('alt');
|
|
179
|
+
return alt !== null && fuzzyVisibleText(alt, value);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/** Run the appropriate local query against ONE root, light DOM or a shadow root. */
|
|
151
183
|
function findIn(container, query) {
|
|
152
184
|
const by = query.by;
|
|
153
185
|
const value = query.value;
|
|
@@ -157,18 +189,15 @@ function findIn(container, query) {
|
|
|
157
189
|
case QueryBy.ROLE:
|
|
158
190
|
return queryByRoleAndName(container, value, query.name);
|
|
159
191
|
case QueryBy.TEXT:
|
|
160
|
-
return
|
|
192
|
+
return queryByText(container, value);
|
|
161
193
|
case QueryBy.LABEL:
|
|
162
|
-
return
|
|
194
|
+
return queryByLabel(container, value);
|
|
163
195
|
case QueryBy.PLACEHOLDER:
|
|
164
|
-
return
|
|
165
|
-
exact: false,
|
|
166
|
-
...TEXT_NORMALIZER,
|
|
167
|
-
});
|
|
196
|
+
return queryByPlaceholder(container, value);
|
|
168
197
|
case QueryBy.TESTID:
|
|
169
|
-
return
|
|
198
|
+
return queryByTestId(container, value);
|
|
170
199
|
case QueryBy.ALT:
|
|
171
|
-
return
|
|
200
|
+
return queryByAlt(container, value);
|
|
172
201
|
case QueryBy.COMPONENT:
|
|
173
202
|
// value is the component name;.source (if present) still takes precedence inside.
|
|
174
203
|
return findByComponent(container, { ...query, component: query.component ?? value });
|
|
@@ -193,20 +222,15 @@ function findIn(container, query) {
|
|
|
193
222
|
return queryByRoleAndName(container, query.role, query.name);
|
|
194
223
|
}
|
|
195
224
|
if (query.text !== undefined)
|
|
196
|
-
return
|
|
197
|
-
if (query.label !== undefined)
|
|
198
|
-
return
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return queryAllByPlaceholderText(container, toNfc(query.placeholder), {
|
|
202
|
-
exact: false,
|
|
203
|
-
...TEXT_NORMALIZER,
|
|
204
|
-
});
|
|
205
|
-
}
|
|
225
|
+
return queryByText(container, query.text);
|
|
226
|
+
if (query.label !== undefined)
|
|
227
|
+
return queryByLabel(container, query.label);
|
|
228
|
+
if (query.placeholder !== undefined)
|
|
229
|
+
return queryByPlaceholder(container, query.placeholder);
|
|
206
230
|
if (query.testid !== undefined)
|
|
207
|
-
return
|
|
231
|
+
return queryByTestId(container, query.testid);
|
|
208
232
|
if (query.alt !== undefined)
|
|
209
|
-
return
|
|
233
|
+
return queryByAlt(container, query.alt);
|
|
210
234
|
return [];
|
|
211
235
|
}
|
|
212
236
|
/**
|
|
@@ -247,8 +271,8 @@ function embeddedRootsUnder(root) {
|
|
|
247
271
|
for (const el of node.querySelectorAll('*')) {
|
|
248
272
|
const shadow = el.shadowRoot ?? capturedRootOf(el);
|
|
249
273
|
if (shadow !== null) {
|
|
250
|
-
// A ShadowRoot is a DocumentFragment
|
|
251
|
-
// container, and every call site only reads from it.
|
|
274
|
+
// A ShadowRoot is a DocumentFragment. The local query helpers only call querySelectorAll on
|
|
275
|
+
// the container, and every call site only reads from it.
|
|
252
276
|
found.push(shadow);
|
|
253
277
|
walk(shadow, depth); // nested web components
|
|
254
278
|
continue;
|
|
@@ -284,9 +308,10 @@ function embeddedRootsUnder(root) {
|
|
|
284
308
|
/**
|
|
285
309
|
* Run the query against the light DOM AND every open shadow root beneath the scope.
|
|
286
310
|
*
|
|
287
|
-
*
|
|
288
|
-
* on a completely healthy page - a miss indistinguishable from a
|
|
289
|
-
* snapshot has always pierced open roots; this makes `query` agree
|
|
311
|
+
* A single querySelectorAll call only walks the root it starts from, so a control inside a web
|
|
312
|
+
* component returned zero matches on a completely healthy page - a miss indistinguishable from a
|
|
313
|
+
* genuinely absent element. The snapshot has always pierced open roots; this makes `query` agree
|
|
314
|
+
* with it.
|
|
290
315
|
*/
|
|
291
316
|
function findCandidates(query) {
|
|
292
317
|
const { container, scopeMissing } = resolveContainer(query.scope);
|
|
@@ -449,13 +474,7 @@ function buildPresentRegions(query) {
|
|
|
449
474
|
'tablist',
|
|
450
475
|
];
|
|
451
476
|
for (const role of CONTAINER_ROLES) {
|
|
452
|
-
|
|
453
|
-
try {
|
|
454
|
-
containers = queryAllByRole(container, role, { hidden: true });
|
|
455
|
-
}
|
|
456
|
-
catch {
|
|
457
|
-
continue;
|
|
458
|
-
}
|
|
477
|
+
const containers = queryByRoleAndName(container, role, undefined);
|
|
459
478
|
for (const el of containers) {
|
|
460
479
|
const name = el.getAttribute('aria-label') ??
|
|
461
480
|
resolveLabelledBy(el) ?? // aria-labelledby is an element ID - resolve it to the referenced TEXT
|
|
@@ -485,6 +504,47 @@ function buildPresentRegions(query) {
|
|
|
485
504
|
}
|
|
486
505
|
return regions;
|
|
487
506
|
}
|
|
507
|
+
/**
|
|
508
|
+
* The tightest single element whose SUBTREE text carries `wanted`, when no element's OWN text does.
|
|
509
|
+
*
|
|
510
|
+
* `by: text` matches an element's direct text nodes (`directText`), so a label rendered across
|
|
511
|
+
* several child nodes — `v-html`, an inline `<span>` per word, a highlighted substring — exists on
|
|
512
|
+
* the page and matches nothing. The two channels disagree by construction: `act_and_wait` reports
|
|
513
|
+
* `appeared` by concatenating an added subtree, so the string an agent is most likely to copy into
|
|
514
|
+
* its next predicate is exactly the shape this query cannot find.
|
|
515
|
+
*
|
|
516
|
+
* That failure is indistinguishable from the element being absent, which is the expensive half: the
|
|
517
|
+
* agent reads "no element matched", concludes the app did not render, and reports a bug against
|
|
518
|
+
* correct code.
|
|
519
|
+
*
|
|
520
|
+
* Returns the DEEPEST container rather than the first, because every ancestor up to `<body>` also
|
|
521
|
+
* contains the string and naming `<body>` is not a locator. The deepest one is the tightest scope
|
|
522
|
+
* that still holds the whole string, which is the one worth pasting back.
|
|
523
|
+
*/
|
|
524
|
+
function splitTextOwner(container, wanted) {
|
|
525
|
+
let best;
|
|
526
|
+
let bestDepth = -1;
|
|
527
|
+
for (const el of elementsUnder(container)) {
|
|
528
|
+
if (isIgnored(el))
|
|
529
|
+
continue; // never point an agent at Reticle's own UI
|
|
530
|
+
if (!fuzzyVisibleText(el.textContent ?? '', wanted))
|
|
531
|
+
continue;
|
|
532
|
+
let depth = 0;
|
|
533
|
+
for (let parent = el.parentElement; parent !== null; parent = parent.parentElement)
|
|
534
|
+
depth++;
|
|
535
|
+
if (depth > bestDepth) {
|
|
536
|
+
best = el;
|
|
537
|
+
bestDepth = depth;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return best;
|
|
541
|
+
}
|
|
542
|
+
/** The text this query searched for, in either spelling, or undefined when it searched by something else. */
|
|
543
|
+
function wantedTextOf(query) {
|
|
544
|
+
if (query.text !== undefined)
|
|
545
|
+
return query.text;
|
|
546
|
+
return QueryBy.TEXT === query.by ? query.value : undefined;
|
|
547
|
+
}
|
|
488
548
|
/** Diagnostic hint for a zero-match query: what testids ARE present in the searched scope. */
|
|
489
549
|
function buildEmptyHint(query) {
|
|
490
550
|
const container = resolveContainer(query.scope).container ?? document.body;
|
|
@@ -500,15 +560,26 @@ function buildEmptyHint(query) {
|
|
|
500
560
|
break;
|
|
501
561
|
}
|
|
502
562
|
}
|
|
503
|
-
|
|
563
|
+
// DECLARED, not observed: see declaredTestids. Every present testid is also an observed one, so
|
|
564
|
+
// the merged list would make this flag true for any page that has a testid at all.
|
|
565
|
+
const registered = declaredTestids();
|
|
504
566
|
const knownEmptyState = present.some((id) => registered.includes(id));
|
|
505
567
|
const route = `${location.pathname}${location.search}`;
|
|
506
|
-
|
|
568
|
+
const hint = {
|
|
507
569
|
route,
|
|
508
570
|
presentTestids: present,
|
|
509
571
|
presentRegions: buildPresentRegions(query),
|
|
510
572
|
knownEmptyState,
|
|
511
573
|
};
|
|
574
|
+
// Only for a text search, and only when the string IS here — otherwise this is an ordinary miss
|
|
575
|
+
// and the extra clause would lengthen every failure to say nothing.
|
|
576
|
+
const wanted = wantedTextOf(query);
|
|
577
|
+
if (wanted !== undefined) {
|
|
578
|
+
const owner = splitTextOwner(container, wanted);
|
|
579
|
+
if (owner !== undefined)
|
|
580
|
+
hint.splitText = describe(owner);
|
|
581
|
+
}
|
|
582
|
+
return hint;
|
|
512
583
|
}
|
|
513
584
|
/**
|
|
514
585
|
* Resolve a query to descriptors for the `query` MCP tool.
|
package/dist/dom/refs.d.ts
CHANGED
|
@@ -63,6 +63,17 @@ export declare class RefRegistry {
|
|
|
63
63
|
constructor(storage?: () => Storage | undefined);
|
|
64
64
|
/** How many reverse entries are currently retained. Exposed so the bound can be asserted. */
|
|
65
65
|
get size(): number;
|
|
66
|
+
/**
|
|
67
|
+
* A hot update just replaced modules in this document: every ref handed out so far was minted
|
|
68
|
+
* against the DOM the previous version of that code produced.
|
|
69
|
+
*
|
|
70
|
+
* Only the boundary is recorded. Nothing is evicted — a hot update is not a navigation, and most
|
|
71
|
+
* of the page survives one, so refs minted before it very often still resolve. This exists so the
|
|
72
|
+
* ones that DON'T can say why.
|
|
73
|
+
*/
|
|
74
|
+
markEdited(): void;
|
|
75
|
+
/** Was this ref handed out before the last hot update? False while no update has been observed. */
|
|
76
|
+
mintedBeforeLastEdit(ref: string): boolean;
|
|
66
77
|
/** Get the existing ref for an element, or mint a new one. */
|
|
67
78
|
refFor(el: Element): Ref;
|
|
68
79
|
/**
|
package/dist/dom/refs.js
CHANGED
|
@@ -19,6 +19,8 @@ import { TRANSPORT_LIMITS, asRef } from '@reticlehq/core';
|
|
|
19
19
|
export const MAX_TRACKED_REFS = 10000;
|
|
20
20
|
/** Marks a ref that was too long to echo in full. */
|
|
21
21
|
const REF_ELISION = '…';
|
|
22
|
+
/** Every ref Reticle mints is this letter followed by its sequence number. */
|
|
23
|
+
const REF_PREFIX = 'e';
|
|
22
24
|
/**
|
|
23
25
|
* A ref, made safe to interpolate into an error message.
|
|
24
26
|
*
|
|
@@ -105,6 +107,15 @@ export class RefRegistry {
|
|
|
105
107
|
/** The end of the block currently reserved. Minting past it claims the next one. */
|
|
106
108
|
#reserved = 0;
|
|
107
109
|
#mintsSinceSweep = 0;
|
|
110
|
+
/**
|
|
111
|
+
* The highest number handed out before the last hot update was applied, or 0 while none has been.
|
|
112
|
+
*
|
|
113
|
+
* A watermark rather than a per-ref stamp: the sequence never restarts within a document, so one
|
|
114
|
+
* number separates "minted before the code changed" from "minted after it" for every ref at once.
|
|
115
|
+
* The alternative — an epoch on every map entry — is bookkeeping paid on every mint for an answer
|
|
116
|
+
* needed only on the rare refusal.
|
|
117
|
+
*/
|
|
118
|
+
#editedAtSeq = 0;
|
|
108
119
|
/**
|
|
109
120
|
* Storage is injected rather than read from the global for the same reason the clock is: it is an
|
|
110
121
|
* ambient dependency that decides behaviour, and the cross-document property is untestable if the
|
|
@@ -156,6 +167,24 @@ export class RefRegistry {
|
|
|
156
167
|
while (this.#fromRef.size > MAX_TRACKED_REFS)
|
|
157
168
|
this.#evictOldest();
|
|
158
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* A hot update just replaced modules in this document: every ref handed out so far was minted
|
|
172
|
+
* against the DOM the previous version of that code produced.
|
|
173
|
+
*
|
|
174
|
+
* Only the boundary is recorded. Nothing is evicted — a hot update is not a navigation, and most
|
|
175
|
+
* of the page survives one, so refs minted before it very often still resolve. This exists so the
|
|
176
|
+
* ones that DON'T can say why.
|
|
177
|
+
*/
|
|
178
|
+
markEdited() {
|
|
179
|
+
this.#editedAtSeq = this.#seq;
|
|
180
|
+
}
|
|
181
|
+
/** Was this ref handed out before the last hot update? False while no update has been observed. */
|
|
182
|
+
mintedBeforeLastEdit(ref) {
|
|
183
|
+
if (0 === this.#editedAtSeq || !ref.startsWith(REF_PREFIX))
|
|
184
|
+
return false;
|
|
185
|
+
const n = Number(ref.slice(REF_PREFIX.length));
|
|
186
|
+
return Number.isSafeInteger(n) && n > 0 && n <= this.#editedAtSeq;
|
|
187
|
+
}
|
|
159
188
|
/** Get the existing ref for an element, or mint a new one. */
|
|
160
189
|
refFor(el) {
|
|
161
190
|
const existing = this.#toRef.get(el);
|
|
@@ -173,7 +202,7 @@ export class RefRegistry {
|
|
|
173
202
|
if (this.#seq >= this.#reserved)
|
|
174
203
|
this.#reserve();
|
|
175
204
|
this.#seq += 1;
|
|
176
|
-
const ref = asRef(
|
|
205
|
+
const ref = asRef(`${REF_PREFIX}${String(this.#seq)}`);
|
|
177
206
|
this.#toRef.set(el, ref);
|
|
178
207
|
this.#fromRef.set(ref, new WeakRef(el));
|
|
179
208
|
// Keep bounded cheaply on every mint; run the full dead-entry sweep only periodically.
|
package/dist/dom/snapshot.d.ts
CHANGED
|
@@ -21,13 +21,50 @@ export interface SnapshotResult {
|
|
|
21
21
|
* must not receive the entire page as if it were the modal.
|
|
22
22
|
*/
|
|
23
23
|
scopeMissing?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* How many MEANINGFUL nodes the lean walk passed over, present only in a lean mode and only when
|
|
26
|
+
* non-zero. This is what stops an empty `interactive` tree reading as an empty page — the mode is
|
|
27
|
+
* a role filter, and a production app whose controls carry no roles yields nothing while being
|
|
28
|
+
* full of things to drive. It shipped untyped: the value was spread into the result and every
|
|
29
|
+
* consumer had to reach for it without a type, including the tool that builds the note from it.
|
|
30
|
+
*/
|
|
31
|
+
leanSkipped?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Refs of the subtree roots the walk never entered, present ONLY when `truncated`. This is the
|
|
34
|
+
* cut's own frontier: re-snapshot each with `{ scope: ref, includeRoot: true }` and the union is
|
|
35
|
+
* the whole tree. Without it `truncated` says only THAT the read stopped, never WHERE, so nobody
|
|
36
|
+
* could finish it — and a reader who does not finish it must not conclude anything is ABSENT.
|
|
37
|
+
*/
|
|
38
|
+
unread?: string[];
|
|
39
|
+
/**
|
|
40
|
+
* True when the frontier itself did not fit in `MAX_UNREAD_BRANCHES`, so `unread` is a prefix of
|
|
41
|
+
* the branches that were cut. Completion is then impossible from this read, and the difference
|
|
42
|
+
* between "impossible" and "not attempted" is the whole point of saying so.
|
|
43
|
+
*/
|
|
44
|
+
unreadOverflow?: true;
|
|
24
45
|
}
|
|
25
46
|
interface SnapshotOptions {
|
|
26
47
|
scope?: string | undefined;
|
|
27
48
|
mode?: SnapshotMode | undefined;
|
|
28
49
|
maxNodes?: number | undefined;
|
|
29
50
|
maxDepth?: number | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* Emit the scope element's OWN line before its descendants. Off by default (a scoped snapshot has
|
|
53
|
+
* always been the subtree UNDER the scope); the completion re-read needs it, because the branch it
|
|
54
|
+
* is re-reading is exactly the node the truncated walk stopped before emitting.
|
|
55
|
+
*/
|
|
56
|
+
includeRoot?: boolean | undefined;
|
|
30
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Cap on how many cut branches one snapshot names.
|
|
60
|
+
*
|
|
61
|
+
* A hostile or merely huge DOM can cut thousands of siblings at once (a non-virtualized grid does it
|
|
62
|
+
* routinely), and a frontier that large is neither affordable to send nor useful to re-read one at a
|
|
63
|
+
* time. Fifty is well above what a real page's cut produces — the frontier is the branches at the
|
|
64
|
+
* node cap, not the nodes themselves — and small enough that the list stays a hint rather than a
|
|
65
|
+
* second snapshot. Past it the read declares `unreadOverflow` instead of quietly sending fewer.
|
|
66
|
+
*/
|
|
67
|
+
export declare const MAX_UNREAD_BRANCHES = 50;
|
|
31
68
|
/** Build the semantic accessibility snapshot of the page or a subtree. */
|
|
32
69
|
export declare function buildSnapshot(options?: SnapshotOptions): SnapshotResult;
|
|
33
70
|
export {};
|