@reticlehq/browser 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/actions-dom.d.ts +8 -1
- package/dist/actions/actions-dom.js +26 -12
- package/dist/actions/actions.d.ts +46 -0
- package/dist/actions/actions.js +114 -26
- package/dist/actions/appeared-text.d.ts +27 -0
- package/dist/actions/appeared-text.js +92 -0
- package/dist/actions/scroll.js +1 -1
- package/dist/commands/commands.js +45 -22
- package/dist/connect-options.d.ts +6 -0
- package/dist/dom/a11y.js +20 -20
- package/dist/dom/auto-anchor.js +1 -1
- package/dist/dom/blind-spots.js +1 -1
- package/dist/dom/chart.js +5 -5
- package/dist/dom/desktop-capture.d.ts +2 -1
- package/dist/dom/desktop-capture.js +4 -4
- package/dist/dom/occlusion.js +2 -2
- package/dist/dom/query.js +68 -25
- package/dist/dom/realm.js +13 -13
- package/dist/dom/refs.d.ts +14 -0
- package/dist/dom/refs.js +20 -1
- package/dist/dom/shadow-registry.js +2 -2
- package/dist/dom/snapshot.js +32 -11
- package/dist/dom/source.js +3 -3
- package/dist/dom/theme.js +6 -6
- package/dist/dom/virtualized.js +1 -1
- package/dist/observers/blind-spots.js +2 -2
- package/dist/observers/console.js +3 -3
- package/dist/observers/dom.js +5 -5
- package/dist/observers/download.d.ts +2 -1
- package/dist/observers/download.js +2 -2
- package/dist/observers/focus.js +1 -1
- package/dist/observers/frames.js +1 -1
- package/dist/observers/health.js +70 -2
- package/dist/observers/install-all.d.ts +2 -1
- package/dist/observers/ipc.d.ts +2 -7
- package/dist/observers/ipc.js +7 -7
- package/dist/observers/network-body.js +2 -2
- package/dist/observers/network-redact.js +5 -5
- package/dist/observers/network-stream.js +2 -2
- package/dist/observers/network.d.ts +3 -3
- package/dist/observers/network.js +9 -9
- package/dist/observers/perf.js +1 -1
- package/dist/observers/scroll.js +1 -1
- package/dist/observers/state.js +1 -1
- package/dist/observers/storage.js +7 -7
- package/dist/presenter/presenter-brand.d.ts +19 -0
- package/dist/presenter/presenter-brand.js +21 -0
- package/dist/presenter/presenter-config.d.ts +17 -0
- package/dist/presenter/presenter-config.js +21 -0
- package/dist/presenter/presenter-controls.d.ts +1 -11
- package/dist/presenter/presenter-controls.js +6 -7
- package/dist/presenter/presenter-log.js +2 -2
- package/dist/presenter/presenter-run-state.js +1 -1
- package/dist/presenter/presenter-styles.d.ts +1 -1
- package/dist/presenter/presenter-styles.js +20 -9
- package/dist/presenter/presenter-tally.js +3 -3
- package/dist/presenter/presenter-test-helpers.d.ts +2 -1
- package/dist/presenter/presenter-test-helpers.js +2 -2
- package/dist/presenter/presenter-verbs.js +16 -11
- package/dist/presenter/presenter.js +13 -10
- package/dist/recorder/recorder.js +10 -9
- package/dist/registry/store-adapters.js +1 -1
- package/dist/registry/stores.js +3 -3
- package/dist/reticle-presenter-helpers.js +3 -3
- package/dist/reticle.js +31 -13
- package/dist/review/annotator.js +7 -7
- package/dist/security/serialization.js +9 -9
- package/dist/session-continuity.d.ts +17 -0
- package/dist/session-continuity.js +42 -0
- package/dist/timers/clock.js +28 -15
- package/dist/timers/native-console.js +1 -1
- package/dist/timers/native-timers.js +4 -4
- package/dist/transport/transport.d.ts +23 -0
- package/dist/transport/transport.js +85 -25
- package/package.json +6 -4
|
@@ -5,7 +5,7 @@ import { executeAction, executeSequence, dispatchWebMcp, } from '../actions/acti
|
|
|
5
5
|
import { describe } from '../dom/a11y.js';
|
|
6
6
|
import { sourceFor, formatSource } from '../dom/source.js';
|
|
7
7
|
import { themeReport } from '../dom/theme.js';
|
|
8
|
-
import { refs } from '../dom/refs.js';
|
|
8
|
+
import { echoRef, refs } from '../dom/refs.js';
|
|
9
9
|
import { isButton, isInput } from '../dom/realm.js';
|
|
10
10
|
import { hitTestOccluder } from '../dom/occlusion.js';
|
|
11
11
|
import { readStorage } from '../observers/storage.js';
|
|
@@ -19,16 +19,16 @@ import { scrollContainer } from '../actions/scroll.js';
|
|
|
19
19
|
/** Query param appended on a hard reload to bypass the browser cache. */
|
|
20
20
|
export const RELOAD_CACHE_BUST_PARAM = '_reticle_reload';
|
|
21
21
|
function str(value) {
|
|
22
|
-
return typeof value
|
|
22
|
+
return 'string' === typeof value ? value : undefined;
|
|
23
23
|
}
|
|
24
24
|
function num(value) {
|
|
25
|
-
return typeof value
|
|
25
|
+
return 'number' === typeof value ? value : undefined;
|
|
26
26
|
}
|
|
27
27
|
function record(value) {
|
|
28
|
-
return typeof value
|
|
28
|
+
return 'object' === typeof value && value !== null ? value : {};
|
|
29
29
|
}
|
|
30
30
|
function sourceLocation(value) {
|
|
31
|
-
if (typeof value !== 'object' ||
|
|
31
|
+
if (typeof value !== 'object' || null === value)
|
|
32
32
|
return undefined;
|
|
33
33
|
const obj = value;
|
|
34
34
|
if (typeof obj['file'] !== 'string' || typeof obj['line'] !== 'number')
|
|
@@ -49,15 +49,20 @@ function queryFromArgs(args) {
|
|
|
49
49
|
component: str(args['component']),
|
|
50
50
|
scope: str(args['scope']),
|
|
51
51
|
attrs: Array.isArray(args['attrs'])
|
|
52
|
-
? args['attrs'].filter((a) => typeof a
|
|
52
|
+
? args['attrs'].filter((a) => 'string' === typeof a)
|
|
53
53
|
: undefined,
|
|
54
54
|
source: sourceLocation(args['source']),
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
function inspect(ref) {
|
|
58
58
|
const el = refs.resolve(ref);
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
// THROW, exactly as executeAction does. Returning an error payload made it a SUCCESSFUL command
|
|
60
|
+
// result, which the server then handed to reticle_inspect's outputSchema (ref/role/name/states/
|
|
61
|
+
// visible all required) — so the MCP layer answered -32602 Output validation error for the most
|
|
62
|
+
// ordinary thing that follows a click. The wording matches actions.ts because the server's
|
|
63
|
+
// recovery table keys off /no longer resolves to an element/ to attach the stale-ref recovery.
|
|
64
|
+
if (null === el)
|
|
65
|
+
throw new Error(`ref '${echoRef(ref)}' no longer resolves to an element`);
|
|
61
66
|
const rect = el.getBoundingClientRect();
|
|
62
67
|
const component = identifyComponent(el);
|
|
63
68
|
const view = el.ownerDocument.defaultView;
|
|
@@ -104,7 +109,7 @@ function isOccluded(el, rect) {
|
|
|
104
109
|
}
|
|
105
110
|
/** Narrowing guard: an adapter returned a ComponentStateResult (has a boolean `ok`). */
|
|
106
111
|
function isComponentStateResult(value) {
|
|
107
|
-
return (typeof value
|
|
112
|
+
return ('object' === typeof value && value !== null && 'ok' in value && 'boolean' === typeof value.ok);
|
|
108
113
|
}
|
|
109
114
|
const COMPONENT_UNAVAILABLE = {
|
|
110
115
|
ok: false,
|
|
@@ -158,7 +163,7 @@ function readState(ref, store, path, depth) {
|
|
|
158
163
|
result.truncation = truncation;
|
|
159
164
|
if (ref !== undefined && ref.length > 0) {
|
|
160
165
|
const el = refs.resolve(ref);
|
|
161
|
-
if (
|
|
166
|
+
if (null === el) {
|
|
162
167
|
result.component = COMPONENT_UNAVAILABLE;
|
|
163
168
|
}
|
|
164
169
|
else {
|
|
@@ -168,6 +173,19 @@ function readState(ref, store, path, depth) {
|
|
|
168
173
|
}
|
|
169
174
|
return result;
|
|
170
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Every animation, and WHICH element it drives.
|
|
178
|
+
*
|
|
179
|
+
* Reported from the field: a page with nine concurrent animations returned nine indistinguishable
|
|
180
|
+
* `{playState, currentTime, duration}` rows. `a.effect` was read only for `getTiming()`, never for
|
|
181
|
+
* its `target` — so the tool advertised "targets/timing" and shipped timing alone. A list you cannot
|
|
182
|
+
* index is not a list, and the question this tool exists to answer is "is THAT thing still
|
|
183
|
+
* animating".
|
|
184
|
+
*
|
|
185
|
+
* `describe()` is reused rather than inventing a target shape: it is what `reticle_query` returns,
|
|
186
|
+
* so the descriptor carries a `ref` the agent can pass straight to `reticle_act` — a target you
|
|
187
|
+
* cannot act on would be half an answer.
|
|
188
|
+
*/
|
|
171
189
|
function listAnimations() {
|
|
172
190
|
const doc = document;
|
|
173
191
|
if (typeof doc.getAnimations !== 'function')
|
|
@@ -175,20 +193,25 @@ function listAnimations() {
|
|
|
175
193
|
const animations = doc.getAnimations().map((a) => {
|
|
176
194
|
const effect = a.effect;
|
|
177
195
|
const timing = effect?.getTiming();
|
|
196
|
+
// A KeyframeEffect's target is legitimately nullable, and only KeyframeEffect has one at all.
|
|
197
|
+
// `null` is reported explicitly: omitting the key would make "this animation has no element"
|
|
198
|
+
// indistinguishable from the bug above, which is the distinction the fix exists to draw.
|
|
199
|
+
const target = effect?.target ?? null;
|
|
178
200
|
return {
|
|
179
201
|
playState: a.playState,
|
|
180
202
|
currentTime: a.currentTime,
|
|
181
203
|
duration: timing?.duration,
|
|
204
|
+
target: null === target ? null : describe(target),
|
|
182
205
|
};
|
|
183
206
|
});
|
|
184
207
|
return { animations };
|
|
185
208
|
}
|
|
186
209
|
export function resolveNavigationUrl(rawUrl, baseUrl) {
|
|
187
|
-
if (rawUrl.length
|
|
210
|
+
if (0 === rawUrl.length || rawUrl.length > TRANSPORT_LIMITS.MAX_URL_LENGTH)
|
|
188
211
|
return null;
|
|
189
212
|
try {
|
|
190
213
|
const url = new URL(rawUrl, baseUrl);
|
|
191
|
-
return
|
|
214
|
+
return 'http:' === url.protocol || 'https:' === url.protocol ? url.toString() : null;
|
|
192
215
|
}
|
|
193
216
|
catch {
|
|
194
217
|
return null;
|
|
@@ -203,14 +226,14 @@ export function createCommandRegistry() {
|
|
|
203
226
|
}));
|
|
204
227
|
reg.set(ReticleCommand.QUERY, (args) => {
|
|
205
228
|
const limit = args['limit'];
|
|
206
|
-
return runQuery(queryFromArgs(args), typeof limit
|
|
229
|
+
return runQuery(queryFromArgs(args), 'number' === typeof limit ? limit : undefined);
|
|
207
230
|
});
|
|
208
231
|
reg.set(ReticleCommand.MATCH, (args) => matchQuery(ElementQuerySchema.parse(record(args['query'])), str(args['state'])));
|
|
209
232
|
reg.set(ReticleCommand.ACT, (args) => {
|
|
210
233
|
const action = str(args['action']) ?? '';
|
|
211
234
|
if (action === ActionType.WEBMCP) {
|
|
212
235
|
const inner = record(args['args']);
|
|
213
|
-
return dispatchWebMcp(str(inner['tool']) ?? '', record(inner['params']), inner[DANGEROUS_ACTION_CONFIRM_ARG]
|
|
236
|
+
return dispatchWebMcp(str(inner['tool']) ?? '', record(inner['params']), true === inner[DANGEROUS_ACTION_CONFIRM_ARG]);
|
|
214
237
|
}
|
|
215
238
|
return executeAction(str(args['ref']) ?? '', action, record(args['args']));
|
|
216
239
|
});
|
|
@@ -218,14 +241,14 @@ export function createCommandRegistry() {
|
|
|
218
241
|
reg.set(ReticleCommand.INSPECT, (args) => inspect(str(args['ref']) ?? ''));
|
|
219
242
|
reg.set(ReticleCommand.ANIMATIONS, () => listAnimations());
|
|
220
243
|
reg.set(ReticleCommand.CLOCK, (args) => {
|
|
221
|
-
if (args['reset']
|
|
244
|
+
if (true === args['reset']) {
|
|
222
245
|
resetClock();
|
|
223
246
|
}
|
|
224
247
|
else {
|
|
225
|
-
if (args['freeze']
|
|
248
|
+
if (true === args['freeze'])
|
|
226
249
|
freezeClock();
|
|
227
250
|
const adv = args['advanceMs'];
|
|
228
|
-
if (typeof adv
|
|
251
|
+
if ('number' === typeof adv)
|
|
229
252
|
advanceClock(adv);
|
|
230
253
|
}
|
|
231
254
|
return { frozen: isClockFrozen() };
|
|
@@ -233,24 +256,24 @@ export function createCommandRegistry() {
|
|
|
233
256
|
reg.set(ReticleCommand.STATE_READ, (args) => readState(str(args['ref']), str(args['store']), str(args['path']), num(args['depth'])));
|
|
234
257
|
reg.set(ReticleCommand.STORAGE_READ, (args) => readStorage(str(args['area'])));
|
|
235
258
|
reg.set(ReticleCommand.CAPABILITIES, () => getCapabilities());
|
|
236
|
-
reg.set(ReticleCommand.CAPTURE, (args) => captureDesktopWindow(args['fullPage']
|
|
259
|
+
reg.set(ReticleCommand.CAPTURE, (args) => captureDesktopWindow(true === args['fullPage']));
|
|
237
260
|
reg.set(ReticleCommand.SCROLL, (args) => {
|
|
238
261
|
const dy = args['dy'];
|
|
239
262
|
const fraction = args['fraction'];
|
|
240
|
-
return scrollContainer(str(args['ref']), typeof dy
|
|
263
|
+
return scrollContainer(str(args['ref']), 'number' === typeof dy ? dy : undefined, 'number' === typeof fraction ? fraction : undefined);
|
|
241
264
|
});
|
|
242
265
|
reg.set(ReticleCommand.NAVIGATE, (args) => {
|
|
243
266
|
const rawUrl = str(args['url']);
|
|
244
|
-
if (rawUrl === undefined || rawUrl.length
|
|
267
|
+
if (rawUrl === undefined || 0 === rawUrl.length)
|
|
245
268
|
return { ok: false, reason: 'url required' };
|
|
246
269
|
const url = resolveNavigationUrl(rawUrl, window.location.href);
|
|
247
|
-
if (
|
|
270
|
+
if (null === url)
|
|
248
271
|
return { ok: false, reason: 'only http(s) navigation is allowed' };
|
|
249
272
|
window.location.assign(url);
|
|
250
273
|
return { ok: true, url };
|
|
251
274
|
});
|
|
252
275
|
reg.set(ReticleCommand.REFRESH, (args) => {
|
|
253
|
-
if (args['hard']
|
|
276
|
+
if (true === args['hard']) {
|
|
254
277
|
// Hard reload: navigate to self with a cache-busting param then replace history.
|
|
255
278
|
const url = new URL(window.location.href);
|
|
256
279
|
url.searchParams.set(RELOAD_CACHE_BUST_PARAM, String(Date.now()));
|
|
@@ -45,6 +45,12 @@ export interface ReticleConnectOptions {
|
|
|
45
45
|
* and the TypeScript cast that needed shipped into a `.jsx` file and broke the build.
|
|
46
46
|
*/
|
|
47
47
|
root?: string;
|
|
48
|
+
/**
|
|
49
|
+
* The SDK's own package version, so a version-skewed pair against the daemon can name itself
|
|
50
|
+
* rather than surfacing as a bare `-32000`. Build plugins supply it automatically; pass it here
|
|
51
|
+
* only for a hand-wired connect that has no plugin.
|
|
52
|
+
*/
|
|
53
|
+
sdkVersion?: string;
|
|
48
54
|
/** Presenter mode: glow border, animated cursor, click/hover effects, narration HUD. */
|
|
49
55
|
present?: boolean;
|
|
50
56
|
/** Per-action pacing (ms) in presenter mode so a human can follow. Default 450. */
|
package/dist/dom/a11y.js
CHANGED
|
@@ -45,15 +45,15 @@ function inputRole(input) {
|
|
|
45
45
|
const type = input.type.toLowerCase();
|
|
46
46
|
if (INPUT_TEXT_TYPES.has(type))
|
|
47
47
|
return 'textbox';
|
|
48
|
-
if (
|
|
48
|
+
if ('checkbox' === type)
|
|
49
49
|
return 'checkbox';
|
|
50
|
-
if (
|
|
50
|
+
if ('radio' === type)
|
|
51
51
|
return 'radio';
|
|
52
|
-
if (
|
|
52
|
+
if ('range' === type)
|
|
53
53
|
return 'slider';
|
|
54
|
-
if (
|
|
54
|
+
if ('number' === type)
|
|
55
55
|
return 'spinbutton';
|
|
56
|
-
if (
|
|
56
|
+
if ('submit' === type || 'button' === type || 'reset' === type)
|
|
57
57
|
return 'button';
|
|
58
58
|
return 'textbox';
|
|
59
59
|
}
|
|
@@ -115,7 +115,7 @@ function collapse(text) {
|
|
|
115
115
|
}
|
|
116
116
|
function labelledByText(el) {
|
|
117
117
|
const ids = el.getAttribute('aria-labelledby');
|
|
118
|
-
if (
|
|
118
|
+
if (null === ids)
|
|
119
119
|
return null;
|
|
120
120
|
const parts = [];
|
|
121
121
|
for (const id of ids.split(/\s+/)) {
|
|
@@ -144,7 +144,7 @@ function textWithoutHidden(node) {
|
|
|
144
144
|
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
145
145
|
return '';
|
|
146
146
|
const el = node;
|
|
147
|
-
if (el.getAttribute('aria-hidden')
|
|
147
|
+
if ('true' === el.getAttribute('aria-hidden'))
|
|
148
148
|
return '';
|
|
149
149
|
let out = '';
|
|
150
150
|
for (const child of el.childNodes)
|
|
@@ -191,9 +191,9 @@ export function getAccessibleName(el) {
|
|
|
191
191
|
}
|
|
192
192
|
function ariaBool(el, attr) {
|
|
193
193
|
const value = el.getAttribute(attr);
|
|
194
|
-
if (
|
|
194
|
+
if (null === value)
|
|
195
195
|
return undefined;
|
|
196
|
-
return
|
|
196
|
+
return 'true' === value;
|
|
197
197
|
}
|
|
198
198
|
/**
|
|
199
199
|
* The set of states relevant to assertions. `visible` is an O(depth) forced-style walk; callers that
|
|
@@ -203,12 +203,12 @@ export function getStates(el, visible = isVisible(el)) {
|
|
|
203
203
|
const states = [ElementState.PRESENT];
|
|
204
204
|
states.push(visible ? ElementState.VISIBLE : ElementState.HIDDEN);
|
|
205
205
|
const disabledProp = (isButton(el) || isInput(el) || isSelect(el) || isTextArea(el)) && el.disabled;
|
|
206
|
-
const disabled = disabledProp || ariaBool(el, 'aria-disabled')
|
|
206
|
+
const disabled = disabledProp || true === ariaBool(el, 'aria-disabled');
|
|
207
207
|
states.push(disabled ? ElementState.DISABLED : ElementState.ENABLED);
|
|
208
|
-
const checkedProp = isInput(el) && (
|
|
209
|
-
if (checkedProp || ariaBool(el, 'aria-checked')
|
|
208
|
+
const checkedProp = isInput(el) && ('checkbox' === el.type || 'radio' === el.type) && el.checked;
|
|
209
|
+
if (checkedProp || true === ariaBool(el, 'aria-checked'))
|
|
210
210
|
states.push(ElementState.CHECKED);
|
|
211
|
-
if (ariaBool(el, 'aria-expanded')
|
|
211
|
+
if (true === ariaBool(el, 'aria-expanded'))
|
|
212
212
|
states.push(ElementState.EXPANDED);
|
|
213
213
|
if (el.ownerDocument.activeElement === el)
|
|
214
214
|
states.push(ElementState.FOCUSED);
|
|
@@ -232,7 +232,7 @@ export function isSensitiveField(el) {
|
|
|
232
232
|
el.getAttribute('aria-label') ?? '',
|
|
233
233
|
];
|
|
234
234
|
const sensitiveAutocomplete = /current-password|new-password|cc-number|cc-csc|one-time-code/i.test(autocomplete);
|
|
235
|
-
return ((isInput(el) && el.type.toLowerCase()
|
|
235
|
+
return ((isInput(el) && 'password' === el.type.toLowerCase()) ||
|
|
236
236
|
sensitiveAutocomplete ||
|
|
237
237
|
identifiers.some(isSensitiveKey));
|
|
238
238
|
}
|
|
@@ -247,19 +247,19 @@ export function getValue(el) {
|
|
|
247
247
|
}
|
|
248
248
|
/** Whether the element's OWN box hides it — one forced-style resolution, no ancestor walk. */
|
|
249
249
|
function selfHidden(el) {
|
|
250
|
-
if (el.getAttribute('aria-hidden')
|
|
250
|
+
if ('true' === el.getAttribute('aria-hidden'))
|
|
251
251
|
return true;
|
|
252
252
|
if (isHtmlElement(el) && el.hidden)
|
|
253
253
|
return true;
|
|
254
254
|
const view = el.ownerDocument.defaultView;
|
|
255
255
|
if (view !== null) {
|
|
256
256
|
const style = view.getComputedStyle(el);
|
|
257
|
-
if (style.display
|
|
258
|
-
style.visibility
|
|
259
|
-
style.visibility
|
|
257
|
+
if ('none' === style.display ||
|
|
258
|
+
'hidden' === style.visibility ||
|
|
259
|
+
'collapse' === style.visibility) {
|
|
260
260
|
return true;
|
|
261
261
|
}
|
|
262
|
-
if (Number.parseFloat(style.opacity || '1')
|
|
262
|
+
if (0 === Number.parseFloat(style.opacity || '1'))
|
|
263
263
|
return true;
|
|
264
264
|
}
|
|
265
265
|
return false;
|
|
@@ -282,7 +282,7 @@ export function isVisible(el, memo) {
|
|
|
282
282
|
// Each cached boolean already folds in that node's own aria-hidden/[hidden]/display/visibility/opacity,
|
|
283
283
|
// so inherited visibility composes by AND up the chain and a sibling short-circuits at the first
|
|
284
284
|
// cached ancestor.
|
|
285
|
-
const result = !selfHidden(el) && (
|
|
285
|
+
const result = !selfHidden(el) && (null === parent || isVisible(parent, memo));
|
|
286
286
|
if (memo !== undefined)
|
|
287
287
|
memo.set(el, result);
|
|
288
288
|
return result;
|
package/dist/dom/auto-anchor.js
CHANGED
|
@@ -32,7 +32,7 @@ export const AnchorStrategy = {
|
|
|
32
32
|
POSITION: 'position',
|
|
33
33
|
};
|
|
34
34
|
function nonEmpty(s) {
|
|
35
|
-
return typeof s
|
|
35
|
+
return 'string' === typeof s && s.length > 0;
|
|
36
36
|
}
|
|
37
37
|
/** Source as a compact `file:line` suffix; basename only to stay terse and path-independent. */
|
|
38
38
|
function sourceTag(source) {
|
package/dist/dom/blind-spots.js
CHANGED
|
@@ -10,7 +10,7 @@ export function classifyCrossOriginFrames(frames) {
|
|
|
10
10
|
}
|
|
11
11
|
/** Probe the live document: count iframes whose document is unreachable (cross-origin). */
|
|
12
12
|
export function countCrossOriginFrames() {
|
|
13
|
-
if (typeof document
|
|
13
|
+
if ('undefined' === typeof document)
|
|
14
14
|
return 0;
|
|
15
15
|
const frames = [];
|
|
16
16
|
for (const frame of document.querySelectorAll('iframe')) {
|
package/dist/dom/chart.js
CHANGED
|
@@ -54,12 +54,12 @@ export function hasNonFiniteCoordinate(value) {
|
|
|
54
54
|
/** True when the geometry encloses no area — no coordinates, or every coordinate pair identical. */
|
|
55
55
|
export function isDegenerate(value) {
|
|
56
56
|
const nums = geometryNumbers(value).filter((n) => Number.isFinite(n));
|
|
57
|
-
if (nums.length
|
|
57
|
+
if (0 === nums.length)
|
|
58
58
|
return true;
|
|
59
59
|
// A polyline of one repeated point draws nothing. Compare pairs, not raw numbers, so a legitimately
|
|
60
60
|
// flat line (varying x, constant y) is NOT flagged — that is a real chart of constant data.
|
|
61
|
-
const xs = nums.filter((_, i) => i % 2
|
|
62
|
-
const ys = nums.filter((_, i) => i % 2
|
|
61
|
+
const xs = nums.filter((_, i) => 0 === i % 2);
|
|
62
|
+
const ys = nums.filter((_, i) => 1 === i % 2);
|
|
63
63
|
const allSame = (arr) => arr.every((v) => v === arr[0]);
|
|
64
64
|
return xs.length > 1 && allSame(xs) && allSame(ys);
|
|
65
65
|
}
|
|
@@ -74,14 +74,14 @@ export function inspectChart(root) {
|
|
|
74
74
|
for (const el of Array.from(root.querySelectorAll(tag))) {
|
|
75
75
|
for (const attr of GEOMETRY_ATTRS) {
|
|
76
76
|
const value = el.getAttribute(attr);
|
|
77
|
-
if (
|
|
77
|
+
if (null === value)
|
|
78
78
|
continue;
|
|
79
79
|
examined += 1;
|
|
80
80
|
const sample = value.length > SAMPLE_MAX ? `${value.slice(0, SAMPLE_MAX)}…` : value;
|
|
81
81
|
if (hasNonFiniteCoordinate(value)) {
|
|
82
82
|
findings.push({ kind: 'non-finite-coordinates', tag, attr, sample });
|
|
83
83
|
}
|
|
84
|
-
else if (value.trim().length
|
|
84
|
+
else if (0 === value.trim().length) {
|
|
85
85
|
findings.push({ kind: 'empty-geometry', tag, attr, sample });
|
|
86
86
|
}
|
|
87
87
|
else if (isDegenerate(value)) {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* (`reticle-tauri`). Absent that, this returns `{ ok: false }` and the tool reports no-provider
|
|
21
21
|
* rather than guessing.
|
|
22
22
|
*/
|
|
23
|
-
|
|
23
|
+
interface CaptureResult {
|
|
24
24
|
ok: boolean;
|
|
25
25
|
/**
|
|
26
26
|
* Filesystem path of the captured PNG, written by the desktop shell.
|
|
@@ -34,3 +34,4 @@ export interface CaptureResult {
|
|
|
34
34
|
reason?: string;
|
|
35
35
|
}
|
|
36
36
|
export declare function captureDesktopWindow(fullPage?: boolean): Promise<CaptureResult>;
|
|
37
|
+
export {};
|
|
@@ -48,8 +48,8 @@ function tauriCapture() {
|
|
|
48
48
|
return undefined;
|
|
49
49
|
const invoke = internals.invoke.bind(internals);
|
|
50
50
|
return async (fullPage) => {
|
|
51
|
-
const path = await invoke(RETICLE_TAURI_CAPTURE_COMMAND, { fullPage:
|
|
52
|
-
return typeof path
|
|
51
|
+
const path = await invoke(RETICLE_TAURI_CAPTURE_COMMAND, { fullPage: true === fullPage });
|
|
52
|
+
return 'string' === typeof path ? path : null;
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
/**
|
|
@@ -94,13 +94,13 @@ async function withReticleUiHidden(capture) {
|
|
|
94
94
|
}
|
|
95
95
|
export async function captureDesktopWindow(fullPage = false) {
|
|
96
96
|
const channel = window[RETICLE_IPC_GLOBAL];
|
|
97
|
-
const capture = typeof channel?.capture
|
|
97
|
+
const capture = 'function' === typeof channel?.capture ? channel.capture : tauriCapture();
|
|
98
98
|
if (capture === undefined) {
|
|
99
99
|
return { ok: false, reason: 'no desktop capture helper installed' };
|
|
100
100
|
}
|
|
101
101
|
try {
|
|
102
102
|
const path = await withReticleUiHidden(() => capture(fullPage));
|
|
103
|
-
return typeof path
|
|
103
|
+
return 'string' === typeof path && path.length > 0
|
|
104
104
|
? { ok: true, path }
|
|
105
105
|
: { ok: false, reason: 'capture returned no image' };
|
|
106
106
|
}
|
package/dist/dom/occlusion.js
CHANGED
|
@@ -7,13 +7,13 @@ import { isReticleUi } from './dom-ignore.js';
|
|
|
7
7
|
* we never false-positive an occlusion. Reticle's own HUD is never treated as an occluder.
|
|
8
8
|
*/
|
|
9
9
|
export function hitTestOccluder(el, rect) {
|
|
10
|
-
if (rect.width
|
|
10
|
+
if (0 === rect.width || 0 === rect.height)
|
|
11
11
|
return null;
|
|
12
12
|
const doc = el.ownerDocument;
|
|
13
13
|
if (typeof doc.elementFromPoint !== 'function')
|
|
14
14
|
return null;
|
|
15
15
|
const top = doc.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
|
16
|
-
if (
|
|
16
|
+
if (null === top || isReticleUi(top))
|
|
17
17
|
return null;
|
|
18
18
|
const lands = top === el || el.contains(top) || top.contains(el);
|
|
19
19
|
return lands ? null : top;
|
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:
|
|
@@ -106,7 +141,13 @@ function findIn(container, query) {
|
|
|
106
141
|
// value is the component name;.source (if present) still takes precedence inside.
|
|
107
142
|
return findByComponent(container, { ...query, component: query.component ?? value });
|
|
108
143
|
default:
|
|
109
|
-
return []
|
|
144
|
+
// THROW, never `return []`. An unsupported strategy answering "no matches" is
|
|
145
|
+
// indistinguishable from the element genuinely being absent, so `by:'css'` — the first thing
|
|
146
|
+
// anyone arriving from Playwright reaches for — reported a page with a <body> as empty. The
|
|
147
|
+
// server now rejects unknown strategies at the schema; this is the same guarantee for every
|
|
148
|
+
// other path in (replay, reticle_run, an internal caller), because a false negative invented
|
|
149
|
+
// by the tool is the exact failure this product exists to prevent.
|
|
150
|
+
throw new Error(`unsupported query strategy '${String(by)}' — use one of: ${Object.values(QueryBy).join(', ')}`);
|
|
110
151
|
}
|
|
111
152
|
}
|
|
112
153
|
// Auto-anchor (component / source) — checked before the role/text fields so a query carrying
|
|
@@ -114,12 +155,10 @@ function findIn(container, query) {
|
|
|
114
155
|
if (query.component !== undefined || query.source !== undefined) {
|
|
115
156
|
return findByComponent(container, query);
|
|
116
157
|
}
|
|
117
|
-
// 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.
|
|
118
160
|
if (query.role !== undefined) {
|
|
119
|
-
|
|
120
|
-
? { hidden: true, name: query.name }
|
|
121
|
-
: { hidden: true };
|
|
122
|
-
return queryAllByRole(container, query.role, options);
|
|
161
|
+
return queryByRoleAndName(container, query.role, query.name);
|
|
123
162
|
}
|
|
124
163
|
if (query.text !== undefined)
|
|
125
164
|
return queryAllByText(container, query.text, { exact: false });
|
|
@@ -184,7 +223,7 @@ function embeddedRootsUnder(root) {
|
|
|
184
223
|
if (!isFrame(el))
|
|
185
224
|
continue;
|
|
186
225
|
const body = readableFrameBody(el);
|
|
187
|
-
if (
|
|
226
|
+
if (null === body)
|
|
188
227
|
continue; // cross-origin — declared, not searched
|
|
189
228
|
found.push(body);
|
|
190
229
|
walk(body, depth + 1);
|
|
@@ -218,7 +257,7 @@ function findCandidates(query) {
|
|
|
218
257
|
const { container, scopeMissing } = resolveContainer(query.scope);
|
|
219
258
|
// A given-but-missing scope searches NOTHING — never the whole page. The empty result plus the
|
|
220
259
|
// scopeMissing flag is what keeps "gone scope" distinct from "absent element".
|
|
221
|
-
if (
|
|
260
|
+
if (null === container)
|
|
222
261
|
return { candidates: [], scopeMissing: true };
|
|
223
262
|
const seen = new Set();
|
|
224
263
|
const out = [];
|
|
@@ -259,7 +298,7 @@ function projectAttrs(el, keys) {
|
|
|
259
298
|
const out = {};
|
|
260
299
|
for (const key of keys.slice(0, ATTR_KEYS_MAX)) {
|
|
261
300
|
const raw = el.getAttribute(key);
|
|
262
|
-
if (
|
|
301
|
+
if (null === raw)
|
|
263
302
|
continue;
|
|
264
303
|
out[key] = isSensitiveKey(key) ? REDACTED_VALUE : raw.slice(0, ATTR_VALUE_MAX);
|
|
265
304
|
}
|
|
@@ -286,16 +325,15 @@ const MAX_DESCRIBED = TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS;
|
|
|
286
325
|
* "how many?" stay exact while the answer stays affordable.
|
|
287
326
|
*/
|
|
288
327
|
export function matchQuery(query, state, limit = MAX_DESCRIBED) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
328
|
+
// NO blanket try/catch here. It used to turn ANY exception during candidate-finding into
|
|
329
|
+
// `elements = []`, which is the same lie as the `default` arm above: a query that could not run
|
|
330
|
+
// reported that the element is not on the page. The one failure it was plausibly guarding —
|
|
331
|
+
// a scope selector that matches nothing — is already handled explicitly and distinctly, as
|
|
332
|
+
// `scopeMissing`, so what remained was a net that could only convert real faults into false
|
|
333
|
+
// negatives.
|
|
334
|
+
const found = findCandidates(query);
|
|
335
|
+
const elements = found.candidates;
|
|
336
|
+
const scopeMissing = found.scopeMissing;
|
|
299
337
|
// One visibility cache for the whole (synchronous) query pass. isVisible is an O(depth) forced-style
|
|
300
338
|
// walk; the state filter runs it over EVERY candidate (the count must be exact) — on a match-heavy
|
|
301
339
|
// page (e.g. a 3k-row grid) that is tens of thousands of getComputedStyle calls on the host's main
|
|
@@ -306,7 +344,7 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED) {
|
|
|
306
344
|
const described = filtered.slice(0, Math.max(0, Math.min(limit, MAX_DESCRIBED)));
|
|
307
345
|
const descriptors = described.map((el) => {
|
|
308
346
|
const base = describe(el, visMemo);
|
|
309
|
-
if (attrs === undefined || attrs.length
|
|
347
|
+
if (attrs === undefined || 0 === attrs.length)
|
|
310
348
|
return base;
|
|
311
349
|
const projected = projectAttrs(el, attrs);
|
|
312
350
|
return projected === undefined ? base : { ...base, attrs: projected };
|
|
@@ -316,13 +354,18 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED) {
|
|
|
316
354
|
count: filtered.length,
|
|
317
355
|
elements: descriptors,
|
|
318
356
|
...(scopeMissing ? { scopeMissing: true } : {}),
|
|
357
|
+
// On a MISS, carry the same diagnosis `runQuery` has always returned. MATCH is the command every
|
|
358
|
+
// PREDICATE uses, so without this a failed assertion was a dead end ("no element matched") while
|
|
359
|
+
// the identical failure through reticle_query listed the testids that ARE present. Computed only
|
|
360
|
+
// when there is nothing to report, so the hot path pays nothing.
|
|
361
|
+
...(0 === filtered.length ? { hint: buildEmptyHint(query) } : {}),
|
|
319
362
|
};
|
|
320
363
|
}
|
|
321
364
|
/** Resolve `aria-labelledby` (one or more element IDs) to the referenced elements' text — a bare ID is
|
|
322
365
|
* not a human-readable name. Undefined when unset or nothing resolves. */
|
|
323
366
|
function resolveLabelledBy(el) {
|
|
324
367
|
const ids = el.getAttribute('aria-labelledby');
|
|
325
|
-
if (
|
|
368
|
+
if (null === ids)
|
|
326
369
|
return undefined;
|
|
327
370
|
const text = ids
|
|
328
371
|
.split(/\s+/)
|
|
@@ -426,8 +469,8 @@ function buildEmptyHint(query) {
|
|
|
426
469
|
*/
|
|
427
470
|
export function runQuery(query, limit) {
|
|
428
471
|
const result = matchQuery(query, undefined, limit);
|
|
429
|
-
const scopeFields = result.scopeMissing
|
|
430
|
-
if (result.elements.length
|
|
472
|
+
const scopeFields = true === result.scopeMissing ? { scopeMissing: true } : {};
|
|
473
|
+
if (0 === result.elements.length) {
|
|
431
474
|
return {
|
|
432
475
|
elements: result.elements,
|
|
433
476
|
count: result.count,
|