@reticlehq/browser 2.10.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.
@@ -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: true,
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;
@@ -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
- /** The keys `upload` actually reads, and the generic ones every action accepts. */
261
- const UPLOAD_ARG_KEYS = new Set(['content', 'name', 'type']);
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 (`{ path, name }`) is worse, because the right filename arrives attached
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
- 'Reading a file from disk is not supported; pass its bytes as `content`.');
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
- const file = new File([asString(args['content'], 'reticle test file')], asString(args['name'], 'file.txt'), {
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 { echoRef, refs } from '../dom/refs.js';
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(`ref '${echoRef(ref)}' no longer resolves to an element`);
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 — decisively — so does dom-accessibility-api, which is
135
- * what `by: role` + name MATCHES against via testing-library. Reading raw `textContent` here made the
136
- * name we REPORT differ from the name that can be SELECTED: Material UI renders its required-field
137
- * marker as `<span aria-hidden="true"> *</span>`, so a login field was reported as `"Username *"` and
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 must come from the same rule.
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
- let out = '';
150
- for (const child of el.childNodes)
151
- out += textWithoutHidden(child);
152
- return out;
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 way Reticle REPORTS them - not only the way Testing Library computes them.
152
+ * Role + name, matched with the same local accessibility engine used to describe results.
88
153
  *
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.
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
- if (name === undefined)
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
- * 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)) };
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 queryAllByText(container, toNfc(value), { exact: false, ...TEXT_NORMALIZER });
192
+ return queryByText(container, value);
161
193
  case QueryBy.LABEL:
162
- return queryAllByLabelText(container, toNfc(value), { exact: false, ...TEXT_NORMALIZER });
194
+ return queryByLabel(container, value);
163
195
  case QueryBy.PLACEHOLDER:
164
- return queryAllByPlaceholderText(container, toNfc(value), {
165
- exact: false,
166
- ...TEXT_NORMALIZER,
167
- });
196
+ return queryByPlaceholder(container, value);
168
197
  case QueryBy.TESTID:
169
- return queryAllByTestId(container, value, { exact: true });
198
+ return queryByTestId(container, value);
170
199
  case QueryBy.ALT:
171
- return queryAllByAltText(container, toNfc(value), { exact: false, ...TEXT_NORMALIZER });
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 queryAllByText(container, toNfc(query.text), { exact: false, ...TEXT_NORMALIZER });
197
- if (query.label !== undefined) {
198
- return queryAllByLabelText(container, toNfc(query.label), { exact: false, ...TEXT_NORMALIZER });
199
- }
200
- if (query.placeholder !== undefined) {
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 queryAllByTestId(container, query.testid, { exact: true });
231
+ return queryByTestId(container, query.testid);
208
232
  if (query.alt !== undefined)
209
- return queryAllByAltText(container, toNfc(query.alt), { exact: false, ...TEXT_NORMALIZER });
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; the Testing-Library queries accept any HTMLElement-like
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
- * Testing Library only walks the light DOM, so a control inside a web component returned zero matches
288
- * on a completely healthy page - a miss indistinguishable from a genuinely absent element. The
289
- * snapshot has always pierced open roots; this makes `query` agree with it.
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
- let containers;
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
@@ -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
  /**