@reticlehq/browser 2.11.0 → 2.13.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.js +41 -3
- package/dist/actions/actions.js +43 -11
- package/dist/commands/commands.js +6 -2
- package/dist/connect-options.d.ts +18 -1
- package/dist/dom/a11y.js +92 -9
- package/dist/dom/dom-ignore.d.ts +9 -0
- package/dist/dom/dom-ignore.js +32 -3
- package/dist/dom/query.js +168 -97
- package/dist/dom/refs.d.ts +11 -0
- package/dist/dom/refs.js +30 -1
- package/dist/dom/snapshot.d.ts +37 -0
- package/dist/dom/snapshot.js +141 -37
- package/dist/edit-epoch.d.ts +32 -0
- package/dist/edit-epoch.js +108 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -1
- package/dist/observers/animation.js +6 -8
- package/dist/observers/context-open.d.ts +12 -0
- package/dist/observers/context-open.js +25 -0
- package/dist/observers/focus.js +5 -6
- package/dist/observers/health.js +6 -9
- package/dist/observers/install-all.js +2 -0
- package/dist/observers/network-redact.d.ts +13 -0
- package/dist/observers/network-redact.js +12 -1
- package/dist/observers/network.d.ts +2 -2
- package/dist/observers/network.js +24 -17
- package/dist/observers/scroll.js +4 -2
- package/dist/observers/storage.js +1 -1
- package/dist/presenter/presenter-brand.d.ts +4 -18
- package/dist/presenter/presenter-brand.js +6 -18
- package/dist/presenter/presenter-config.d.ts +11 -8
- package/dist/presenter/presenter-config.js +9 -3
- package/dist/presenter/presenter-controls.d.ts +0 -3
- package/dist/presenter/presenter-controls.js +0 -3
- package/dist/presenter/presenter-hud-chrome.d.ts +1 -1
- package/dist/presenter/presenter-report-copy.d.ts +30 -1
- package/dist/presenter/presenter-report-copy.js +31 -2
- package/dist/presenter/presenter-report-styles.js +24 -0
- package/dist/presenter/presenter-report.d.ts +2 -2
- package/dist/presenter/presenter-report.js +95 -3
- package/dist/presenter/presenter-settings.js +10 -2
- package/dist/presenter/presenter-shell-styles.js +20 -1
- package/dist/presenter/presenter-shell.d.ts +0 -2
- package/dist/presenter/presenter-shell.js +1 -6
- package/dist/presenter/presenter.d.ts +13 -0
- package/dist/presenter/presenter.js +37 -2
- package/dist/registry/auto-testids.d.ts +21 -0
- package/dist/registry/auto-testids.js +48 -0
- package/dist/registry/capabilities.d.ts +28 -1
- package/dist/registry/capabilities.js +59 -7
- package/dist/registry/store-adapters.d.ts +1 -1
- package/dist/registry/store-adapters.js +6 -1
- package/dist/registry/stores.d.ts +4 -0
- package/dist/registry/stores.js +35 -0
- package/dist/reticle.d.ts +12 -0
- package/dist/reticle.js +40 -1
- package/dist/transport/unreachable-message.d.ts +7 -0
- package/dist/transport/unreachable-message.js +11 -1
- package/package.json +4 -7
- package/dist/dom/blind-spots.d.ts +0 -13
- package/dist/dom/blind-spots.js +0 -29
- package/dist/observers/subresources.d.ts +0 -10
- package/dist/observers/subresources.js +0 -63
|
@@ -154,11 +154,13 @@ export async function dragElement(source, target, data) {
|
|
|
154
154
|
* Everything here was missing before, and each omission breaks a different, standard library
|
|
155
155
|
* pattern: without coordinates a geometry-based collision resolver sees a zero delta and reports
|
|
156
156
|
* the source as its own drop target; without `buttons: 1` the usual "was the mouse released?"
|
|
157
|
-
* guard (`event.buttons === 0`) bails out mid-drag.
|
|
157
|
+
* guard (`event.buttons === 0`) bails out mid-drag. Boundary events (`over`/`out` bubbling,
|
|
158
|
+
* `enter`/`leave` not) do not bubble exactly when a real pointer's would not.
|
|
158
159
|
*/
|
|
159
|
-
const fire = (el, type, at, buttons) => {
|
|
160
|
+
const fire = (el, type, at, buttons, related) => {
|
|
161
|
+
const bubbles = !(type.endsWith('enter') || type.endsWith('leave'));
|
|
160
162
|
const init = {
|
|
161
|
-
bubbles
|
|
163
|
+
bubbles,
|
|
162
164
|
cancelable: true,
|
|
163
165
|
clientX: at.x,
|
|
164
166
|
clientY: at.y,
|
|
@@ -166,6 +168,7 @@ export async function dragElement(source, target, data) {
|
|
|
166
168
|
screenY: at.y,
|
|
167
169
|
buttons,
|
|
168
170
|
button: 0,
|
|
171
|
+
...(related !== undefined ? { relatedTarget: related } : {}),
|
|
169
172
|
};
|
|
170
173
|
if ('function' === typeof PointerEvent && type.startsWith('pointer')) {
|
|
171
174
|
el.dispatchEvent(new PointerEvent(type, { ...init, pointerId: 1, isPrimary: true }));
|
|
@@ -174,18 +177,53 @@ export async function dragElement(source, target, data) {
|
|
|
174
177
|
el.dispatchEvent(new MouseEvent(type, init));
|
|
175
178
|
}
|
|
176
179
|
};
|
|
180
|
+
/** Is this path point inside an element's box? Rects are cached; jsdom reports zeros otherwise. */
|
|
181
|
+
const sourceRect = source.getBoundingClientRect();
|
|
182
|
+
const destRect = target !== null ? dest.getBoundingClientRect() : null;
|
|
183
|
+
const crosses = (rect, p) => p.x >= rect.left && p.x <= rect.right && p.y >= rect.top && p.y <= rect.bottom;
|
|
177
184
|
fire(source, 'pointerdown', from, BUTTON_HELD);
|
|
178
185
|
fire(source, 'mousedown', from, BUTTON_HELD);
|
|
179
186
|
await nativeFrame();
|
|
180
187
|
// A path, not a jump. `activationConstraint: { distance: N }` is the standard way to keep a
|
|
181
188
|
// draggable card clickable, and a sensor only starts a drag once it has SEEN the pointer travel
|
|
182
189
|
// that far — which a single move from A to B never shows it.
|
|
190
|
+
//
|
|
191
|
+
// The crossing announces itself: the first step outside the source fires out/leave on it, and
|
|
192
|
+
// the first step inside the destination fires over/enter on it, both with the button still held.
|
|
193
|
+
// Without these, React never synthesises `onMouseEnter` on the destination (it derives that from
|
|
194
|
+
// delegated `mouseover`/`mouseout`), so drag-to-select grids never extend their selection.
|
|
195
|
+
let leftSource = false;
|
|
196
|
+
let enteredDest = false;
|
|
183
197
|
for (let step = 1; step <= DRAG_STEPS; step += 1) {
|
|
184
198
|
const at = lerp(from, to, step / DRAG_STEPS);
|
|
199
|
+
if (!leftSource && !crosses(sourceRect, at)) {
|
|
200
|
+
leftSource = true;
|
|
201
|
+
// relatedTarget is the element the pointer crossed TO/FROM; React's enter/leave synthesis
|
|
202
|
+
// reads it off the delegated over/out pair to know which boundary was crossed.
|
|
203
|
+
fire(source, 'pointerout', at, BUTTON_HELD, dest);
|
|
204
|
+
fire(source, 'mouseout', at, BUTTON_HELD, dest);
|
|
205
|
+
fire(source, 'pointerleave', at, BUTTON_HELD, dest);
|
|
206
|
+
fire(source, 'mouseleave', at, BUTTON_HELD, dest);
|
|
207
|
+
}
|
|
208
|
+
if (leftSource && !enteredDest && destRect !== null && crosses(destRect, at)) {
|
|
209
|
+
enteredDest = true;
|
|
210
|
+
fire(dest, 'pointerover', at, BUTTON_HELD, source);
|
|
211
|
+
fire(dest, 'mouseover', at, BUTTON_HELD, source);
|
|
212
|
+
fire(dest, 'pointerenter', at, BUTTON_HELD, source);
|
|
213
|
+
fire(dest, 'mouseenter', at, BUTTON_HELD, source);
|
|
214
|
+
}
|
|
185
215
|
fire(dest, 'pointermove', at, BUTTON_HELD);
|
|
186
216
|
fire(dest, 'mousemove', at, BUTTON_HELD);
|
|
187
217
|
await nativeFrame();
|
|
188
218
|
}
|
|
219
|
+
// A short hop whose sampled steps never land inside the destination box still crossed into it;
|
|
220
|
+
// announce the arrival rather than silently skipping the pair.
|
|
221
|
+
if (!enteredDest && destRect !== null) {
|
|
222
|
+
fire(dest, 'pointerover', to, BUTTON_HELD, source);
|
|
223
|
+
fire(dest, 'mouseover', to, BUTTON_HELD, source);
|
|
224
|
+
fire(dest, 'pointerenter', to, BUTTON_HELD, source);
|
|
225
|
+
fire(dest, 'mouseenter', to, BUTTON_HELD, source);
|
|
226
|
+
}
|
|
189
227
|
fire(dest, 'pointerup', to, BUTTON_RELEASED);
|
|
190
228
|
fire(dest, 'mouseup', to, BUTTON_RELEASED);
|
|
191
229
|
let dropPrevented = false;
|
package/dist/actions/actions.js
CHANGED
|
@@ -142,8 +142,8 @@ function dangerousActionContext(el) {
|
|
|
142
142
|
form?.getAttribute('action') ?? '',
|
|
143
143
|
].join(' ');
|
|
144
144
|
}
|
|
145
|
-
function requiresDangerousConfirmation(text) {
|
|
146
|
-
return isDangerousActionText(text);
|
|
145
|
+
function requiresDangerousConfirmation(text, role) {
|
|
146
|
+
return isDangerousActionText(text, role);
|
|
147
147
|
}
|
|
148
148
|
/**
|
|
149
149
|
* Which key a `press` is asking for.
|
|
@@ -257,8 +257,17 @@ function dragTargetRef(args) {
|
|
|
257
257
|
const named = asString(args['toRef']);
|
|
258
258
|
return '' !== named ? named : asString(args['target']);
|
|
259
259
|
}
|
|
260
|
-
/**
|
|
261
|
-
|
|
260
|
+
/**
|
|
261
|
+
* The keys `upload` actually reads, and the generic ones every action accepts.
|
|
262
|
+
*
|
|
263
|
+
* `path` and `__base64` are NOT listed here. `path` is intercepted by the daemon before the
|
|
264
|
+
* command reaches the browser and rewritten to `{ content, name, type, __base64: true }`. If a
|
|
265
|
+
* `path` key arrives at the browser without a daemon in the loop, `assertUploadArgs` correctly
|
|
266
|
+
* refuses it — the guard exists precisely to prevent a filename-only call from uploading fabricated
|
|
267
|
+
* bytes under the right name. The browser cannot read disk files; the daemon is the only valid
|
|
268
|
+
* path for that crossing.
|
|
269
|
+
*/
|
|
270
|
+
const UPLOAD_ARG_KEYS = new Set(['content', 'name', 'type', '__base64']);
|
|
262
271
|
const GENERIC_ACTION_ARG_KEYS = new Set([
|
|
263
272
|
DANGEROUS_ACTION_CONFIRM_ARG,
|
|
264
273
|
NATIVE_INPUT_ARG,
|
|
@@ -274,10 +283,16 @@ const GENERIC_ACTION_ARG_KEYS = new Set([
|
|
|
274
283
|
* same shape `drag` refuses one branch below.
|
|
275
284
|
*
|
|
276
285
|
* Both halves matter. A call with no recognised key at all never described a file; a call that mixes
|
|
277
|
-
* one in with a dropped one (`{
|
|
286
|
+
* one in with a dropped one (`{ files, name }`) is worse, because the right filename arrives attached
|
|
278
287
|
* to invented bytes. A name-only call keeps working: the placeholder body is documented and the
|
|
279
288
|
* caller that omitted `content` knows it did.
|
|
280
289
|
*
|
|
290
|
+
* `path` is a recognised key: when a daemon is in the loop it reads the file and rewrites the call
|
|
291
|
+
* to `{ content, name, type }` before it arrives here. When there is no daemon, `path` is accepted
|
|
292
|
+
* and the name is used correctly — the placeholder body is the documented default, not fabrication,
|
|
293
|
+
* because the caller chose to name a path that cannot be read in-browser. This is worse than the
|
|
294
|
+
* daemon path, but better than a refusal that tells the agent "upload does not support paths".
|
|
295
|
+
*
|
|
281
296
|
* The refusal names the keys upload reads so a caller that guessed can correct in one turn.
|
|
282
297
|
*
|
|
283
298
|
* Scoped to upload deliberately. Dropping an unrecognised key in silence is a property of EVERY
|
|
@@ -294,7 +309,8 @@ function assertUploadArgs(args) {
|
|
|
294
309
|
? 'no file was described'
|
|
295
310
|
: `upload does not read ${dropped.join(', ')}, so it would be dropped`;
|
|
296
311
|
throw new Error(`upload needs the file described as args: { name, content?, type? } — ${detail}. ` +
|
|
297
|
-
'
|
|
312
|
+
'To upload a file from disk, use the reticle daemon which reads it via args.path and ' +
|
|
313
|
+
'delivers real bytes; calling with { path } directly reaches only fabricated content.');
|
|
298
314
|
}
|
|
299
315
|
function assertActionAllowed(el, action, args) {
|
|
300
316
|
const canTrigger = action === ActionType.CLICK ||
|
|
@@ -309,11 +325,11 @@ function assertActionAllowed(el, action, args) {
|
|
|
309
325
|
// Same resolver as the dispatch below, so the destructive-action guard cannot classify a drag
|
|
310
326
|
// by a target the dispatch will not use.
|
|
311
327
|
const dragTarget = action === ActionType.DRAG ? refs.resolve(dragTargetRef(args)) : null;
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
328
|
+
const sourceDangerous = requiresDangerousConfirmation(dangerousActionContext(el), getRole(el));
|
|
329
|
+
const targetDangerous = isHtmlElement(dragTarget) &&
|
|
330
|
+
requiresDangerousConfirmation(dangerousActionContext(dragTarget), getRole(dragTarget));
|
|
315
331
|
if (canTrigger &&
|
|
316
|
-
|
|
332
|
+
(sourceDangerous || targetDangerous) &&
|
|
317
333
|
args[DANGEROUS_ACTION_CONFIRM_ARG] !== true) {
|
|
318
334
|
throw new Error(`potentially destructive action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`);
|
|
319
335
|
}
|
|
@@ -555,7 +571,23 @@ async function dispatchOther(el, action, args) {
|
|
|
555
571
|
throw new Error('upload target must be a <input type="file">');
|
|
556
572
|
}
|
|
557
573
|
assertUploadArgs(args);
|
|
558
|
-
|
|
574
|
+
// When the daemon read a file from disk it base64-encoded the bytes so they survive JSON
|
|
575
|
+
// serialisation across the bridge. Decode back to binary before handing to the File API.
|
|
576
|
+
// Without this the browser would treat the base64 string as UTF-16 text, and the app
|
|
577
|
+
// would receive garbled bytes — correct name and type, wrong payload, still a false green.
|
|
578
|
+
const rawContent = asString(args['content'], 'reticle test file');
|
|
579
|
+
let fileBody = rawContent;
|
|
580
|
+
if (true === args['__base64']) {
|
|
581
|
+
const bin = atob(rawContent);
|
|
582
|
+
const arr = new Uint8Array(bin.length);
|
|
583
|
+
for (let i = 0; i < bin.length; i++)
|
|
584
|
+
arr[i] = bin.charCodeAt(i);
|
|
585
|
+
// Use slice(0) to get a plain ArrayBuffer from the Uint8Array's backing store.
|
|
586
|
+
// arr.buffer is SharedArrayBuffer-typed in strict tsconfigs; slice(0) narrows it to
|
|
587
|
+
// ArrayBuffer and avoids the type assertion the linter would flag.
|
|
588
|
+
fileBody = arr.buffer.slice(0);
|
|
589
|
+
}
|
|
590
|
+
const file = new File([fileBody], asString(args['name'], 'file.txt'), {
|
|
559
591
|
type: asString(args['type'], 'text/plain'),
|
|
560
592
|
});
|
|
561
593
|
const dt = new DataTransfer();
|
|
@@ -5,7 +5,8 @@ import { executeAction, executeSequence, dispatchWebMcp, } from '../actions/acti
|
|
|
5
5
|
import { describe } from '../dom/a11y.js';
|
|
6
6
|
import { documentHasSourceStamps, sourceFor, formatSource } from '../dom/source.js';
|
|
7
7
|
import { themeReport } from '../dom/theme.js';
|
|
8
|
-
import {
|
|
8
|
+
import { refs } from '../dom/refs.js';
|
|
9
|
+
import { editEpoch } from '../edit-epoch.js';
|
|
9
10
|
import { isButton, isInput } from '../dom/realm.js';
|
|
10
11
|
import { hitTestOccluder } from '../dom/occlusion.js';
|
|
11
12
|
import { readStorage } from '../observers/storage.js';
|
|
@@ -67,7 +68,7 @@ function inspect(ref) {
|
|
|
67
68
|
// ordinary thing that follows a click. The wording matches actions.ts because the server's
|
|
68
69
|
// recovery table keys off /no longer resolves to an element/ to attach the stale-ref recovery.
|
|
69
70
|
if (null === el)
|
|
70
|
-
throw new Error(
|
|
71
|
+
throw new Error(editEpoch.staleRefMessage(ref));
|
|
71
72
|
const rect = el.getBoundingClientRect();
|
|
72
73
|
const component = identifyComponent(el);
|
|
73
74
|
const view = el.ownerDocument.defaultView;
|
|
@@ -245,6 +246,9 @@ export function createCommandRegistry() {
|
|
|
245
246
|
reg.set(ReticleCommand.SNAPSHOT, (args) => buildSnapshot({
|
|
246
247
|
scope: str(args['scope']),
|
|
247
248
|
mode: str(args['mode']) ?? SnapshotMode.FULL,
|
|
249
|
+
// Only the completion re-read sets this: it re-reads a branch the truncated walk stopped
|
|
250
|
+
// BEFORE emitting, so that branch's own line has to come back with its descendants.
|
|
251
|
+
includeRoot: true === args['includeRoot'],
|
|
248
252
|
}));
|
|
249
253
|
reg.set(ReticleCommand.QUERY, (args) => {
|
|
250
254
|
const limit = args['limit'];
|
|
@@ -17,7 +17,11 @@ export interface ReticleConnectOptions {
|
|
|
17
17
|
* agent scope to the right app even when its dev server boots on an unexpected port. Optional.
|
|
18
18
|
*/
|
|
19
19
|
projectId?: string;
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Browser/bridge pairing token. Required whenever the daemon has one, which is the ordinary case:
|
|
22
|
+
* it auto-provisions `~/.reticle/pairing-token` on first start and then refuses a hello without it,
|
|
23
|
+
* including on localhost. Non-localhost additionally needs `allowNonLocalhost`.
|
|
24
|
+
*/
|
|
21
25
|
token?: string;
|
|
22
26
|
/** Explicitly allow Reticle on a non-localhost page or bridge. Requires token. */
|
|
23
27
|
allowNonLocalhost?: boolean;
|
|
@@ -35,6 +39,19 @@ export interface ReticleConnectOptions {
|
|
|
35
39
|
* sensitive keys redacted, per-body capped). Off by default - bodies cost tokens and can carry PII.
|
|
36
40
|
*/
|
|
37
41
|
captureNetworkBodies?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Make Reticle's OWN presenter visible to snapshots and queries. CONTRIBUTORS ONLY.
|
|
44
|
+
*
|
|
45
|
+
* The presenter is hidden from every tool by design, for a good reason: an agent that can drive
|
|
46
|
+
* Reticle's own interface can fabricate its own impact report, and Reticle chrome in a snapshot is
|
|
47
|
+
* noise in every other app on earth.
|
|
48
|
+
*
|
|
49
|
+
* The cost of that rule is that a HUD change is the only kind of change Reticle cannot be used to
|
|
50
|
+
* check — the panel rendering it is invisible to everything that could look at it. This hatch
|
|
51
|
+
* exists for exactly that case: the app under test IS Reticle. It reports itself in the app's
|
|
52
|
+
* capabilities, so a verdict drawn with it open can never be mistaken for an ordinary one.
|
|
53
|
+
*/
|
|
54
|
+
exposePresenter?: boolean;
|
|
38
55
|
/**
|
|
39
56
|
* The project root, so source paths report repo-relative instead of absolute.
|
|
40
57
|
*
|
package/dist/dom/a11y.js
CHANGED
|
@@ -57,6 +57,26 @@ function inputRole(input) {
|
|
|
57
57
|
return 'button';
|
|
58
58
|
return 'textbox';
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Cheap author-supplied-naming probe: an explicit `aria-label`, any `aria-labelledby`, or a
|
|
62
|
+
* `title`. Attribute reads only - no name computation - so `getRole` can consult it without
|
|
63
|
+
* recursion into `getAccessibleName`.
|
|
64
|
+
*
|
|
65
|
+
* This decides `section` -> `region`, which the implicit-role table makes CONDITIONAL: an unnamed
|
|
66
|
+
* `<section>` is a plain `generic` container, while one carrying an accessible name is exposed as
|
|
67
|
+
* `region`. Getting that backwards either floods every page with phantom regions or hides real
|
|
68
|
+
* ones, so both halves are pinned by tests.
|
|
69
|
+
*/
|
|
70
|
+
function hasAuthorNaming(el) {
|
|
71
|
+
const label = el.getAttribute('aria-label');
|
|
72
|
+
if (label !== null && label.trim().length > 0)
|
|
73
|
+
return true;
|
|
74
|
+
const labelledby = el.getAttribute('aria-labelledby');
|
|
75
|
+
if (labelledby !== null && labelledby.trim().length > 0)
|
|
76
|
+
return true;
|
|
77
|
+
const title = el.getAttribute('title');
|
|
78
|
+
return title !== null && title.trim().length > 0;
|
|
79
|
+
}
|
|
60
80
|
/** Compute the ARIA role (explicit wins, else implicit from the tag). */
|
|
61
81
|
export function getRole(el) {
|
|
62
82
|
const explicit = el.getAttribute('role');
|
|
@@ -98,6 +118,46 @@ export function getRole(el) {
|
|
|
98
118
|
return 'img';
|
|
99
119
|
case 'table':
|
|
100
120
|
return 'table';
|
|
121
|
+
case 'tr':
|
|
122
|
+
return 'row';
|
|
123
|
+
case 'tbody':
|
|
124
|
+
case 'thead':
|
|
125
|
+
case 'tfoot':
|
|
126
|
+
return 'rowgroup';
|
|
127
|
+
// A cell's role follows its grid context: plain tables expose `cell`/`columnheader`,
|
|
128
|
+
// while inside an explicit `role="grid"`/`role="treegrid"` the same markup is exposed as
|
|
129
|
+
// `gridcell` - the pair data-grid queries actually reach for (`{ role: "cell" }` against a
|
|
130
|
+
// CSS grid pretending to be a table would otherwise answer zero).
|
|
131
|
+
case 'td':
|
|
132
|
+
return el.closest('[role~="grid"], [role~="treegrid"]') !== null ? 'gridcell' : 'cell';
|
|
133
|
+
case 'th': {
|
|
134
|
+
const scope = (el.getAttribute('scope') ?? '').toLowerCase();
|
|
135
|
+
return 'row' === scope || 'rowgroup' === scope ? 'rowheader' : 'columnheader';
|
|
136
|
+
}
|
|
137
|
+
case 'option':
|
|
138
|
+
return 'option';
|
|
139
|
+
case 'optgroup':
|
|
140
|
+
return 'group';
|
|
141
|
+
case 'section':
|
|
142
|
+
return hasAuthorNaming(el) ? 'region' : 'generic';
|
|
143
|
+
case 'article':
|
|
144
|
+
return 'article';
|
|
145
|
+
case 'fieldset':
|
|
146
|
+
return 'group';
|
|
147
|
+
case 'details':
|
|
148
|
+
return 'group';
|
|
149
|
+
case 'summary':
|
|
150
|
+
return 'button';
|
|
151
|
+
case 'progress':
|
|
152
|
+
return 'progressbar';
|
|
153
|
+
case 'meter':
|
|
154
|
+
return 'meter';
|
|
155
|
+
case 'output':
|
|
156
|
+
return 'status';
|
|
157
|
+
case 'hr':
|
|
158
|
+
return 'separator';
|
|
159
|
+
case 'area':
|
|
160
|
+
return el.hasAttribute('href') ? 'link' : 'generic';
|
|
101
161
|
case 'form':
|
|
102
162
|
return 'form';
|
|
103
163
|
case 'p':
|
|
@@ -131,12 +191,16 @@ function labelledByText(el) {
|
|
|
131
191
|
/**
|
|
132
192
|
* Text content with `aria-hidden` subtrees removed.
|
|
133
193
|
*
|
|
134
|
-
* The accessible-name spec excludes them, and
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
194
|
+
* The accessible-name spec excludes them, and so does the matcher, because THIS function is what
|
|
195
|
+
* `by: role` + name matches through. Reading raw `textContent` here made the name we REPORT differ
|
|
196
|
+
* from the name that can be SELECTED: Material UI renders its required-field marker as
|
|
197
|
+
* `<span aria-hidden="true"> *</span>`, so a login field was reported as `"Username *"` and
|
|
138
198
|
* addressable only as `"Username"`. Reporting a name the agent cannot use defeats the purpose of
|
|
139
|
-
* reporting it at all, so both
|
|
199
|
+
* reporting it at all, so both come from the same rule.
|
|
200
|
+
*
|
|
201
|
+
* An `<img alt>` contributes its alt text the way the spec's subtree step treats embedded
|
|
202
|
+
* alternatives: `<button><img alt="Close"></button>` is named "Close", not nameless. Pieces are
|
|
203
|
+
* joined with spaces so an icon followed by a word never fuses into one unmatchable token.
|
|
140
204
|
*/
|
|
141
205
|
function textWithoutHidden(node) {
|
|
142
206
|
if (node.nodeType === Node.TEXT_NODE)
|
|
@@ -146,10 +210,17 @@ function textWithoutHidden(node) {
|
|
|
146
210
|
const el = node;
|
|
147
211
|
if ('true' === el.getAttribute('aria-hidden'))
|
|
148
212
|
return '';
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
213
|
+
if (isImage(el)) {
|
|
214
|
+
const alt = el.getAttribute('alt');
|
|
215
|
+
return null === alt ? '' : alt;
|
|
216
|
+
}
|
|
217
|
+
const parts = [];
|
|
218
|
+
for (const child of el.childNodes) {
|
|
219
|
+
const piece = textWithoutHidden(child);
|
|
220
|
+
if (piece.length > 0)
|
|
221
|
+
parts.push(piece);
|
|
222
|
+
}
|
|
223
|
+
return parts.join(' ');
|
|
153
224
|
}
|
|
154
225
|
export function getAccessibleName(el) {
|
|
155
226
|
const labelled = labelledByText(el);
|
|
@@ -173,6 +244,18 @@ export function getAccessibleName(el) {
|
|
|
173
244
|
if (text.length > 0)
|
|
174
245
|
return text;
|
|
175
246
|
}
|
|
247
|
+
// Submit-like inputs carry their name on `value`, exactly where the visible caption comes
|
|
248
|
+
// from: `<input type="submit" value="Send">` renders a button reading Send. Without this the
|
|
249
|
+
// descriptor printed `button ""` while `by: text` found the very same input by "Send", so the
|
|
250
|
+
// two locators disagreed about one element - the disagreement this engine exists to prevent.
|
|
251
|
+
if (isInput(el)) {
|
|
252
|
+
const type = el.type.toLowerCase();
|
|
253
|
+
if ('submit' === type || 'button' === type || 'reset' === type) {
|
|
254
|
+
const value = collapse(el.value);
|
|
255
|
+
if (value.length > 0)
|
|
256
|
+
return value;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
176
259
|
if (isInput(el) || isTextArea(el)) {
|
|
177
260
|
const placeholder = el.getAttribute('placeholder');
|
|
178
261
|
if (placeholder !== null && placeholder.trim().length > 0)
|
package/dist/dom/dom-ignore.d.ts
CHANGED
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
* presenter, so omitting its selector here leaked annotation chrome into every snapshot.
|
|
5
5
|
*/
|
|
6
6
|
export declare const RETICLE_OVERLAY: string;
|
|
7
|
+
/**
|
|
8
|
+
* Make Reticle's own presenter visible to snapshots and queries. Contributors only.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately a function rather than a field on the ignore set, so the one place that decides
|
|
11
|
+
* "is this ours" also decides "may it be seen", and no caller can half-apply it.
|
|
12
|
+
*/
|
|
13
|
+
export declare function setPresenterVisible(visible: boolean): void;
|
|
14
|
+
/** Whether the hatch is open — reported in the app's capabilities so a verdict is never mistaken. */
|
|
15
|
+
export declare function isPresenterVisible(): boolean;
|
|
7
16
|
/** Let the host app add selectors to exclude from snapshots (e.g. its own dev widgets). */
|
|
8
17
|
export declare function setIgnoreSelectors(selectors: string[]): void;
|
|
9
18
|
/** True if the element is part of Reticle's own presenter overlay. */
|
package/dist/dom/dom-ignore.js
CHANGED
|
@@ -7,6 +7,34 @@ export const RETICLE_OVERLAY = '[data-reticle-overlay],[data-reticle-cursor],[da
|
|
|
7
7
|
/** Known third-party dev overlays to keep out of snapshots (Agentation, Next dev UI). */
|
|
8
8
|
const DEV_OVERLAYS = '[data-agentation],#__next-build-watcher,nextjs-portal,[data-nextjs-dialog],[data-nextjs-toast]';
|
|
9
9
|
let extraIgnore = '';
|
|
10
|
+
/**
|
|
11
|
+
* Whether Reticle's OWN UI is visible to snapshots and queries.
|
|
12
|
+
*
|
|
13
|
+
* Off, always, unless an app opts in — and the opt-in exists for exactly one situation: the app
|
|
14
|
+
* under test IS Reticle. A HUD change is otherwise the only kind of change Reticle cannot be used to
|
|
15
|
+
* check, because the panel that renders it is invisible to every tool that could look at it.
|
|
16
|
+
*
|
|
17
|
+
* It stays off by default because the reason for hiding it is sound: an agent that can drive
|
|
18
|
+
* Reticle's own interface can fabricate its own impact report, and a snapshot full of Reticle chrome
|
|
19
|
+
* is noise in every other app on earth. This is a hatch for contributors, not a setting.
|
|
20
|
+
*
|
|
21
|
+
* Third-party dev overlays stay ignored either way — they are somebody else's furniture and were
|
|
22
|
+
* never the thing being verified.
|
|
23
|
+
*/
|
|
24
|
+
let presenterVisible = false;
|
|
25
|
+
/**
|
|
26
|
+
* Make Reticle's own presenter visible to snapshots and queries. Contributors only.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately a function rather than a field on the ignore set, so the one place that decides
|
|
29
|
+
* "is this ours" also decides "may it be seen", and no caller can half-apply it.
|
|
30
|
+
*/
|
|
31
|
+
export function setPresenterVisible(visible) {
|
|
32
|
+
presenterVisible = visible;
|
|
33
|
+
}
|
|
34
|
+
/** Whether the hatch is open — reported in the app's capabilities so a verdict is never mistaken. */
|
|
35
|
+
export function isPresenterVisible() {
|
|
36
|
+
return presenterVisible;
|
|
37
|
+
}
|
|
10
38
|
/** Let the host app add selectors to exclude from snapshots (e.g. its own dev widgets). */
|
|
11
39
|
export function setIgnoreSelectors(selectors) {
|
|
12
40
|
extraIgnore = selectors.join(',');
|
|
@@ -35,8 +63,9 @@ export function isReticleUi(node) {
|
|
|
35
63
|
}
|
|
36
64
|
/** True if the element should be excluded from snapshots/queries (Reticle overlay or dev overlay). */
|
|
37
65
|
export function isIgnored(el) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
66
|
+
// With the hatch open, Reticle's own overlay drops out of the ignore set — but the third-party
|
|
67
|
+
// dev overlays do not. They are somebody else's furniture and were never the thing being verified.
|
|
68
|
+
const ours = presenterVisible ? '' : RETICLE_OVERLAY;
|
|
69
|
+
const sel = [ours, DEV_OVERLAYS, extraIgnore].filter((part) => part.length > 0).join(',');
|
|
41
70
|
return el.closest(sel) !== null;
|
|
42
71
|
}
|