@reticlehq/browser 2.5.0 → 2.7.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
@@ -23,6 +30,10 @@ export declare function firePointerNonBubbling(el: Element, type: string, relate
23
30
  /**
24
31
  * Pointer-based drag (dnd-kit / react-beautiful-dnd) + best-effort HTML5 DnD. Async: yields a
25
32
  * frame between phases so React commits state between steps (fixes stale-closure handlers).
33
+ *
34
+ * Dispatches along a PATH with real client coordinates and a held button. Without those a
35
+ * geometry-based library resolves the drop target as the source, so the action reports success over
36
+ * an app that did not change — a false green produced by the tool rather than caught by it.
26
37
  */
27
38
  export declare function dragElement(source: HTMLElement, target: HTMLElement | null, data: unknown): Promise<boolean>;
28
39
  export {};
@@ -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) {
@@ -95,28 +109,77 @@ function makeDataTransfer(data) {
95
109
  }
96
110
  return dt;
97
111
  }
112
+ /** `buttons` bitmask for the primary button: 1 while held, 0 once released. */
113
+ const BUTTON_HELD = 1;
114
+ const BUTTON_RELEASED = 0;
115
+ /**
116
+ * How many intermediate moves a drag emits.
117
+ *
118
+ * Enough to cross any realistic `activationConstraint: { distance: N }` while a sensor is watching,
119
+ * and few enough that a drag stays a handful of frames rather than an animation.
120
+ */
121
+ const DRAG_STEPS = 5;
122
+ /** The centre of an element in client coordinates — what a real pointer would be over. */
123
+ function centreOf(el) {
124
+ const box = el.getBoundingClientRect();
125
+ return { x: box.left + box.width / 2, y: box.top + box.height / 2 };
126
+ }
127
+ /** A point `t` of the way from `a` to `b`, rounded — pointer coordinates are integers in practice. */
128
+ function lerp(a, b, t) {
129
+ return { x: Math.round(a.x + (b.x - a.x) * t), y: Math.round(a.y + (b.y - a.y) * t) };
130
+ }
98
131
  /**
99
132
  * Pointer-based drag (dnd-kit / react-beautiful-dnd) + best-effort HTML5 DnD. Async: yields a
100
133
  * frame between phases so React commits state between steps (fixes stale-closure handlers).
134
+ *
135
+ * Dispatches along a PATH with real client coordinates and a held button. Without those a
136
+ * geometry-based library resolves the drop target as the source, so the action reports success over
137
+ * an app that did not change — a false green produced by the tool rather than caught by it.
101
138
  */
102
139
  export async function dragElement(source, target, data) {
103
140
  const dest = target ?? source;
104
- const fire = (el, type) => {
141
+ const from = centreOf(source);
142
+ const to = centreOf(dest);
143
+ /**
144
+ * Dispatch ONE pointer/mouse pair with real coordinates and button state.
145
+ *
146
+ * Everything here was missing before, and each omission breaks a different, standard library
147
+ * pattern: without coordinates a geometry-based collision resolver sees a zero delta and reports
148
+ * the source as its own drop target; without `buttons: 1` the usual "was the mouse released?"
149
+ * guard (`event.buttons === 0`) bails out mid-drag.
150
+ */
151
+ const fire = (el, type, at, buttons) => {
152
+ const init = {
153
+ bubbles: true,
154
+ cancelable: true,
155
+ clientX: at.x,
156
+ clientY: at.y,
157
+ screenX: at.x,
158
+ screenY: at.y,
159
+ buttons,
160
+ button: 0,
161
+ };
105
162
  if ('function' === typeof PointerEvent && type.startsWith('pointer')) {
106
- el.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true }));
163
+ el.dispatchEvent(new PointerEvent(type, { ...init, pointerId: 1, isPrimary: true }));
107
164
  }
108
165
  else {
109
- el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
166
+ el.dispatchEvent(new MouseEvent(type, init));
110
167
  }
111
168
  };
112
- fire(source, 'pointerdown');
113
- fire(source, 'mousedown');
114
- await nativeFrame();
115
- fire(dest, 'pointermove');
116
- fire(dest, 'mousemove');
169
+ fire(source, 'pointerdown', from, BUTTON_HELD);
170
+ fire(source, 'mousedown', from, BUTTON_HELD);
117
171
  await nativeFrame();
118
- fire(dest, 'pointerup');
119
- fire(dest, 'mouseup');
172
+ // A path, not a jump. `activationConstraint: { distance: N }` is the standard way to keep a
173
+ // draggable card clickable, and a sensor only starts a drag once it has SEEN the pointer travel
174
+ // that far — which a single move from A to B never shows it.
175
+ for (let step = 1; step <= DRAG_STEPS; step += 1) {
176
+ const at = lerp(from, to, step / DRAG_STEPS);
177
+ fire(dest, 'pointermove', at, BUTTON_HELD);
178
+ fire(dest, 'mousemove', at, BUTTON_HELD);
179
+ await nativeFrame();
180
+ }
181
+ fire(dest, 'pointerup', to, BUTTON_RELEASED);
182
+ fire(dest, 'mouseup', to, BUTTON_RELEASED);
120
183
  let dropPrevented = false;
121
184
  if ('function' === typeof DragEvent) {
122
185
  const dataTransfer = makeDataTransfer(data);
@@ -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
  }
@@ -111,13 +112,112 @@ function dangerousActionContext(el) {
111
112
  function requiresDangerousConfirmation(text) {
112
113
  return isDangerousActionText(text);
113
114
  }
115
+ /**
116
+ * Which key a `press` is asking for.
117
+ *
118
+ * `text` FIRST, because that is what the tool description documents ("{ text } for type/press") and
119
+ * therefore what agents send. The implementation read only `args['key']` and defaulted to `'Enter'`,
120
+ * so the documented call — `press` with `{ text: 'Escape' }` — silently dispatched **Enter** and
121
+ * reported success. Two field reports blamed "synthetic events not reaching the app"; the events
122
+ * arrived perfectly and said the wrong thing.
123
+ *
124
+ * Enter is the worst possible substitute to pick by accident: on a focused field inside a form it
125
+ * SUBMITS it, so a request to dismiss a dialog could file the form behind it. `key` still works —
126
+ * it is what anyone reading the source would have sent — and the default only applies when neither
127
+ * was named.
128
+ */
129
+ function pressKey(args) {
130
+ const text = args['text'];
131
+ if ('string' === typeof text && text.length > 0)
132
+ return text;
133
+ return asString(args['key'], 'Enter');
134
+ }
135
+ /**
136
+ * The PHYSICAL key identity — `event.code` — derived from the logical key.
137
+ *
138
+ * A real browser always sends both, and a meaningful class of library reads only `code`, because it
139
+ * is the layout-independent one: dnd-kit's KeyboardSensor matches its activation and arrow keys on
140
+ * it, and so does react-aria. We sent `key` alone, so those handlers simply never matched — the
141
+ * event fired, the listener ran, the guard failed, and the action reported dispatched over an app
142
+ * that did nothing. Reported from the field as a keyboard drag that could be neither started nor
143
+ * steered.
144
+ *
145
+ * An explicit `code` always wins: a caller driving a non-US layout knows something this derivation
146
+ * cannot. And an unrecognised multi-character key yields `''` rather than a guess — a wrong `code`
147
+ * is worse than none, because a handler will act on it.
148
+ */
149
+ function pressCode(args, key) {
150
+ const explicit = args['code'];
151
+ if ('string' === typeof explicit && explicit.length > 0)
152
+ return explicit;
153
+ if (' ' === key)
154
+ return 'Space';
155
+ if (1 === key.length) {
156
+ if (/[a-z]/i.test(key))
157
+ return `Key${key.toUpperCase()}`;
158
+ if (/[0-9]/.test(key))
159
+ return `Digit${key}`;
160
+ return '';
161
+ }
162
+ // Named keys — 'Enter', 'Escape', 'Tab', 'ArrowDown' — already ARE their own code.
163
+ return /^[A-Z][A-Za-z0-9]*$/.test(key) && KNOWN_NAMED_KEYS.has(key) ? key : '';
164
+ }
165
+ /**
166
+ * Named keys whose `code` equals their `key`. An allow-list rather than a shape test: `Zzz` looks
167
+ * exactly like `Tab` to a regex, and inventing `code: "Zzz"` would be a confident fabrication.
168
+ */
169
+ const KNOWN_NAMED_KEYS = new Set([
170
+ 'Enter',
171
+ 'Escape',
172
+ 'Tab',
173
+ 'Backspace',
174
+ 'Delete',
175
+ 'Home',
176
+ 'End',
177
+ 'PageUp',
178
+ 'PageDown',
179
+ 'ArrowUp',
180
+ 'ArrowDown',
181
+ 'ArrowLeft',
182
+ 'ArrowRight',
183
+ 'Insert',
184
+ 'F1',
185
+ 'F2',
186
+ 'F3',
187
+ 'F4',
188
+ 'F5',
189
+ 'F6',
190
+ 'F7',
191
+ 'F8',
192
+ 'F9',
193
+ 'F10',
194
+ 'F11',
195
+ 'F12',
196
+ ]);
197
+ /**
198
+ * The drop target a `drag` names, under either spelling.
199
+ *
200
+ * `toRef` is what the source has always read and it appears NOWHERE in the tool description an
201
+ * agent sees — the `args` sentence names `value`, `text`, `native`, `holdMs` and `confirmDangerous`
202
+ * and omits the one argument without which this action cannot do its job. So agents guess, and
203
+ * `target` is the guess they make. Accepting it costs nothing and removes a whole class of
204
+ * silently-target-less drags; the description now documents `toRef` as well.
205
+ */
206
+ function dragTargetRef(args) {
207
+ const named = asString(args['toRef']);
208
+ return '' !== named ? named : asString(args['target']);
209
+ }
114
210
  function assertActionAllowed(el, action, args) {
115
211
  const canTrigger = action === ActionType.CLICK ||
116
212
  action === ActionType.DBLCLICK ||
117
213
  action === ActionType.DRAG ||
118
214
  action === ActionType.SUBMIT ||
119
- (action === ActionType.PRESS && 'Enter' === asString(args['key'], 'Enter'));
120
- const dragTarget = action === ActionType.DRAG ? refs.resolve(asString(args['toRef'])) : null;
215
+ // Read through the SAME resolver as the dispatch below. Reading a different argument here meant
216
+ // the destructive-action guard classified the call by a key nobody had asked for.
217
+ (action === ActionType.PRESS && 'Enter' === pressKey(args));
218
+ // Same resolver as the dispatch below, so the destructive-action guard cannot classify a drag
219
+ // by a target the dispatch will not use.
220
+ const dragTarget = action === ActionType.DRAG ? refs.resolve(dragTargetRef(args)) : null;
121
221
  const context = isHtmlElement(dragTarget)
122
222
  ? `${dangerousActionContext(el)} ${dangerousActionContext(dragTarget)}`
123
223
  : dangerousActionContext(el);
@@ -152,12 +252,38 @@ function activeRef(el) {
152
252
  * (false for actions whose primary event is non-cancelable or unobservable). Drag/hover-hold
153
253
  * await internally; the probe wrapper builds the result once they resolve.
154
254
  */
255
+ /**
256
+ * The longest a single action may hold the pointer down, in milliseconds.
257
+ *
258
+ * An unbounded `holdMs` is a tool call that never returns, which is the failure the transport layer
259
+ * is shaped around. 30s is far above any real hold-to-confirm (the reported case was 1.2s) and far
260
+ * below a hang.
261
+ */
262
+ export const MAX_HOLD_MS = 30_000;
263
+ /** A hold the caller asked for, bounded and sanitised. Non-numbers and negatives mean "no hold". */
264
+ function clampHold(raw) {
265
+ if ('number' !== typeof raw || !Number.isFinite(raw) || raw <= 0)
266
+ return 0;
267
+ return Math.min(raw, MAX_HOLD_MS);
268
+ }
269
+ /**
270
+ * Click is the only action that can hold the pointer down, so it is the only one that reports a
271
+ * duration. Everything else routes through the switch below unchanged and holds for zero.
272
+ */
155
273
  async function dispatchFor(el, action, args) {
274
+ if (ActionType.CLICK === action) {
275
+ // Full pointer/mouse sequence (not a bare click) so pointer- and focus-gated handlers fire the
276
+ // way they do for a real user.
277
+ //
278
+ // `args.holdMs` keeps the pointer down in between, which is the only way to drive a
279
+ // hold-to-confirm control. Capped: an unbounded hold is a tool call that never returns.
280
+ const hold = clampHold(args['holdMs']);
281
+ return await fireClickSequence(el, 0 === hold ? undefined : { ms: hold, sleep, now: () => Date.now() });
282
+ }
283
+ return { prevented: await dispatchOther(el, action, args), heldMs: 0 };
284
+ }
285
+ async function dispatchOther(el, action, args) {
156
286
  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
287
  case ActionType.DBLCLICK:
162
288
  return !el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
163
289
  case ActionType.HOVER: {
@@ -172,8 +298,9 @@ async function dispatchFor(el, action, args) {
172
298
  el.dispatchEvent(new MouseEvent('mouseenter', { relatedTarget: from }));
173
299
  firePointer(el, 'pointermove', from);
174
300
  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;
301
+ // hover-dwell: keep "hovering" for holdMs so timer-gated reveals can mount. Same cap as
302
+ // click — this arg was previously unbounded here, which is the same never-returns hazard.
303
+ const holdMs = clampHold(args['holdMs']);
177
304
  if (holdMs > 0)
178
305
  await sleep(holdMs);
179
306
  return !moved;
@@ -182,13 +309,31 @@ async function dispatchFor(el, action, args) {
182
309
  el.focus();
183
310
  el.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
184
311
  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.
312
+ case ActionType.BLUR: {
313
+ // React 19 listens for `focusout` at the ROOT, so a commit-on-blur handler needs a BUBBLING
314
+ // event to reach it — and `el.blur()` is a no-op on an element that was never focused, which
315
+ // is why the synthetic pair exists at all.
316
+ //
317
+ // But on a FOCUSED element `el.blur()` already dispatches `blur` and a bubbling `focusout`
318
+ // natively. Dispatching them again meant React ran `onBlur` TWICE, and Reticle reported the
319
+ // resulting double write as a `duplicate-request` contradiction — a defect we invented and
320
+ // handed to a human as real. Reported from the field on React 19 + Vite with a single
321
+ // `onBlur={() => mutate(...)}`, one render site and no StrictMode.
322
+ //
323
+ // So synthesize ONLY when nothing native will fire. Read before the call, because `blur()` is
324
+ // what changes it — and read off the element's own ROOT: `document.activeElement` reports the
325
+ // shadow HOST for an element inside a shadow root, so an element that genuinely was focused
326
+ // would read as unfocused and take the double-dispatch path this exists to prevent. Both
327
+ // Document and ShadowRoot expose `activeElement`, so one expression covers both.
328
+ const root = el.getRootNode();
329
+ const wasFocused = root.activeElement === el;
188
330
  el.blur();
189
- el.dispatchEvent(new FocusEvent('blur'));
190
- el.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
331
+ if (!wasFocused) {
332
+ el.dispatchEvent(new FocusEvent('blur'));
333
+ el.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
334
+ }
191
335
  return false;
336
+ }
192
337
  case ActionType.FILL:
193
338
  if (isInput(el) || isTextArea(el)) {
194
339
  // A MISSING value is a malformed call, not a request to empty the field. `asString` defaults
@@ -258,13 +403,39 @@ async function dispatchFor(el, action, args) {
258
403
  }
259
404
  throw new Error(`cannot select on a <${el.tagName.toLowerCase()}>`);
260
405
  case ActionType.CHECK:
261
- case ActionType.UNCHECK:
262
- if (isInput(el)) {
263
- el.checked = action === ActionType.CHECK;
264
- el.dispatchEvent(new Event('change', { bubbles: true }));
406
+ case ActionType.UNCHECK: {
407
+ if (!isInput(el))
408
+ throw new Error(`cannot (un)check a <${el.tagName.toLowerCase()}>`);
409
+ // Same rule as the readonly/disabled refusal on fill: if a real user could not do it, forcing
410
+ // it is not a test, it is damage — and a green over it is a false one.
411
+ if (!enabledOf(el)) {
412
+ throw new Error(`cannot ${action} a disabled control — a real user could not, so neither will Reticle`);
413
+ }
414
+ const wanted = action === ActionType.CHECK;
415
+ // Already there: report success and touch nothing. `check` means "end up checked", not
416
+ // "toggle", so clicking here would flip it OFF and hand the app an event no user produced.
417
+ if (el.checked === wanted)
265
418
  return false;
419
+ // `click()`, never `el.checked = …`. Assigning the property flips the pixel and tells no
420
+ // framework: React binds a checkbox's onChange to the CLICK event, and its value tracker
421
+ // dedups the change it would otherwise synthesise from a direct assignment — so a controlled
422
+ // `checked={state}` box never heard from us while the action reported success. `click()` is
423
+ // the one DOM call that runs the element's ACTIVATION BEHAVIOUR (the native toggle) as well
424
+ // as firing the event; `dispatchEvent` does not.
425
+ let prevented = false;
426
+ // On window, so it runs after every listener on the element itself whenever they were added.
427
+ const probe = (e) => {
428
+ prevented = e.defaultPrevented;
429
+ };
430
+ window.addEventListener('click', probe, { once: true, capture: false });
431
+ try {
432
+ el.click();
433
+ }
434
+ finally {
435
+ window.removeEventListener('click', probe, false);
266
436
  }
267
- throw new Error(`cannot (un)check a <${el.tagName.toLowerCase()}>`);
437
+ return prevented;
438
+ }
268
439
  case ActionType.SUBMIT: {
269
440
  const form = isForm(el) ? el : el.closest('form');
270
441
  if (null === form)
@@ -273,9 +444,10 @@ async function dispatchFor(el, action, args) {
273
444
  return false; // requestSubmit returns void; the internal submit event is unobservable.
274
445
  }
275
446
  case ActionType.PRESS: {
276
- const key = asString(args['key'], 'Enter');
277
- const down = el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
278
- el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
447
+ const key = pressKey(args);
448
+ const code = pressCode(args, key);
449
+ const down = el.dispatchEvent(new KeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true }));
450
+ el.dispatchEvent(new KeyboardEvent('keyup', { key, code, bubbles: true }));
279
451
  return !down;
280
452
  }
281
453
  case ActionType.SCROLL_INTO_VIEW:
@@ -296,11 +468,21 @@ async function dispatchFor(el, action, args) {
296
468
  return !inputOk;
297
469
  }
298
470
  case ActionType.DRAG: {
299
- const toRef = asString(args['toRef']);
471
+ const toRef = dragTargetRef(args);
300
472
  // asString falls back to '' (never undefined), so the old `!== undefined` was always true and
301
- // handed '' to refs.resolve. A drag with no target ref is a free drag — resolve nothing.
302
- const resolved = toRef !== '' ? refs.resolve(toRef) : null;
303
- return await dragElement(el, isHtmlElement(resolved) ? resolved : null, args['data']);
473
+ // handed '' to refs.resolve. A drag with NO target named is a free drag — resolve nothing.
474
+ if ('' === toRef)
475
+ return await dragElement(el, null, args['data']);
476
+ // A target WAS named and did not resolve. That is a malformed call, not a free drag: dragging
477
+ // nowhere and reporting `ok: true` is a false green, and it is the one this action produced
478
+ // in the field — the drop target argument is undocumented, the agent guessed a name, the guess
479
+ // read as "no target", and the drag landed nowhere while every effect field looked healthy.
480
+ const resolved = refs.resolve(toRef);
481
+ if (!isHtmlElement(resolved)) {
482
+ throw new Error(`drag target '${echoRef(toRef)}' did not resolve to an element — pass a ref from ` +
483
+ 'reticle_snapshot or reticle_query as args.toRef (alias: args.target)');
484
+ }
485
+ return await dragElement(el, resolved, args['data']);
304
486
  }
305
487
  default:
306
488
  throw new Error(`unknown action '${action}'`);
@@ -323,8 +505,10 @@ export async function executeAction(ref, action, args = {}) {
323
505
  // mutation observer (scroll/hit-test never mutate the DOM, but keep the probe window clean).
324
506
  const geometry = CLICK_LIKE.has(action) ? clickGeometry(el) : NO_GEOMETRY;
325
507
  let mutated = 0;
508
+ const said = new AppearedText();
326
509
  const obs = new MutationObserver((records) => {
327
510
  mutated += records.length;
511
+ said.collect(records);
328
512
  });
329
513
  obs.observe(el.ownerDocument.documentElement, {
330
514
  subtree: true,
@@ -333,10 +517,14 @@ export async function executeAction(ref, action, args = {}) {
333
517
  characterData: true,
334
518
  });
335
519
  let defaultPrevented = false;
520
+ /** How long the pointer was actually held down. Zero for every action that cannot hold. */
521
+ let heldMs = 0;
336
522
  let settled = false;
337
523
  let settleReason = null;
338
524
  try {
339
- defaultPrevented = await dispatchFor(el, action, args);
525
+ const outcome = await dispatchFor(el, action, args);
526
+ defaultPrevented = outcome.prevented;
527
+ heldMs = outcome.heldMs;
340
528
  }
341
529
  finally {
342
530
  // bounded settle — microtask + one BOUNDED frame so React's commit (and the resulting DOM
@@ -359,9 +547,15 @@ export async function executeAction(ref, action, args = {}) {
359
547
  focusMoved: prevFocus !== nextFocus ? `${prevFocus ?? 'null'}->${nextFocus ?? 'null'}` : null,
360
548
  valueChanged: isFillLike(action) ? valueBefore !== valueAfter : false,
361
549
  domMutatedWithin: mutated,
550
+ // Omitted rather than "" when nothing was said — an empty string reads as "it said nothing
551
+ // meaningful", where absence says "it added no text at all". Those are different findings.
552
+ ...said.effect(valueAfter),
362
553
  occluded: geometry.occluded,
363
554
  occludedBy: geometry.occludedBy,
364
555
  scrolledIntoView: geometry.scrolledIntoView,
556
+ // Omitted when there was no hold: an absent key says "this action does not hold", where a 0
557
+ // would read as "it held for no time", which is a different and misleading claim.
558
+ ...(heldMs > 0 ? { heldMs } : {}),
365
559
  };
366
560
  // Honesty caveats: a visually-occluded click is reported even though synthetic dispatch landed;
367
561
  // 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
+ }
@@ -48,6 +48,11 @@ function queryFromArgs(args) {
48
48
  alt: str(args['alt']),
49
49
  component: str(args['component']),
50
50
  scope: str(args['scope']),
51
+ // The THIRD allowlist a query input has to appear in — tool schema, server forward, and here.
52
+ // `self` was in the first two and missing from this one, so a live call returned zero matches on
53
+ // an element that was plainly on the page, with no error to explain it. Same shape as the
54
+ // `attrs` drop below it.
55
+ self: true === args['self'] ? true : undefined,
51
56
  attrs: Array.isArray(args['attrs'])
52
57
  ? args['attrs'].filter((a) => 'string' === typeof a)
53
58
  : undefined,
@@ -173,6 +178,19 @@ function readState(ref, store, path, depth) {
173
178
  }
174
179
  return result;
175
180
  }
181
+ /**
182
+ * Every animation, and WHICH element it drives.
183
+ *
184
+ * Reported from the field: a page with nine concurrent animations returned nine indistinguishable
185
+ * `{playState, currentTime, duration}` rows. `a.effect` was read only for `getTiming()`, never for
186
+ * its `target` — so the tool advertised "targets/timing" and shipped timing alone. A list you cannot
187
+ * index is not a list, and the question this tool exists to answer is "is THAT thing still
188
+ * animating".
189
+ *
190
+ * `describe()` is reused rather than inventing a target shape: it is what `reticle_query` returns,
191
+ * so the descriptor carries a `ref` the agent can pass straight to `reticle_act` — a target you
192
+ * cannot act on would be half an answer.
193
+ */
176
194
  function listAnimations() {
177
195
  const doc = document;
178
196
  if (typeof doc.getAnimations !== 'function')
@@ -180,10 +198,15 @@ function listAnimations() {
180
198
  const animations = doc.getAnimations().map((a) => {
181
199
  const effect = a.effect;
182
200
  const timing = effect?.getTiming();
201
+ // A KeyframeEffect's target is legitimately nullable, and only KeyframeEffect has one at all.
202
+ // `null` is reported explicitly: omitting the key would make "this animation has no element"
203
+ // indistinguishable from the bug above, which is the distinction the fix exists to draw.
204
+ const target = effect?.target ?? null;
183
205
  return {
184
206
  playState: a.playState,
185
207
  currentTime: a.currentTime,
186
208
  duration: timing?.duration,
209
+ target: null === target ? null : describe(target),
187
210
  };
188
211
  });
189
212
  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 });
@@ -226,6 +259,16 @@ function findCandidates(query) {
226
259
  // scopeMissing flag is what keeps "gone scope" distinct from "absent element".
227
260
  if (null === container)
228
261
  return { candidates: [], scopeMissing: true };
262
+ // `self: true` asks for the container ITSELF, which every other path excludes by construction.
263
+ // A layout element with no role, name, testid or own text is unreachable by any semantic locator,
264
+ // and it is routinely the element carrying the handler — "click the empty space in this row" is a
265
+ // real user action that was simply not expressible. Requires a scope: without one there is no root
266
+ // to return, and answering `document.body` would be a wrong answer wearing the shape of one.
267
+ if (true === query.self) {
268
+ if (query.scope === undefined)
269
+ return { candidates: [], scopeMissing };
270
+ return { candidates: isIgnored(container) ? [] : [container], scopeMissing };
271
+ }
229
272
  const seen = new Set();
230
273
  const out = [];
231
274
  const collect = (els) => {
@@ -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
  }
@@ -449,6 +449,10 @@ export function installNetwork(emit, opts = {}) {
449
449
  });
450
450
  });
451
451
  }
452
+ // `override` is required, not decorative: this shadows WebSocket.prototype.send, and without
453
+ // the keyword a rename or signature drift in the base class would silently turn this from an
454
+ // override into a NEW method — the observer would stop intercepting and report no WebSocket
455
+ // traffic at all, which reads as "the app sends none". Caught by noImplicitOverride.
452
456
  send(data) {
453
457
  if (this.#isBridge) {
454
458
  super.send(data);
package/dist/reticle.js CHANGED
@@ -160,7 +160,7 @@ export class Reticle {
160
160
  // Dev-only backstop: refuse to activate in a production build (SSR healthcheck, prod bundle opened
161
161
  // on localhost). `process` may not exist in a raw browser, so read NODE_ENV off globalThis.
162
162
  const proc = globalThis.process;
163
- const nodeEnv = proc?.env?.NODE_ENV;
163
+ const nodeEnv = proc?.env?.['NODE_ENV'];
164
164
  if (shouldBlockProduction(nodeEnv, true === options.allowInProduction)) {
165
165
  globalThis.console.warn('[Reticle] disabled in production (NODE_ENV=production). Gate the import behind ' +
166
166
  'import.meta.env.DEV, or pass allowInProduction:true to override.');
@@ -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.7.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.7.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@tanstack/query-core": "^5.101.4",