@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.
- package/dist/actions/actions-dom.d.ts +12 -1
- package/dist/actions/actions-dom.js +75 -12
- package/dist/actions/actions.d.ts +33 -0
- package/dist/actions/actions.js +220 -26
- package/dist/actions/appeared-text.d.ts +27 -0
- package/dist/actions/appeared-text.js +92 -0
- package/dist/commands/commands.js +23 -0
- package/dist/dom/query.js +50 -7
- package/dist/dom/snapshot.js +25 -4
- package/dist/observers/network.js +4 -0
- package/dist/reticle.js +1 -1
- package/dist/timers/clock.js +24 -11
- package/dist/transport/transport.d.ts +23 -0
- package/dist/transport/transport.js +80 -20
- package/package.json +2 -2
|
@@ -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
|
|
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
|
|
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, {
|
|
163
|
+
el.dispatchEvent(new PointerEvent(type, { ...init, pointerId: 1, isPrimary: true }));
|
|
107
164
|
}
|
|
108
165
|
else {
|
|
109
|
-
el.dispatchEvent(new MouseEvent(type,
|
|
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
|
-
|
|
119
|
-
|
|
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.
|
package/dist/actions/actions.js
CHANGED
|
@@ -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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
187
|
-
//
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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 =
|
|
277
|
-
const
|
|
278
|
-
el.dispatchEvent(new KeyboardEvent('
|
|
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 =
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) => {
|
package/dist/dom/snapshot.js
CHANGED
|
@@ -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.');
|
package/dist/timers/clock.js
CHANGED
|
@@ -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,
|
|
128
|
+
reArmed.set(task.id, nativeSetInterval(task.cb, task.interval));
|
|
121
129
|
}
|
|
122
130
|
else {
|
|
123
|
-
reArmed.set(task.id,
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
108
|
-
//
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
17
|
+
"@reticlehq/core": "2.7.0"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@tanstack/query-core": "^5.101.4",
|