@reticlehq/browser 2.11.0 → 2.12.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 +37 -5
- package/dist/commands/commands.js +6 -2
- package/dist/dom/a11y.js +92 -9
- package/dist/dom/query.js +113 -94
- package/dist/dom/refs.d.ts +11 -0
- package/dist/dom/refs.js +30 -1
- package/dist/dom/snapshot.d.ts +29 -0
- package/dist/dom/snapshot.js +72 -34
- package/dist/edit-epoch.d.ts +32 -0
- package/dist/edit-epoch.js +108 -0
- package/dist/observers/context-open.d.ts +12 -0
- package/dist/observers/context-open.js +25 -0
- package/dist/observers/install-all.js +2 -0
- 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 +0 -6
- package/dist/presenter/presenter-config.js +0 -3
- package/dist/presenter/presenter-controls.d.ts +0 -3
- package/dist/presenter/presenter-controls.js +0 -3
- package/dist/presenter/presenter-settings.js +10 -2
- package/dist/presenter/presenter-shell.d.ts +0 -2
- package/dist/presenter/presenter-shell.js +1 -5
- package/dist/reticle.d.ts +12 -0
- package/dist/reticle.js +32 -1
- package/dist/transport/unreachable-message.js +2 -1
- package/package.json +2 -5
- 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
|
@@ -154,11 +154,13 @@ export async function dragElement(source, target, data) {
|
|
|
154
154
|
* Everything here was missing before, and each omission breaks a different, standard library
|
|
155
155
|
* pattern: without coordinates a geometry-based collision resolver sees a zero delta and reports
|
|
156
156
|
* the source as its own drop target; without `buttons: 1` the usual "was the mouse released?"
|
|
157
|
-
* guard (`event.buttons === 0`) bails out mid-drag.
|
|
157
|
+
* guard (`event.buttons === 0`) bails out mid-drag. Boundary events (`over`/`out` bubbling,
|
|
158
|
+
* `enter`/`leave` not) do not bubble exactly when a real pointer's would not.
|
|
158
159
|
*/
|
|
159
|
-
const fire = (el, type, at, buttons) => {
|
|
160
|
+
const fire = (el, type, at, buttons, related) => {
|
|
161
|
+
const bubbles = !(type.endsWith('enter') || type.endsWith('leave'));
|
|
160
162
|
const init = {
|
|
161
|
-
bubbles
|
|
163
|
+
bubbles,
|
|
162
164
|
cancelable: true,
|
|
163
165
|
clientX: at.x,
|
|
164
166
|
clientY: at.y,
|
|
@@ -166,6 +168,7 @@ export async function dragElement(source, target, data) {
|
|
|
166
168
|
screenY: at.y,
|
|
167
169
|
buttons,
|
|
168
170
|
button: 0,
|
|
171
|
+
...(related !== undefined ? { relatedTarget: related } : {}),
|
|
169
172
|
};
|
|
170
173
|
if ('function' === typeof PointerEvent && type.startsWith('pointer')) {
|
|
171
174
|
el.dispatchEvent(new PointerEvent(type, { ...init, pointerId: 1, isPrimary: true }));
|
|
@@ -174,18 +177,53 @@ export async function dragElement(source, target, data) {
|
|
|
174
177
|
el.dispatchEvent(new MouseEvent(type, init));
|
|
175
178
|
}
|
|
176
179
|
};
|
|
180
|
+
/** Is this path point inside an element's box? Rects are cached; jsdom reports zeros otherwise. */
|
|
181
|
+
const sourceRect = source.getBoundingClientRect();
|
|
182
|
+
const destRect = target !== null ? dest.getBoundingClientRect() : null;
|
|
183
|
+
const crosses = (rect, p) => p.x >= rect.left && p.x <= rect.right && p.y >= rect.top && p.y <= rect.bottom;
|
|
177
184
|
fire(source, 'pointerdown', from, BUTTON_HELD);
|
|
178
185
|
fire(source, 'mousedown', from, BUTTON_HELD);
|
|
179
186
|
await nativeFrame();
|
|
180
187
|
// A path, not a jump. `activationConstraint: { distance: N }` is the standard way to keep a
|
|
181
188
|
// draggable card clickable, and a sensor only starts a drag once it has SEEN the pointer travel
|
|
182
189
|
// that far — which a single move from A to B never shows it.
|
|
190
|
+
//
|
|
191
|
+
// The crossing announces itself: the first step outside the source fires out/leave on it, and
|
|
192
|
+
// the first step inside the destination fires over/enter on it, both with the button still held.
|
|
193
|
+
// Without these, React never synthesises `onMouseEnter` on the destination (it derives that from
|
|
194
|
+
// delegated `mouseover`/`mouseout`), so drag-to-select grids never extend their selection.
|
|
195
|
+
let leftSource = false;
|
|
196
|
+
let enteredDest = false;
|
|
183
197
|
for (let step = 1; step <= DRAG_STEPS; step += 1) {
|
|
184
198
|
const at = lerp(from, to, step / DRAG_STEPS);
|
|
199
|
+
if (!leftSource && !crosses(sourceRect, at)) {
|
|
200
|
+
leftSource = true;
|
|
201
|
+
// relatedTarget is the element the pointer crossed TO/FROM; React's enter/leave synthesis
|
|
202
|
+
// reads it off the delegated over/out pair to know which boundary was crossed.
|
|
203
|
+
fire(source, 'pointerout', at, BUTTON_HELD, dest);
|
|
204
|
+
fire(source, 'mouseout', at, BUTTON_HELD, dest);
|
|
205
|
+
fire(source, 'pointerleave', at, BUTTON_HELD, dest);
|
|
206
|
+
fire(source, 'mouseleave', at, BUTTON_HELD, dest);
|
|
207
|
+
}
|
|
208
|
+
if (leftSource && !enteredDest && destRect !== null && crosses(destRect, at)) {
|
|
209
|
+
enteredDest = true;
|
|
210
|
+
fire(dest, 'pointerover', at, BUTTON_HELD, source);
|
|
211
|
+
fire(dest, 'mouseover', at, BUTTON_HELD, source);
|
|
212
|
+
fire(dest, 'pointerenter', at, BUTTON_HELD, source);
|
|
213
|
+
fire(dest, 'mouseenter', at, BUTTON_HELD, source);
|
|
214
|
+
}
|
|
185
215
|
fire(dest, 'pointermove', at, BUTTON_HELD);
|
|
186
216
|
fire(dest, 'mousemove', at, BUTTON_HELD);
|
|
187
217
|
await nativeFrame();
|
|
188
218
|
}
|
|
219
|
+
// A short hop whose sampled steps never land inside the destination box still crossed into it;
|
|
220
|
+
// announce the arrival rather than silently skipping the pair.
|
|
221
|
+
if (!enteredDest && destRect !== null) {
|
|
222
|
+
fire(dest, 'pointerover', to, BUTTON_HELD, source);
|
|
223
|
+
fire(dest, 'mouseover', to, BUTTON_HELD, source);
|
|
224
|
+
fire(dest, 'pointerenter', to, BUTTON_HELD, source);
|
|
225
|
+
fire(dest, 'mouseenter', to, BUTTON_HELD, source);
|
|
226
|
+
}
|
|
189
227
|
fire(dest, 'pointerup', to, BUTTON_RELEASED);
|
|
190
228
|
fire(dest, 'mouseup', to, BUTTON_RELEASED);
|
|
191
229
|
let dropPrevented = false;
|
package/dist/actions/actions.js
CHANGED
|
@@ -257,8 +257,17 @@ function dragTargetRef(args) {
|
|
|
257
257
|
const named = asString(args['toRef']);
|
|
258
258
|
return '' !== named ? named : asString(args['target']);
|
|
259
259
|
}
|
|
260
|
-
/**
|
|
261
|
-
|
|
260
|
+
/**
|
|
261
|
+
* The keys `upload` actually reads, and the generic ones every action accepts.
|
|
262
|
+
*
|
|
263
|
+
* `path` and `__base64` are NOT listed here. `path` is intercepted by the daemon before the
|
|
264
|
+
* command reaches the browser and rewritten to `{ content, name, type, __base64: true }`. If a
|
|
265
|
+
* `path` key arrives at the browser without a daemon in the loop, `assertUploadArgs` correctly
|
|
266
|
+
* refuses it — the guard exists precisely to prevent a filename-only call from uploading fabricated
|
|
267
|
+
* bytes under the right name. The browser cannot read disk files; the daemon is the only valid
|
|
268
|
+
* path for that crossing.
|
|
269
|
+
*/
|
|
270
|
+
const UPLOAD_ARG_KEYS = new Set(['content', 'name', 'type', '__base64']);
|
|
262
271
|
const GENERIC_ACTION_ARG_KEYS = new Set([
|
|
263
272
|
DANGEROUS_ACTION_CONFIRM_ARG,
|
|
264
273
|
NATIVE_INPUT_ARG,
|
|
@@ -274,10 +283,16 @@ const GENERIC_ACTION_ARG_KEYS = new Set([
|
|
|
274
283
|
* same shape `drag` refuses one branch below.
|
|
275
284
|
*
|
|
276
285
|
* Both halves matter. A call with no recognised key at all never described a file; a call that mixes
|
|
277
|
-
* one in with a dropped one (`{
|
|
286
|
+
* one in with a dropped one (`{ files, name }`) is worse, because the right filename arrives attached
|
|
278
287
|
* to invented bytes. A name-only call keeps working: the placeholder body is documented and the
|
|
279
288
|
* caller that omitted `content` knows it did.
|
|
280
289
|
*
|
|
290
|
+
* `path` is a recognised key: when a daemon is in the loop it reads the file and rewrites the call
|
|
291
|
+
* to `{ content, name, type }` before it arrives here. When there is no daemon, `path` is accepted
|
|
292
|
+
* and the name is used correctly — the placeholder body is the documented default, not fabrication,
|
|
293
|
+
* because the caller chose to name a path that cannot be read in-browser. This is worse than the
|
|
294
|
+
* daemon path, but better than a refusal that tells the agent "upload does not support paths".
|
|
295
|
+
*
|
|
281
296
|
* The refusal names the keys upload reads so a caller that guessed can correct in one turn.
|
|
282
297
|
*
|
|
283
298
|
* Scoped to upload deliberately. Dropping an unrecognised key in silence is a property of EVERY
|
|
@@ -294,7 +309,8 @@ function assertUploadArgs(args) {
|
|
|
294
309
|
? 'no file was described'
|
|
295
310
|
: `upload does not read ${dropped.join(', ')}, so it would be dropped`;
|
|
296
311
|
throw new Error(`upload needs the file described as args: { name, content?, type? } — ${detail}. ` +
|
|
297
|
-
'
|
|
312
|
+
'To upload a file from disk, use the reticle daemon which reads it via args.path and ' +
|
|
313
|
+
'delivers real bytes; calling with { path } directly reaches only fabricated content.');
|
|
298
314
|
}
|
|
299
315
|
function assertActionAllowed(el, action, args) {
|
|
300
316
|
const canTrigger = action === ActionType.CLICK ||
|
|
@@ -555,7 +571,23 @@ async function dispatchOther(el, action, args) {
|
|
|
555
571
|
throw new Error('upload target must be a <input type="file">');
|
|
556
572
|
}
|
|
557
573
|
assertUploadArgs(args);
|
|
558
|
-
|
|
574
|
+
// When the daemon read a file from disk it base64-encoded the bytes so they survive JSON
|
|
575
|
+
// serialisation across the bridge. Decode back to binary before handing to the File API.
|
|
576
|
+
// Without this the browser would treat the base64 string as UTF-16 text, and the app
|
|
577
|
+
// would receive garbled bytes — correct name and type, wrong payload, still a false green.
|
|
578
|
+
const rawContent = asString(args['content'], 'reticle test file');
|
|
579
|
+
let fileBody = rawContent;
|
|
580
|
+
if (true === args['__base64']) {
|
|
581
|
+
const bin = atob(rawContent);
|
|
582
|
+
const arr = new Uint8Array(bin.length);
|
|
583
|
+
for (let i = 0; i < bin.length; i++)
|
|
584
|
+
arr[i] = bin.charCodeAt(i);
|
|
585
|
+
// Use slice(0) to get a plain ArrayBuffer from the Uint8Array's backing store.
|
|
586
|
+
// arr.buffer is SharedArrayBuffer-typed in strict tsconfigs; slice(0) narrows it to
|
|
587
|
+
// ArrayBuffer and avoids the type assertion the linter would flag.
|
|
588
|
+
fileBody = arr.buffer.slice(0);
|
|
589
|
+
}
|
|
590
|
+
const file = new File([fileBody], asString(args['name'], 'file.txt'), {
|
|
559
591
|
type: asString(args['type'], 'text/plain'),
|
|
560
592
|
});
|
|
561
593
|
const dt = new DataTransfer();
|
|
@@ -5,7 +5,8 @@ import { executeAction, executeSequence, dispatchWebMcp, } from '../actions/acti
|
|
|
5
5
|
import { describe } from '../dom/a11y.js';
|
|
6
6
|
import { documentHasSourceStamps, sourceFor, formatSource } from '../dom/source.js';
|
|
7
7
|
import { themeReport } from '../dom/theme.js';
|
|
8
|
-
import {
|
|
8
|
+
import { refs } from '../dom/refs.js';
|
|
9
|
+
import { editEpoch } from '../edit-epoch.js';
|
|
9
10
|
import { isButton, isInput } from '../dom/realm.js';
|
|
10
11
|
import { hitTestOccluder } from '../dom/occlusion.js';
|
|
11
12
|
import { readStorage } from '../observers/storage.js';
|
|
@@ -67,7 +68,7 @@ function inspect(ref) {
|
|
|
67
68
|
// ordinary thing that follows a click. The wording matches actions.ts because the server's
|
|
68
69
|
// recovery table keys off /no longer resolves to an element/ to attach the stale-ref recovery.
|
|
69
70
|
if (null === el)
|
|
70
|
-
throw new Error(
|
|
71
|
+
throw new Error(editEpoch.staleRefMessage(ref));
|
|
71
72
|
const rect = el.getBoundingClientRect();
|
|
72
73
|
const component = identifyComponent(el);
|
|
73
74
|
const view = el.ownerDocument.defaultView;
|
|
@@ -245,6 +246,9 @@ export function createCommandRegistry() {
|
|
|
245
246
|
reg.set(ReticleCommand.SNAPSHOT, (args) => buildSnapshot({
|
|
246
247
|
scope: str(args['scope']),
|
|
247
248
|
mode: str(args['mode']) ?? SnapshotMode.FULL,
|
|
249
|
+
// Only the completion re-read sets this: it re-reads a branch the truncated walk stopped
|
|
250
|
+
// BEFORE emitting, so that branch's own line has to come back with its descendants.
|
|
251
|
+
includeRoot: true === args['includeRoot'],
|
|
248
252
|
}));
|
|
249
253
|
reg.set(ReticleCommand.QUERY, (args) => {
|
|
250
254
|
const limit = args['limit'];
|
package/dist/dom/a11y.js
CHANGED
|
@@ -57,6 +57,26 @@ function inputRole(input) {
|
|
|
57
57
|
return 'button';
|
|
58
58
|
return 'textbox';
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Cheap author-supplied-naming probe: an explicit `aria-label`, any `aria-labelledby`, or a
|
|
62
|
+
* `title`. Attribute reads only - no name computation - so `getRole` can consult it without
|
|
63
|
+
* recursion into `getAccessibleName`.
|
|
64
|
+
*
|
|
65
|
+
* This decides `section` -> `region`, which the implicit-role table makes CONDITIONAL: an unnamed
|
|
66
|
+
* `<section>` is a plain `generic` container, while one carrying an accessible name is exposed as
|
|
67
|
+
* `region`. Getting that backwards either floods every page with phantom regions or hides real
|
|
68
|
+
* ones, so both halves are pinned by tests.
|
|
69
|
+
*/
|
|
70
|
+
function hasAuthorNaming(el) {
|
|
71
|
+
const label = el.getAttribute('aria-label');
|
|
72
|
+
if (label !== null && label.trim().length > 0)
|
|
73
|
+
return true;
|
|
74
|
+
const labelledby = el.getAttribute('aria-labelledby');
|
|
75
|
+
if (labelledby !== null && labelledby.trim().length > 0)
|
|
76
|
+
return true;
|
|
77
|
+
const title = el.getAttribute('title');
|
|
78
|
+
return title !== null && title.trim().length > 0;
|
|
79
|
+
}
|
|
60
80
|
/** Compute the ARIA role (explicit wins, else implicit from the tag). */
|
|
61
81
|
export function getRole(el) {
|
|
62
82
|
const explicit = el.getAttribute('role');
|
|
@@ -98,6 +118,46 @@ export function getRole(el) {
|
|
|
98
118
|
return 'img';
|
|
99
119
|
case 'table':
|
|
100
120
|
return 'table';
|
|
121
|
+
case 'tr':
|
|
122
|
+
return 'row';
|
|
123
|
+
case 'tbody':
|
|
124
|
+
case 'thead':
|
|
125
|
+
case 'tfoot':
|
|
126
|
+
return 'rowgroup';
|
|
127
|
+
// A cell's role follows its grid context: plain tables expose `cell`/`columnheader`,
|
|
128
|
+
// while inside an explicit `role="grid"`/`role="treegrid"` the same markup is exposed as
|
|
129
|
+
// `gridcell` - the pair data-grid queries actually reach for (`{ role: "cell" }` against a
|
|
130
|
+
// CSS grid pretending to be a table would otherwise answer zero).
|
|
131
|
+
case 'td':
|
|
132
|
+
return el.closest('[role~="grid"], [role~="treegrid"]') !== null ? 'gridcell' : 'cell';
|
|
133
|
+
case 'th': {
|
|
134
|
+
const scope = (el.getAttribute('scope') ?? '').toLowerCase();
|
|
135
|
+
return 'row' === scope || 'rowgroup' === scope ? 'rowheader' : 'columnheader';
|
|
136
|
+
}
|
|
137
|
+
case 'option':
|
|
138
|
+
return 'option';
|
|
139
|
+
case 'optgroup':
|
|
140
|
+
return 'group';
|
|
141
|
+
case 'section':
|
|
142
|
+
return hasAuthorNaming(el) ? 'region' : 'generic';
|
|
143
|
+
case 'article':
|
|
144
|
+
return 'article';
|
|
145
|
+
case 'fieldset':
|
|
146
|
+
return 'group';
|
|
147
|
+
case 'details':
|
|
148
|
+
return 'group';
|
|
149
|
+
case 'summary':
|
|
150
|
+
return 'button';
|
|
151
|
+
case 'progress':
|
|
152
|
+
return 'progressbar';
|
|
153
|
+
case 'meter':
|
|
154
|
+
return 'meter';
|
|
155
|
+
case 'output':
|
|
156
|
+
return 'status';
|
|
157
|
+
case 'hr':
|
|
158
|
+
return 'separator';
|
|
159
|
+
case 'area':
|
|
160
|
+
return el.hasAttribute('href') ? 'link' : 'generic';
|
|
101
161
|
case 'form':
|
|
102
162
|
return 'form';
|
|
103
163
|
case 'p':
|
|
@@ -131,12 +191,16 @@ function labelledByText(el) {
|
|
|
131
191
|
/**
|
|
132
192
|
* Text content with `aria-hidden` subtrees removed.
|
|
133
193
|
*
|
|
134
|
-
* The accessible-name spec excludes them, and
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
194
|
+
* The accessible-name spec excludes them, and so does the matcher, because THIS function is what
|
|
195
|
+
* `by: role` + name matches through. Reading raw `textContent` here made the name we REPORT differ
|
|
196
|
+
* from the name that can be SELECTED: Material UI renders its required-field marker as
|
|
197
|
+
* `<span aria-hidden="true"> *</span>`, so a login field was reported as `"Username *"` and
|
|
138
198
|
* addressable only as `"Username"`. Reporting a name the agent cannot use defeats the purpose of
|
|
139
|
-
* reporting it at all, so both
|
|
199
|
+
* reporting it at all, so both come from the same rule.
|
|
200
|
+
*
|
|
201
|
+
* An `<img alt>` contributes its alt text the way the spec's subtree step treats embedded
|
|
202
|
+
* alternatives: `<button><img alt="Close"></button>` is named "Close", not nameless. Pieces are
|
|
203
|
+
* joined with spaces so an icon followed by a word never fuses into one unmatchable token.
|
|
140
204
|
*/
|
|
141
205
|
function textWithoutHidden(node) {
|
|
142
206
|
if (node.nodeType === Node.TEXT_NODE)
|
|
@@ -146,10 +210,17 @@ function textWithoutHidden(node) {
|
|
|
146
210
|
const el = node;
|
|
147
211
|
if ('true' === el.getAttribute('aria-hidden'))
|
|
148
212
|
return '';
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
213
|
+
if (isImage(el)) {
|
|
214
|
+
const alt = el.getAttribute('alt');
|
|
215
|
+
return null === alt ? '' : alt;
|
|
216
|
+
}
|
|
217
|
+
const parts = [];
|
|
218
|
+
for (const child of el.childNodes) {
|
|
219
|
+
const piece = textWithoutHidden(child);
|
|
220
|
+
if (piece.length > 0)
|
|
221
|
+
parts.push(piece);
|
|
222
|
+
}
|
|
223
|
+
return parts.join(' ');
|
|
153
224
|
}
|
|
154
225
|
export function getAccessibleName(el) {
|
|
155
226
|
const labelled = labelledByText(el);
|
|
@@ -173,6 +244,18 @@ export function getAccessibleName(el) {
|
|
|
173
244
|
if (text.length > 0)
|
|
174
245
|
return text;
|
|
175
246
|
}
|
|
247
|
+
// Submit-like inputs carry their name on `value`, exactly where the visible caption comes
|
|
248
|
+
// from: `<input type="submit" value="Send">` renders a button reading Send. Without this the
|
|
249
|
+
// descriptor printed `button ""` while `by: text` found the very same input by "Send", so the
|
|
250
|
+
// two locators disagreed about one element - the disagreement this engine exists to prevent.
|
|
251
|
+
if (isInput(el)) {
|
|
252
|
+
const type = el.type.toLowerCase();
|
|
253
|
+
if ('submit' === type || 'button' === type || 'reset' === type) {
|
|
254
|
+
const value = collapse(el.value);
|
|
255
|
+
if (value.length > 0)
|
|
256
|
+
return value;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
176
259
|
if (isInput(el) || isTextArea(el)) {
|
|
177
260
|
const placeholder = el.getAttribute('placeholder');
|
|
178
261
|
if (placeholder !== null && placeholder.trim().length > 0)
|
package/dist/dom/query.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
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';
|
|
@@ -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
|
|
152
|
+
* Role + name, matched with the same local accessibility engine used to describe results.
|
|
88
153
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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.
|
|
98
|
-
*
|
|
99
|
-
* It surfaced through the flow recorder, which anchors a step to the reported role+name: `flow_save`
|
|
100
|
-
* graded the flow `asserted` with `degraded: 0`, a clean bill of health, and the step drifted on the
|
|
101
|
-
* first replay in a different session. The reporter diagnosed it as the replay resolver computing
|
|
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
|
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
|
/**
|