@reticlehq/browser 2.6.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 +4 -0
- package/dist/actions/actions-dom.js +59 -10
- package/dist/actions/actions.js +150 -14
- package/dist/commands/commands.js +5 -0
- package/dist/dom/query.js +10 -0
- package/dist/observers/network.js +4 -0
- package/dist/reticle.js +1 -1
- package/package.json +2 -2
|
@@ -30,6 +30,10 @@ export declare function firePointerNonBubbling(el: Element, type: string, relate
|
|
|
30
30
|
/**
|
|
31
31
|
* Pointer-based drag (dnd-kit / react-beautiful-dnd) + best-effort HTML5 DnD. Async: yields a
|
|
32
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.
|
|
33
37
|
*/
|
|
34
38
|
export declare function dragElement(source: HTMLElement, target: HTMLElement | null, data: unknown): Promise<boolean>;
|
|
35
39
|
export {};
|
|
@@ -109,28 +109,77 @@ function makeDataTransfer(data) {
|
|
|
109
109
|
}
|
|
110
110
|
return dt;
|
|
111
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
|
+
}
|
|
112
131
|
/**
|
|
113
132
|
* Pointer-based drag (dnd-kit / react-beautiful-dnd) + best-effort HTML5 DnD. Async: yields a
|
|
114
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.
|
|
115
138
|
*/
|
|
116
139
|
export async function dragElement(source, target, data) {
|
|
117
140
|
const dest = target ?? source;
|
|
118
|
-
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
|
+
};
|
|
119
162
|
if ('function' === typeof PointerEvent && type.startsWith('pointer')) {
|
|
120
|
-
el.dispatchEvent(new PointerEvent(type, {
|
|
163
|
+
el.dispatchEvent(new PointerEvent(type, { ...init, pointerId: 1, isPrimary: true }));
|
|
121
164
|
}
|
|
122
165
|
else {
|
|
123
|
-
el.dispatchEvent(new MouseEvent(type,
|
|
166
|
+
el.dispatchEvent(new MouseEvent(type, init));
|
|
124
167
|
}
|
|
125
168
|
};
|
|
126
|
-
fire(source, 'pointerdown');
|
|
127
|
-
fire(source, 'mousedown');
|
|
169
|
+
fire(source, 'pointerdown', from, BUTTON_HELD);
|
|
170
|
+
fire(source, 'mousedown', from, BUTTON_HELD);
|
|
128
171
|
await nativeFrame();
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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);
|
|
134
183
|
let dropPrevented = false;
|
|
135
184
|
if ('function' === typeof DragEvent) {
|
|
136
185
|
const dataTransfer = makeDataTransfer(data);
|
package/dist/actions/actions.js
CHANGED
|
@@ -112,13 +112,112 @@ function dangerousActionContext(el) {
|
|
|
112
112
|
function requiresDangerousConfirmation(text) {
|
|
113
113
|
return isDangerousActionText(text);
|
|
114
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
|
+
}
|
|
115
210
|
function assertActionAllowed(el, action, args) {
|
|
116
211
|
const canTrigger = action === ActionType.CLICK ||
|
|
117
212
|
action === ActionType.DBLCLICK ||
|
|
118
213
|
action === ActionType.DRAG ||
|
|
119
214
|
action === ActionType.SUBMIT ||
|
|
120
|
-
|
|
121
|
-
|
|
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;
|
|
122
221
|
const context = isHtmlElement(dragTarget)
|
|
123
222
|
? `${dangerousActionContext(el)} ${dangerousActionContext(dragTarget)}`
|
|
124
223
|
: dangerousActionContext(el);
|
|
@@ -304,13 +403,39 @@ async function dispatchOther(el, action, args) {
|
|
|
304
403
|
}
|
|
305
404
|
throw new Error(`cannot select on a <${el.tagName.toLowerCase()}>`);
|
|
306
405
|
case ActionType.CHECK:
|
|
307
|
-
case ActionType.UNCHECK:
|
|
308
|
-
if (isInput(el))
|
|
309
|
-
|
|
310
|
-
|
|
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)
|
|
311
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);
|
|
312
436
|
}
|
|
313
|
-
|
|
437
|
+
return prevented;
|
|
438
|
+
}
|
|
314
439
|
case ActionType.SUBMIT: {
|
|
315
440
|
const form = isForm(el) ? el : el.closest('form');
|
|
316
441
|
if (null === form)
|
|
@@ -319,9 +444,10 @@ async function dispatchOther(el, action, args) {
|
|
|
319
444
|
return false; // requestSubmit returns void; the internal submit event is unobservable.
|
|
320
445
|
}
|
|
321
446
|
case ActionType.PRESS: {
|
|
322
|
-
const key =
|
|
323
|
-
const
|
|
324
|
-
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 }));
|
|
325
451
|
return !down;
|
|
326
452
|
}
|
|
327
453
|
case ActionType.SCROLL_INTO_VIEW:
|
|
@@ -342,11 +468,21 @@ async function dispatchOther(el, action, args) {
|
|
|
342
468
|
return !inputOk;
|
|
343
469
|
}
|
|
344
470
|
case ActionType.DRAG: {
|
|
345
|
-
const toRef =
|
|
471
|
+
const toRef = dragTargetRef(args);
|
|
346
472
|
// asString falls back to '' (never undefined), so the old `!== undefined` was always true and
|
|
347
|
-
// handed '' to refs.resolve. A drag with
|
|
348
|
-
|
|
349
|
-
|
|
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']);
|
|
350
486
|
}
|
|
351
487
|
default:
|
|
352
488
|
throw new Error(`unknown action '${action}'`);
|
|
@@ -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,
|
package/dist/dom/query.js
CHANGED
|
@@ -259,6 +259,16 @@ function findCandidates(query) {
|
|
|
259
259
|
// scopeMissing flag is what keeps "gone scope" distinct from "absent element".
|
|
260
260
|
if (null === container)
|
|
261
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
|
+
}
|
|
262
272
|
const seen = new Set();
|
|
263
273
|
const out = [];
|
|
264
274
|
const collect = (els) => {
|
|
@@ -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/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",
|