@reticlehq/browser 2.5.0 → 2.6.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.
@@ -10,7 +10,14 @@ export declare const NO_GEOMETRY: ClickGeometry;
10
10
  * `defaultPrevented` so the probe is unchanged. Focus only moves for focusable targets (tabIndex>=0),
11
11
  * so a plain <div> click still reports focusMoved=null.
12
12
  */
13
- export declare function fireClickSequence(el: HTMLElement): boolean;
13
+ export declare function fireClickSequence(el: HTMLElement, hold?: {
14
+ ms: number;
15
+ sleep: (ms: number) => Promise<void>;
16
+ now: () => number;
17
+ }): Promise<{
18
+ prevented: boolean;
19
+ heldMs: number;
20
+ }>;
14
21
  /**
15
22
  * Click-like geometry honesty: scroll an off-viewport target into view, then hit-test the click
16
23
  * point. Synthetic dispatch always reaches the target regardless — this is purely so the agent
@@ -12,17 +12,31 @@ export const NO_GEOMETRY = {
12
12
  * `defaultPrevented` so the probe is unchanged. Focus only moves for focusable targets (tabIndex>=0),
13
13
  * so a plain <div> click still reports focusMoved=null.
14
14
  */
15
- export function fireClickSequence(el) {
15
+ export async function fireClickSequence(el, hold) {
16
16
  const doc = el.ownerDocument;
17
17
  const from = doc.activeElement ?? doc.body;
18
18
  firePointer(el, 'pointerdown', from);
19
19
  el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
20
20
  if (el.tabIndex >= 0 && 'function' === typeof el.focus)
21
21
  el.focus();
22
+ // The gap that makes hold-to-confirm driveable. Everything between down and up used to be
23
+ // synchronous, so a control whose contract is "the button is down for N ms" could not be
24
+ // expressed at all — the reported case cancelled its own confirm on the mouseup that arrived 7ms
25
+ // later. `drag` already splits the pair across a gap; this is that shape with a timer.
26
+ //
27
+ // The ACHIEVED hold is measured and returned rather than echoed back: `holdMs: 1200` against a
28
+ // 1200ms animation is a race by construction, and a caller needs to tell "held 1200" from
29
+ // "held 1204". A backgrounded tab throttles timers, so this can legitimately overshoot by a lot.
30
+ let heldMs = 0;
31
+ if (hold !== undefined && hold.ms > 0) {
32
+ const startedAt = hold.now();
33
+ await hold.sleep(hold.ms);
34
+ heldMs = hold.now() - startedAt;
35
+ }
22
36
  firePointer(el, 'pointerup', from);
23
37
  el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
24
38
  const notPrevented = el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
25
- return !notPrevented;
39
+ return { prevented: !notPrevented, heldMs };
26
40
  }
27
41
  /** A box has layout we can reason about (jsdom returns an all-zero box — nothing to hit-test). */
28
42
  function isMeasurable(rect) {
@@ -21,6 +21,18 @@ interface ActionEffect {
21
21
  valueChanged: boolean;
22
22
  /** Mutation records counted by a short-lived MutationObserver (one microtask + rAF window). */
23
23
  domMutatedWithin: number;
24
+ /**
25
+ * Text the action put on the page, truncated. OMITTED when it added none.
26
+ *
27
+ * `domMutatedWithin` says the click did SOMETHING; it cannot say the app rejected you. A login
28
+ * that fails returns ok/settled/mutated and reads exactly like one that succeeded, so an agent
29
+ * moves on and asserts against a page it misread. The observer already receives these records
30
+ * and was keeping only `.length` — the message was being thrown away, not gathered.
31
+ *
32
+ * Deliberately NOT a verdict: this is what appeared, not what it means. Absence means no text
33
+ * was added, which is why a silent class toggle omits the key rather than reporting "".
34
+ */
35
+ appeared?: string;
24
36
  /**
25
37
  * Click-like only: the center hit-tested to a foreign element (an overlay is on top). Synthetic
26
38
  * dispatch STILL delivered the event to the target, but a real user could not click it — treat
@@ -31,6 +43,14 @@ interface ActionEffect {
31
43
  occludedBy: string | null;
32
44
  /** Click-like only: the target was off-viewport, so it was scrolled into view before dispatch. */
33
45
  scrolledIntoView: boolean;
46
+ /**
47
+ * How long the pointer was ACTUALLY held down, for a click with `args.holdMs`. Absent otherwise.
48
+ *
49
+ * Measured, not echoed. `holdMs: 1200` against a 1200ms animation is a race by construction, so a
50
+ * caller needs to tell "held 1200" from "held 1204" — and a backgrounded tab throttles timers, so
51
+ * the achieved hold can legitimately be much longer than the one requested.
52
+ */
53
+ heldMs?: number;
34
54
  }
35
55
  interface ActionResult {
36
56
  ok: true;
@@ -62,6 +82,19 @@ interface ActionResult {
62
82
  /** Best-effort caveat the agent should heed (e.g. synthetic hover may not fire enter/leave). */
63
83
  warning?: string;
64
84
  }
85
+ /**
86
+ * Dispatch the action's events. Returns `defaultPrevented` of the primary cancelable event
87
+ * (false for actions whose primary event is non-cancelable or unobservable). Drag/hover-hold
88
+ * await internally; the probe wrapper builds the result once they resolve.
89
+ */
90
+ /**
91
+ * The longest a single action may hold the pointer down, in milliseconds.
92
+ *
93
+ * An unbounded `holdMs` is a tool call that never returns, which is the failure the transport layer
94
+ * is shaped around. 30s is far above any real hold-to-confirm (the reported case was 1.2s) and far
95
+ * below a hang.
96
+ */
97
+ export declare const MAX_HOLD_MS = 30000;
65
98
  /**
66
99
  * Execute a single action against a ref and probe for best-effort evidence of effect.
67
100
  * Always async: the MutationObserver read needs a microtask + rAF after dispatch.
@@ -5,6 +5,7 @@ import { getAccessibleName, getRole, isVisible, getStates } from '../dom/a11y.js
5
5
  import { elementHasHoverHandlers, identifyComponent } from '../registry/adapters.js';
6
6
  import { isForm, isHtmlElement, isInput, isSelect, isTextArea } from '../dom/realm.js';
7
7
  import { nativeSetTimeout, settle } from '../timers/native-timers.js';
8
+ import { AppearedText } from './appeared-text.js';
8
9
  function asString(value, fallback = '') {
9
10
  return 'string' === typeof value ? value : fallback;
10
11
  }
@@ -152,12 +153,38 @@ function activeRef(el) {
152
153
  * (false for actions whose primary event is non-cancelable or unobservable). Drag/hover-hold
153
154
  * await internally; the probe wrapper builds the result once they resolve.
154
155
  */
156
+ /**
157
+ * The longest a single action may hold the pointer down, in milliseconds.
158
+ *
159
+ * An unbounded `holdMs` is a tool call that never returns, which is the failure the transport layer
160
+ * is shaped around. 30s is far above any real hold-to-confirm (the reported case was 1.2s) and far
161
+ * below a hang.
162
+ */
163
+ export const MAX_HOLD_MS = 30_000;
164
+ /** A hold the caller asked for, bounded and sanitised. Non-numbers and negatives mean "no hold". */
165
+ function clampHold(raw) {
166
+ if ('number' !== typeof raw || !Number.isFinite(raw) || raw <= 0)
167
+ return 0;
168
+ return Math.min(raw, MAX_HOLD_MS);
169
+ }
170
+ /**
171
+ * Click is the only action that can hold the pointer down, so it is the only one that reports a
172
+ * duration. Everything else routes through the switch below unchanged and holds for zero.
173
+ */
155
174
  async function dispatchFor(el, action, args) {
175
+ if (ActionType.CLICK === action) {
176
+ // Full pointer/mouse sequence (not a bare click) so pointer- and focus-gated handlers fire the
177
+ // way they do for a real user.
178
+ //
179
+ // `args.holdMs` keeps the pointer down in between, which is the only way to drive a
180
+ // hold-to-confirm control. Capped: an unbounded hold is a tool call that never returns.
181
+ const hold = clampHold(args['holdMs']);
182
+ return await fireClickSequence(el, 0 === hold ? undefined : { ms: hold, sleep, now: () => Date.now() });
183
+ }
184
+ return { prevented: await dispatchOther(el, action, args), heldMs: 0 };
185
+ }
186
+ async function dispatchOther(el, action, args) {
156
187
  switch (action) {
157
- case ActionType.CLICK:
158
- // Full pointer/mouse sequence (not a bare click) so pointer- and focus-gated handlers fire
159
- // the way they do for a real user. Returns the click event's defaultPrevented.
160
- return fireClickSequence(el);
161
188
  case ActionType.DBLCLICK:
162
189
  return !el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
163
190
  case ActionType.HOVER: {
@@ -172,8 +199,9 @@ async function dispatchFor(el, action, args) {
172
199
  el.dispatchEvent(new MouseEvent('mouseenter', { relatedTarget: from }));
173
200
  firePointer(el, 'pointermove', from);
174
201
  const moved = el.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true }));
175
- // hover-dwell: keep "hovering" for holdMs so timer-gated reveals can mount.
176
- const holdMs = 'number' === typeof args['holdMs'] ? args['holdMs'] : 0;
202
+ // hover-dwell: keep "hovering" for holdMs so timer-gated reveals can mount. Same cap as
203
+ // click — this arg was previously unbounded here, which is the same never-returns hazard.
204
+ const holdMs = clampHold(args['holdMs']);
177
205
  if (holdMs > 0)
178
206
  await sleep(holdMs);
179
207
  return !moved;
@@ -182,13 +210,31 @@ async function dispatchFor(el, action, args) {
182
210
  el.focus();
183
211
  el.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
184
212
  return false; // FocusEvents are not cancelable.
185
- case ActionType.BLUR:
186
- // Fire a bubbling focusout so React 19's delegated root listener runs onBlur
187
- // (commit-on-blur). el.blur alone only works if the element was truly focused.
213
+ case ActionType.BLUR: {
214
+ // React 19 listens for `focusout` at the ROOT, so a commit-on-blur handler needs a BUBBLING
215
+ // event to reach it — and `el.blur()` is a no-op on an element that was never focused, which
216
+ // is why the synthetic pair exists at all.
217
+ //
218
+ // But on a FOCUSED element `el.blur()` already dispatches `blur` and a bubbling `focusout`
219
+ // natively. Dispatching them again meant React ran `onBlur` TWICE, and Reticle reported the
220
+ // resulting double write as a `duplicate-request` contradiction — a defect we invented and
221
+ // handed to a human as real. Reported from the field on React 19 + Vite with a single
222
+ // `onBlur={() => mutate(...)}`, one render site and no StrictMode.
223
+ //
224
+ // So synthesize ONLY when nothing native will fire. Read before the call, because `blur()` is
225
+ // what changes it — and read off the element's own ROOT: `document.activeElement` reports the
226
+ // shadow HOST for an element inside a shadow root, so an element that genuinely was focused
227
+ // would read as unfocused and take the double-dispatch path this exists to prevent. Both
228
+ // Document and ShadowRoot expose `activeElement`, so one expression covers both.
229
+ const root = el.getRootNode();
230
+ const wasFocused = root.activeElement === el;
188
231
  el.blur();
189
- el.dispatchEvent(new FocusEvent('blur'));
190
- el.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
232
+ if (!wasFocused) {
233
+ el.dispatchEvent(new FocusEvent('blur'));
234
+ el.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
235
+ }
191
236
  return false;
237
+ }
192
238
  case ActionType.FILL:
193
239
  if (isInput(el) || isTextArea(el)) {
194
240
  // A MISSING value is a malformed call, not a request to empty the field. `asString` defaults
@@ -323,8 +369,10 @@ export async function executeAction(ref, action, args = {}) {
323
369
  // mutation observer (scroll/hit-test never mutate the DOM, but keep the probe window clean).
324
370
  const geometry = CLICK_LIKE.has(action) ? clickGeometry(el) : NO_GEOMETRY;
325
371
  let mutated = 0;
372
+ const said = new AppearedText();
326
373
  const obs = new MutationObserver((records) => {
327
374
  mutated += records.length;
375
+ said.collect(records);
328
376
  });
329
377
  obs.observe(el.ownerDocument.documentElement, {
330
378
  subtree: true,
@@ -333,10 +381,14 @@ export async function executeAction(ref, action, args = {}) {
333
381
  characterData: true,
334
382
  });
335
383
  let defaultPrevented = false;
384
+ /** How long the pointer was actually held down. Zero for every action that cannot hold. */
385
+ let heldMs = 0;
336
386
  let settled = false;
337
387
  let settleReason = null;
338
388
  try {
339
- defaultPrevented = await dispatchFor(el, action, args);
389
+ const outcome = await dispatchFor(el, action, args);
390
+ defaultPrevented = outcome.prevented;
391
+ heldMs = outcome.heldMs;
340
392
  }
341
393
  finally {
342
394
  // bounded settle — microtask + one BOUNDED frame so React's commit (and the resulting DOM
@@ -359,9 +411,15 @@ export async function executeAction(ref, action, args = {}) {
359
411
  focusMoved: prevFocus !== nextFocus ? `${prevFocus ?? 'null'}->${nextFocus ?? 'null'}` : null,
360
412
  valueChanged: isFillLike(action) ? valueBefore !== valueAfter : false,
361
413
  domMutatedWithin: mutated,
414
+ // Omitted rather than "" when nothing was said — an empty string reads as "it said nothing
415
+ // meaningful", where absence says "it added no text at all". Those are different findings.
416
+ ...said.effect(valueAfter),
362
417
  occluded: geometry.occluded,
363
418
  occludedBy: geometry.occludedBy,
364
419
  scrolledIntoView: geometry.scrolledIntoView,
420
+ // Omitted when there was no hold: an absent key says "this action does not hold", where a 0
421
+ // would read as "it held for no time", which is a different and misleading claim.
422
+ ...(heldMs > 0 ? { heldMs } : {}),
365
423
  };
366
424
  // Honesty caveats: a visually-occluded click is reported even though synthetic dispatch landed;
367
425
  // else synthetic hover may not fire framework enter/leave handlers (no native hit-test).
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Gathers the text an action put on the page, from mutation records the observer already receives.
3
+ *
4
+ * `domMutatedWithin` counts records and throws their content away, so a failed login reports
5
+ * `ok / settled / mutated` and reads exactly like a successful one. The message the app rendered
6
+ * was passing through this callback the whole time.
7
+ *
8
+ * Deliberately reports what appeared, never what it means: no error/success classification, no
9
+ * guessing which fragment matters. Truncated, because a route change can add a whole page.
10
+ */
11
+ export declare class AppearedText {
12
+ #private;
13
+ collect(records: readonly MutationRecord[]): void;
14
+ /**
15
+ * `{ appeared }` when the APP added text, `{}` otherwise — an absent key means it added none.
16
+ *
17
+ * `wrote` is the value the action itself just set, and is excluded: a textarea carries its value
18
+ * in a child text node, so a controlled one re-rendering after the write mutates characterData
19
+ * with the caller's own string. Handing that back is noise wearing the name of evidence, and
20
+ * `valueChanged` already reports that the write landed. Exact-match only, so an app that quotes
21
+ * your input inside a sentence of its own ("No results for zzz") is still reported — that is
22
+ * the app talking.
23
+ */
24
+ effect(wrote?: string): {
25
+ appeared?: string;
26
+ };
27
+ }
@@ -0,0 +1,92 @@
1
+ import { isIgnored } from '../dom/dom-ignore.js';
2
+ /** Cap on the reported text. Enough for an error message, far short of a re-rendered page. */
3
+ const APPEARED_MAX = 200;
4
+ /** Separator between distinct fragments that appeared in the same window. */
5
+ const JOIN = ' | ';
6
+ const TEXT_NODE = 3;
7
+ /** Any letter, in any script — Latin, Cyrillic, CJK, Arabic. */
8
+ const HAS_LETTER = /\p{L}/u;
9
+ /**
10
+ * A fragment with no letters at all is not the app saying something.
11
+ *
12
+ * The Hostile fixture mutates a counter every 16ms, and clicking a button on that page reported
13
+ * `appeared: "409"` — the ticker, not the action's effect. A count-up animation is exactly what
14
+ * emits a bare number into the settle window, and a bare number carries no message a reader can
15
+ * act on. Deliberately conservative: it drops only fragments with NO letters, so "status 500",
16
+ * "3 items deleted" and "Could not save" all survive. An app whose only feedback is a naked
17
+ * numeral loses it here — the snapshot still shows it, and that is the cheaper mistake.
18
+ */
19
+ function saysSomething(text) {
20
+ return HAS_LETTER.test(text);
21
+ }
22
+ /**
23
+ * Gathers the text an action put on the page, from mutation records the observer already receives.
24
+ *
25
+ * `domMutatedWithin` counts records and throws their content away, so a failed login reports
26
+ * `ok / settled / mutated` and reads exactly like a successful one. The message the app rendered
27
+ * was passing through this callback the whole time.
28
+ *
29
+ * Deliberately reports what appeared, never what it means: no error/success classification, no
30
+ * guessing which fragment matters. Truncated, because a route change can add a whole page.
31
+ */
32
+ export class AppearedText {
33
+ #seen = new Set();
34
+ #length = 0;
35
+ collect(records) {
36
+ for (const record of records) {
37
+ if (this.#full())
38
+ return;
39
+ if ('characterData' === record.type) {
40
+ // The NEW value; oldValue is not requested, so this is what the reader now sees.
41
+ this.#add(record.target.textContent, record.target.parentElement);
42
+ continue;
43
+ }
44
+ for (const node of record.addedNodes) {
45
+ if (this.#full())
46
+ return;
47
+ const owner = TEXT_NODE === node.nodeType ? node.parentElement : elementOf(node);
48
+ this.#add(node.textContent, owner);
49
+ }
50
+ }
51
+ }
52
+ /**
53
+ * `{ appeared }` when the APP added text, `{}` otherwise — an absent key means it added none.
54
+ *
55
+ * `wrote` is the value the action itself just set, and is excluded: a textarea carries its value
56
+ * in a child text node, so a controlled one re-rendering after the write mutates characterData
57
+ * with the caller's own string. Handing that back is noise wearing the name of evidence, and
58
+ * `valueChanged` already reports that the write landed. Exact-match only, so an app that quotes
59
+ * your input inside a sentence of its own ("No results for zzz") is still reported — that is
60
+ * the app talking.
61
+ */
62
+ effect(wrote) {
63
+ const said = [...this.#seen].filter((text) => text !== wrote);
64
+ if (0 === said.length)
65
+ return {};
66
+ const joined = said.join(JOIN);
67
+ return {
68
+ appeared: joined.length > APPEARED_MAX ? `${joined.slice(0, APPEARED_MAX)}…` : joined,
69
+ };
70
+ }
71
+ #full() {
72
+ return this.#length > APPEARED_MAX;
73
+ }
74
+ #add(raw, owner) {
75
+ // Reticle's own overlay mutates constantly (the "live" panel); reporting it as the app's
76
+ // response would be worse than reporting nothing.
77
+ if (null !== owner && isIgnored(owner))
78
+ return;
79
+ const text = (raw ?? '').replace(/\s+/g, ' ').trim();
80
+ if (0 === text.length)
81
+ return;
82
+ if (!saysSomething(text))
83
+ return;
84
+ if (this.#seen.has(text))
85
+ return;
86
+ this.#seen.add(text);
87
+ this.#length += text.length + JOIN.length;
88
+ }
89
+ }
90
+ function elementOf(node) {
91
+ return node instanceof Element ? node : node.parentElement;
92
+ }
@@ -173,6 +173,19 @@ function readState(ref, store, path, depth) {
173
173
  }
174
174
  return result;
175
175
  }
176
+ /**
177
+ * Every animation, and WHICH element it drives.
178
+ *
179
+ * Reported from the field: a page with nine concurrent animations returned nine indistinguishable
180
+ * `{playState, currentTime, duration}` rows. `a.effect` was read only for `getTiming()`, never for
181
+ * its `target` — so the tool advertised "targets/timing" and shipped timing alone. A list you cannot
182
+ * index is not a list, and the question this tool exists to answer is "is THAT thing still
183
+ * animating".
184
+ *
185
+ * `describe()` is reused rather than inventing a target shape: it is what `reticle_query` returns,
186
+ * so the descriptor carries a `ref` the agent can pass straight to `reticle_act` — a target you
187
+ * cannot act on would be half an answer.
188
+ */
176
189
  function listAnimations() {
177
190
  const doc = document;
178
191
  if (typeof doc.getAnimations !== 'function')
@@ -180,10 +193,15 @@ function listAnimations() {
180
193
  const animations = doc.getAnimations().map((a) => {
181
194
  const effect = a.effect;
182
195
  const timing = effect?.getTiming();
196
+ // A KeyframeEffect's target is legitimately nullable, and only KeyframeEffect has one at all.
197
+ // `null` is reported explicitly: omitting the key would make "this animation has no element"
198
+ // indistinguishable from the bug above, which is the distinction the fix exists to draw.
199
+ const target = effect?.target ?? null;
183
200
  return {
184
201
  playState: a.playState,
185
202
  currentTime: a.currentTime,
186
203
  duration: timing?.duration,
204
+ target: null === target ? null : describe(target),
187
205
  };
188
206
  });
189
207
  return { animations };
package/dist/dom/query.js CHANGED
@@ -2,7 +2,7 @@ import { queryAllByRole, queryAllByText, queryAllByLabelText, queryAllByPlacehol
2
2
  import { DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
3
3
  import { isFrame, isHtmlElement } from './realm.js';
4
4
  import { capturedRootOf } from './shadow-registry.js';
5
- import { describe, getStates, isVisible } from './a11y.js';
5
+ import { getAccessibleName, getRole, describe, getStates, isVisible } from './a11y.js';
6
6
  import { isIgnored } from './dom-ignore.js';
7
7
  import { isSensitiveKey } from '../security/serialization.js';
8
8
  import { getCapabilities } from '../registry/capabilities.js';
@@ -83,6 +83,41 @@ function findByComponent(container, query) {
83
83
  }
84
84
  return [];
85
85
  }
86
+ /**
87
+ * Role + name, matched the way Reticle REPORTS them — not only the way Testing Library computes them.
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.
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.
110
+ */
111
+ 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);
120
+ }
86
121
  /** Run the appropriate Testing-Library query against ONE root (light DOM or a shadow root). */
87
122
  function findIn(container, query) {
88
123
  const by = query.by;
@@ -91,7 +126,7 @@ function findIn(container, query) {
91
126
  if (by !== undefined && value !== undefined) {
92
127
  switch (by) {
93
128
  case QueryBy.ROLE:
94
- return queryAllByRole(container, value, query.name !== undefined ? { hidden: true, name: query.name } : { hidden: true });
129
+ return queryByRoleAndName(container, value, query.name);
95
130
  case QueryBy.TEXT:
96
131
  return queryAllByText(container, value, { exact: false });
97
132
  case QueryBy.LABEL:
@@ -120,12 +155,10 @@ function findIn(container, query) {
120
155
  if (query.component !== undefined || query.source !== undefined) {
121
156
  return findByComponent(container, query);
122
157
  }
123
- // Structured form (role+name, or any single field).
158
+ // Structured form (role+name, or any single field). Same round-trip guarantee as the by+value
159
+ // spelling above — the two forms must not disagree about what is findable.
124
160
  if (query.role !== undefined) {
125
- const options = query.name !== undefined
126
- ? { hidden: true, name: query.name }
127
- : { hidden: true };
128
- return queryAllByRole(container, query.role, options);
161
+ return queryByRoleAndName(container, query.role, query.name);
129
162
  }
130
163
  if (query.text !== undefined)
131
164
  return queryAllByText(container, query.text, { exact: false });
@@ -20,6 +20,24 @@ const INTERACTIVE = new Set([
20
20
  'option',
21
21
  ]);
22
22
  const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'head', 'meta', 'link']);
23
+ /** Roles whose whole purpose is announcing a change; both imply an implicit aria-live. */
24
+ const ANNOUNCE_ROLES = new Set(['alert', 'status']);
25
+ /**
26
+ * Is this element the app SAYING something changed?
27
+ *
28
+ * INTERACTIVE mode keeps only actionable elements, which silently drops the one node that explains
29
+ * why an action did nothing — the error the app just rendered. An agent told to prefer the cheaper
30
+ * mode was structurally blind to the failure it had caused: the click reports settled with a DOM
31
+ * mutation, and the lean snapshot shows the same controls as before. Live regions exist precisely
32
+ * to announce state changes to a consumer that cannot see the screen, which is exactly the agent,
33
+ * so they are the principled exception — and a bounded one, since a page has very few.
34
+ */
35
+ function announces(el, role) {
36
+ if (ANNOUNCE_ROLES.has(role))
37
+ return true;
38
+ const live = el.getAttribute('aria-live');
39
+ return live !== null && 'off' !== live;
40
+ }
23
41
  /** Cap on inlined text content so a verbose node can't blow up the snapshot. */
24
42
  const TEXT_MAX = 80;
25
43
  /**
@@ -116,7 +134,7 @@ function pierceChildren(parent) {
116
134
  }
117
135
  return out;
118
136
  }
119
- function walk(parent, depth, ctx) {
137
+ function walk(parent, depth, ctx, inLive = false) {
120
138
  if (depth > ctx.maxDepth)
121
139
  return;
122
140
  for (const child of pierceChildren(parent)) {
@@ -135,9 +153,12 @@ function walk(parent, depth, ctx) {
135
153
  const role = getRole(child);
136
154
  const name = getAccessibleName(child);
137
155
  const interactive = INTERACTIVE.has(role);
156
+ // Announcements are exempt from leanness, and so is everything inside one: a live region whose
157
+ // message sits in a child element would otherwise be included as a contentless `- generic`.
158
+ const announce = inLive || announces(child, role);
159
+ const lean = ctx.mode === SnapshotMode.INTERACTIVE && !announce;
138
160
  // A generic, unnamed container's own text content — only consulted outside INTERACTIVE mode,
139
161
  // so the actionable-only view stays lean while FULL/meaningful views see content regressions.
140
- const lean = ctx.mode === SnapshotMode.INTERACTIVE;
141
162
  const text = !lean && 'generic' === role && 0 === name.length ? directText(child) : '';
142
163
  // Layout signature for grid/flex containers — makes CLS/layout regressions visible.
143
164
  const layout = lean ? '' : layoutSignature(style);
@@ -148,10 +169,10 @@ function walk(parent, depth, ctx) {
148
169
  ctx.lines.push(text.length > 0 && 0 === name.length && 0 === layout.length
149
170
  ? formatTextLine(depth, text)
150
171
  : formatLine(child, depth, role, name, layout));
151
- walk(child, depth + 1, ctx);
172
+ walk(child, depth + 1, ctx, announce);
152
173
  }
153
174
  else {
154
- walk(child, depth, ctx);
175
+ walk(child, depth, ctx, announce);
155
176
  }
156
177
  }
157
178
  }
@@ -109,6 +109,14 @@ export function resetClock() {
109
109
  //
110
110
  // So keep a translation map and a clear-shim for exactly as long as re-armed work is outstanding.
111
111
  // Nothing is dropped, and the app's handles keep working.
112
+ // Bound to `window`, not called off `natives`. A timer native invoked with any other receiver
113
+ // throws `TypeError: Illegal invocation` in a real browser — and `natives.setTimeout(...)` passes
114
+ // `natives`. jsdom does not enforce the receiver, so every unit test here passed while the browser
115
+ // threw on the one path that matters: re-arming work the app queued during the freeze. The early
116
+ // return above means it only fires when something IS pending, which is exactly when this function
117
+ // has a job to do. Caught by `bench/harness/clock-timetravel.mjs` driving real Chromium.
118
+ const nativeSetTimeout = natives.setTimeout.bind(window);
119
+ const nativeSetInterval = natives.setInterval.bind(window);
112
120
  const reArmed = new Map();
113
121
  const done = (virtualId) => {
114
122
  reArmed.delete(virtualId);
@@ -117,10 +125,10 @@ export function resetClock() {
117
125
  };
118
126
  for (const task of pending) {
119
127
  if (task.interval !== undefined) {
120
- reArmed.set(task.id, natives.setInterval(task.cb, task.interval));
128
+ reArmed.set(task.id, nativeSetInterval(task.cb, task.interval));
121
129
  }
122
130
  else {
123
- reArmed.set(task.id, natives.setTimeout(() => {
131
+ reArmed.set(task.id, nativeSetTimeout(() => {
124
132
  done(task.id);
125
133
  task.cb();
126
134
  }, Math.max(0, task.time - resumeFrom)));
@@ -130,15 +138,20 @@ export function resetClock() {
130
138
  }
131
139
  /** Route a clear() for a re-armed virtual id to its real native id; everything else passes through. */
132
140
  function installTranslatingClears(reArmed, natives, done) {
133
- const translate = (rawClear) => ((id) => {
134
- const nativeId = reArmed.get(id);
135
- if (nativeId === undefined) {
136
- rawClear(id);
137
- return;
138
- }
139
- rawClear(nativeId);
140
- done(id);
141
- });
141
+ // `.bind(window)` for the same reason as the re-arm above: a bare `rawClear(id)` reaches the DOM
142
+ // with the wrong receiver and throws `Illegal invocation` in a real browser.
143
+ const translate = (rawClear) => {
144
+ const clear = rawClear.bind(window);
145
+ return ((id) => {
146
+ const nativeId = reArmed.get(id);
147
+ if (nativeId === undefined) {
148
+ clear(id);
149
+ return;
150
+ }
151
+ clear(nativeId);
152
+ done(id);
153
+ });
154
+ };
142
155
  window.clearTimeout = translate(natives.clearTimeout);
143
156
  window.clearInterval = translate(natives.clearInterval);
144
157
  }
@@ -36,7 +36,30 @@ interface TransportDeps {
36
36
  * unsubscribe; injected so a test can drive visibility without a real document.
37
37
  */
38
38
  onVisible?: (handler: () => void) => () => void;
39
+ /**
40
+ * Schedule an internal retry (default: `nativeSetTimeout`). Injected for the same reason `now` and
41
+ * `onVisible` are — the default is bound to the real timer at module load so a frozen app clock
42
+ * cannot deadlock the SDK, which also means no fake clock can observe what was scheduled.
43
+ */
44
+ schedule?: (fn: () => void, ms: number) => void;
39
45
  }
46
+ /**
47
+ * The ceiling on reconnect backoff.
48
+ *
49
+ * A flat 1s retry with no growth and no cap meant a tab left open against a stopped daemon made
50
+ * ~600 failed WebSocket opens in ten minutes — CPU, and a devtools console full of
51
+ * ERR_CONNECTION_REFUSED. Backing off is only safe BECAUSE this transport also reconnects on
52
+ * focus/visibility, so a human returning to the tab retries immediately rather than waiting out
53
+ * the delay; the backoff resets on that path for the same reason.
54
+ */
55
+ export declare const RECONNECT_MAX_DELAY_MS = 30000;
56
+ /**
57
+ * The next reconnect delay: double, then clamp. Pure, and exported so the POLICY is testable —
58
+ * the scheduling itself runs on `nativeSetTimeout`, which is bound to the real timer at module load
59
+ * (deliberately, so a frozen `reticle_clock` cannot deadlock the SDK) and therefore cannot be driven
60
+ * by a fake clock.
61
+ */
62
+ export declare function nextReconnectDelay(previous: number): number;
40
63
  /** Offline queue cap. Exported so tests overflow the real bound instead of a mirrored copy of it. */
41
64
  export declare const MAX_QUEUE = 500;
42
65
  /** WebSocket client to the bridge. Reconnects across reloads; buffers events while down. */
@@ -1,4 +1,4 @@
1
- import { CommandMessageSchema, EventType, MessageKind, SESSION_LIFECYCLE, } from '@reticlehq/core';
1
+ import { CHURN_TYPES, CommandMessageSchema, EventType, MessageKind, SESSION_LIFECYCLE, } from '@reticlehq/core';
2
2
  import { nativeSetTimeout, nativeNow } from '../timers/native-timers.js';
3
3
  import { nativeWarn } from '../timers/native-console.js';
4
4
  import { safeStringify } from '../security/serialization.js';
@@ -13,7 +13,27 @@ function subscribeDocumentVisible(handler) {
13
13
  document.addEventListener('visibilitychange', listener);
14
14
  return () => document.removeEventListener('visibilitychange', listener);
15
15
  }
16
+ /** The first retry after a drop. Kept short: the common outage is a daemon restarting. */
16
17
  const RECONNECT_DELAY_MS = 1000;
18
+ /**
19
+ * The ceiling on reconnect backoff.
20
+ *
21
+ * A flat 1s retry with no growth and no cap meant a tab left open against a stopped daemon made
22
+ * ~600 failed WebSocket opens in ten minutes — CPU, and a devtools console full of
23
+ * ERR_CONNECTION_REFUSED. Backing off is only safe BECAUSE this transport also reconnects on
24
+ * focus/visibility, so a human returning to the tab retries immediately rather than waiting out
25
+ * the delay; the backoff resets on that path for the same reason.
26
+ */
27
+ export const RECONNECT_MAX_DELAY_MS = 30_000;
28
+ /**
29
+ * The next reconnect delay: double, then clamp. Pure, and exported so the POLICY is testable —
30
+ * the scheduling itself runs on `nativeSetTimeout`, which is bound to the real timer at module load
31
+ * (deliberately, so a frozen `reticle_clock` cannot deadlock the SDK) and therefore cannot be driven
32
+ * by a fake clock.
33
+ */
34
+ export function nextReconnectDelay(previous) {
35
+ return Math.min(previous * 2, RECONNECT_MAX_DELAY_MS);
36
+ }
17
37
  /** Offline queue cap. Exported so tests overflow the real bound instead of a mirrored copy of it. */
18
38
  export const MAX_QUEUE = 500;
19
39
  /**
@@ -29,10 +49,14 @@ const UNREACHABLE_WARN_AFTER = 3;
29
49
  /** WebSocket client to the bridge. Reconnects across reloads; buffers events while down. */
30
50
  export class Transport {
31
51
  #ws;
32
- #queue = [];
52
+ #churnQueue = [];
53
+ #signalQueue = [];
54
+ #insertOrder = 0;
33
55
  #closed = false;
34
56
  /** When the current continuous outage began (nativeNow), or undefined while connected. */
35
57
  #disconnectedSince;
58
+ /** Current reconnect delay; grows on each failure, resets on a successful open or on focus. */
59
+ #reconnectDelay = RECONNECT_DELAY_MS;
36
60
  /** Whether onConnectionLost has already fired for the current outage (fire-once). */
37
61
  #lost = false;
38
62
  /** True once the socket has opened at least once — gates the "unreachable" first-connect warning. */
@@ -72,7 +96,14 @@ export class Transport {
72
96
  * exists (connected or mid-connect), so we never open a duplicate racing the scheduled retry.
73
97
  */
74
98
  #onVisible() {
75
- if (this.#closed || this.#ws !== undefined)
99
+ if (this.#closed)
100
+ return;
101
+ // A human just came back to the tab, which is the strongest signal the outage may be over — and
102
+ // it is what makes a 30s ceiling safe. Reset the backoff BEFORE the mid-connect early return: if
103
+ // a socket is already in flight and then fails, that retry should be prompt too, rather than
104
+ // inheriting a delay earned while nobody was watching.
105
+ this.#reconnectDelay = RECONNECT_DELAY_MS;
106
+ if (this.#ws !== undefined)
76
107
  return;
77
108
  this.#open();
78
109
  }
@@ -90,8 +121,7 @@ export class Transport {
90
121
  // note the outage and schedule the normal retry.
91
122
  this.#noteOutage();
92
123
  this.#noteInitialFailure();
93
- if (!this.#closed)
94
- nativeSetTimeout(() => this.#reopen(), RECONNECT_DELAY_MS);
124
+ this.#scheduleReopen();
95
125
  return;
96
126
  }
97
127
  this.#ws = ws;
@@ -99,17 +129,21 @@ export class Transport {
99
129
  this.#disconnectedSince = undefined; // healthy again — reset the loss timer
100
130
  this.#lost = false;
101
131
  this.#everConnected = true; // a real connection happened ⇒ never warn "unreachable"
132
+ this.#reconnectDelay = RECONNECT_DELAY_MS; // recovered — the next drop starts prompt again
102
133
  // HELLO is SDK-owned schema data. Preserve its pairing token; the generic sanitizer
103
134
  // intentionally redacts fields named "token" from app-controlled payloads.
104
135
  const greeting = this.#deps.hello();
105
136
  this.#sessionId ??= greeting.sessionId;
106
137
  ws.send(JSON.stringify(greeting));
107
- // Declare any gap BEFORE replaying the backlog. The queue evicts its OLDEST entries, so the
108
- // hole always precedes everything that survived it.
138
+ // Declare any gap BEFORE replaying the backlog, stamped with the OLDEST event the queue
139
+ // dropped — see #sendRaw. The two queues are drained back into one stream in insertion order,
140
+ // so what the bridge receives is the order the page produced, minus the holes.
109
141
  this.#sendGapMarker(ws, greeting.sessionId);
110
- for (const msg of this.#queue)
142
+ const replay = [...this.#churnQueue, ...this.#signalQueue].sort((a, b) => a.order - b.order);
143
+ for (const msg of replay)
111
144
  ws.send(msg.text);
112
- this.#queue = [];
145
+ this.#churnQueue = [];
146
+ this.#signalQueue = [];
113
147
  this.#deps.onConnected?.();
114
148
  };
115
149
  ws.onmessage = (event) => {
@@ -131,8 +165,7 @@ export class Transport {
131
165
  }
132
166
  this.#noteOutage();
133
167
  this.#noteInitialFailure();
134
- if (!this.#closed)
135
- nativeSetTimeout(() => this.#reopen(), RECONNECT_DELAY_MS);
168
+ this.#scheduleReopen();
136
169
  };
137
170
  ws.onerror = () => {
138
171
  ws.close();
@@ -142,6 +175,20 @@ export class Transport {
142
175
  * A scheduled reconnect — skipped if the SDK was torn down, or if a foreground-triggered reconnect
143
176
  * already opened a socket (guard against the throttled timer racing #onVisible into a duplicate).
144
177
  */
178
+ /**
179
+ * Schedule the next retry and grow the delay. Nothing is scheduled once the SDK is torn down.
180
+ *
181
+ * The delay grows AFTER scheduling, so the first retry of any outage is prompt (a daemon
182
+ * restarting must not cost 30 seconds) and only a persistent outage backs off.
183
+ */
184
+ #scheduleReopen() {
185
+ if (this.#closed)
186
+ return;
187
+ const delay = this.#reconnectDelay;
188
+ this.#reconnectDelay = nextReconnectDelay(delay);
189
+ const schedule = this.#deps.schedule ?? nativeSetTimeout;
190
+ schedule(() => this.#reopen(), delay);
191
+ }
145
192
  #reopen() {
146
193
  if (this.#closed || this.#ws !== undefined)
147
194
  return;
@@ -241,6 +288,7 @@ export class Transport {
241
288
  this.#sendRaw(safeStringify({ kind: MessageKind.EVENT, event }), {
242
289
  seq: event.seq,
243
290
  t: event.t,
291
+ type: event.type,
244
292
  });
245
293
  }
246
294
  /**
@@ -262,19 +310,31 @@ export class Transport {
262
310
  this.#ws.send(text);
263
311
  return;
264
312
  }
265
- if (this.#queue.length >= MAX_QUEUE) {
266
- // Full offline queue: drop the OLDEST and keep the newest (ring), so after reconnect the agent
267
- // replays RECENT activity instead of 500 stale events with the latest lost. Remember what went
268
- // so the loss can be declared on reconnect — an unmarked hole reads as "the app did nothing".
269
- // Only an evicted EVENT counts: a dropped COMMAND_RESULT is a reply the agent already gave up
270
- // waiting for, not a hole in what the app was observed to do.
271
- const evicted = this.#queue.shift();
313
+ if (this.#churnQueue.length + this.#signalQueue.length >= MAX_QUEUE) {
314
+ // Prefer sacrificing churn (high-volume, low-signal) over signal events. Falls back to signal
315
+ // FIFO only when the churn queue is empty — the buffer is bounded either way.
316
+ const evicted = this.#churnQueue.length > 0 ? this.#churnQueue.shift() : this.#signalQueue.shift();
272
317
  if (evicted?.event !== undefined) {
273
318
  this.#dropped += 1;
274
- this.#gap = evicted.event;
319
+ // Keep the EARLIEST drop, not the latest. Under plain FIFO the two were the same thing, so
320
+ // "latest evicted" was a precise boundary: everything before it was gone. Churn-first
321
+ // eviction breaks that — a churn event newer than a surviving signal event can be the one
322
+ // sacrificed, and stamping the marker with it would claim a hole that swallows events the
323
+ // agent is about to receive. The oldest drop is the honest floor: the hole starts no later
324
+ // than here. A truncation marker may over-state its reach; it must never under-state it.
325
+ const previous = this.#gap;
326
+ if (previous === undefined || evicted.event.t < previous.t)
327
+ this.#gap = evicted.event;
275
328
  }
276
329
  }
277
- this.#queue.push({ text, event });
330
+ const isChurn = event !== undefined && CHURN_TYPES.has(event.type);
331
+ const order = this.#insertOrder++;
332
+ if (isChurn) {
333
+ this.#churnQueue.push({ text, event, order });
334
+ }
335
+ else {
336
+ this.#signalQueue.push({ text, event, order });
337
+ }
278
338
  }
279
339
  /**
280
340
  * Tell the bridge the offline queue swallowed events, then consider the gap declared.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/browser",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Reticle browser SDK — installs observers, builds semantic snapshots, executes actions, talks to the bridge.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@testing-library/dom": "^10.4.1",
17
- "@reticlehq/core": "2.5.0"
17
+ "@reticlehq/core": "2.6.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@tanstack/query-core": "^5.101.4",