@reticlehq/browser 3.6.0 → 3.8.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/action-report.d.ts +64 -0
- package/dist/actions/action-report.js +87 -0
- package/dist/actions/actions-dom.d.ts +5 -0
- package/dist/actions/actions-dom.js +32 -4
- package/dist/actions/actions-press.d.ts +1 -0
- package/dist/actions/actions-press.js +25 -1
- package/dist/actions/actions.d.ts +2 -37
- package/dist/actions/actions.js +56 -80
- package/dist/actions/appeared-text.js +5 -2
- package/dist/actions/click-target.d.ts +1 -0
- package/dist/actions/click-target.js +14 -0
- package/dist/actions/danger-context.d.ts +4 -1
- package/dist/actions/danger-context.js +45 -4
- package/dist/actions/value-input.d.ts +1 -0
- package/dist/actions/value-input.js +11 -0
- package/dist/activate.d.ts +14 -0
- package/dist/activate.js +43 -0
- package/dist/busy.d.ts +3 -0
- package/dist/busy.js +42 -0
- package/dist/commands/commands.js +49 -17
- package/dist/connect-options.d.ts +12 -1
- package/dist/connect-options.js +18 -1
- package/dist/dom/a11y.d.ts +2 -0
- package/dist/dom/a11y.js +353 -80
- package/dist/dom/addressing/coverage-markers.d.ts +6 -0
- package/dist/dom/addressing/coverage-markers.js +13 -0
- package/dist/dom/chart.d.ts +3 -2
- package/dist/dom/chart.js +3 -2
- package/dist/dom/dom-ignore.d.ts +8 -0
- package/dist/dom/dom-ignore.js +57 -2
- package/dist/dom/layout-audit.d.ts +15 -0
- package/dist/dom/layout-audit.js +208 -0
- package/dist/dom/paint-context.d.ts +15 -0
- package/dist/dom/paint-context.js +78 -0
- package/dist/dom/query.d.ts +2 -1
- package/dist/dom/query.js +163 -38
- package/dist/dom/render-page.d.ts +12 -0
- package/dist/dom/render-page.js +135 -0
- package/dist/dom/shadow-registry.d.ts +1 -0
- package/dist/dom/shadow-registry.js +27 -0
- package/dist/dom/snapshot.d.ts +5 -0
- package/dist/dom/snapshot.js +135 -36
- package/dist/dom/stable-selector.d.ts +1 -0
- package/dist/dom/stable-selector.js +19 -0
- package/dist/dom/text-match.d.ts +12 -0
- package/dist/dom/text-match.js +115 -0
- package/dist/dom/theme.d.ts +1 -0
- package/dist/dom/theme.js +21 -9
- package/dist/dom/virtualized.js +5 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -0
- package/dist/observers/blind-spots.js +3 -2
- package/dist/observers/canvas.d.ts +18 -0
- package/dist/observers/canvas.js +76 -0
- package/dist/observers/dom.js +96 -32
- package/dist/observers/field.d.ts +11 -0
- package/dist/observers/field.js +116 -30
- package/dist/observers/install-all.js +4 -0
- package/dist/observers/media.d.ts +2 -0
- package/dist/observers/media.js +32 -0
- package/dist/observers/net-detail/network-body.d.ts +2 -0
- package/dist/observers/net-detail/network-body.js +8 -1
- package/dist/observers/network.js +82 -13
- package/dist/observers/perf.js +53 -1
- package/dist/observers/route.d.ts +2 -0
- package/dist/observers/route.js +39 -9
- package/dist/presenter/carousel/carousel.js +2 -7
- package/dist/presenter/carousel/panel-slides.d.ts +2 -4
- package/dist/presenter/carousel/panel-slides.js +17 -15
- package/dist/presenter/chrome/log-memory.d.ts +1 -0
- package/dist/presenter/chrome/presenter-log.d.ts +7 -0
- package/dist/presenter/chrome/presenter-log.js +48 -8
- package/dist/presenter/chrome/presenter-morph.d.ts +1 -0
- package/dist/presenter/chrome/presenter-morph.js +34 -0
- package/dist/presenter/chrome/presenter-report-copy.d.ts +12 -4
- package/dist/presenter/chrome/presenter-report-copy.js +12 -4
- package/dist/presenter/hud-telemetry.js +55 -4
- package/dist/presenter/presenter-account-styles.js +3 -3
- package/dist/presenter/presenter-account.d.ts +27 -5
- package/dist/presenter/presenter-account.js +83 -6
- package/dist/presenter/presenter-agent-link.d.ts +9 -0
- package/dist/presenter/presenter-agent-link.js +62 -0
- package/dist/presenter/presenter-chat-views.d.ts +25 -4
- package/dist/presenter/presenter-chat-views.js +339 -72
- package/dist/presenter/presenter-config.d.ts +3 -0
- package/dist/presenter/presenter-config.js +2 -0
- package/dist/presenter/presenter-controls.d.ts +0 -1
- package/dist/presenter/presenter-controls.js +13 -91
- package/dist/presenter/presenter-design-tokens.js +1 -1
- package/dist/presenter/presenter-frame.d.ts +3 -2
- package/dist/presenter/presenter-frame.js +31 -24
- package/dist/presenter/presenter-harness-row.d.ts +56 -0
- package/dist/presenter/presenter-harness-row.js +170 -0
- package/dist/presenter/presenter-personas.d.ts +35 -0
- package/dist/presenter/presenter-personas.js +95 -0
- package/dist/presenter/presenter-plan.js +25 -24
- package/dist/presenter/presenter-report-styles.js +23 -22
- package/dist/presenter/presenter-report.js +29 -13
- package/dist/presenter/presenter-run-state.js +2 -1
- package/dist/presenter/presenter-settings-styles.js +8 -4
- package/dist/presenter/presenter-settings.d.ts +4 -4
- package/dist/presenter/presenter-settings.js +66 -28
- package/dist/presenter/presenter-shell-styles.js +12 -3
- package/dist/presenter/presenter-shell.d.ts +11 -3
- package/dist/presenter/presenter-shell.js +60 -22
- package/dist/presenter/presenter-styles.js +0 -2
- package/dist/presenter/presenter-workspace.js +10 -3
- package/dist/presenter/presenter.d.ts +3 -2
- package/dist/presenter/presenter.js +107 -25
- package/dist/registry/declared-platform.js +2 -0
- package/dist/registry/store-adapters.d.ts +5 -2
- package/dist/registry/store-adapters.js +40 -10
- package/dist/reticle-inject.js +771 -703
- package/dist/reticle.d.ts +4 -2
- package/dist/reticle.js +72 -8
- package/dist/security/page-location.d.ts +2 -0
- package/dist/security/page-location.js +7 -0
- package/dist/security/serialization.js +1 -1
- package/dist/storage-keys.d.ts +4 -2
- package/dist/storage-keys.js +8 -3
- package/dist/timers/native/native-timers.d.ts +1 -0
- package/dist/timers/native/native-timers.js +5 -0
- package/dist/transport/transport.d.ts +1 -0
- package/dist/transport/transport.js +23 -3
- package/dist/transport/unreachable-message.d.ts +1 -1
- package/dist/transport/unreachable-message.js +3 -5
- package/package.json +6 -2
- package/dist/presenter/carousel/offer-card.d.ts +0 -21
- package/dist/presenter/carousel/offer-card.js +0 -56
package/dist/dom/query.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { ALTERNATIVE_TESTID_ATTRS, DEFAULT_TESTID_ATTR, DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
|
|
2
2
|
import { isActionTarget, isElement, isFrame, isInput, isSelect, isTextArea } from './realm.js';
|
|
3
3
|
import { capturedRootOf } from './shadow-registry.js';
|
|
4
|
-
import { getAccessibleName, getRole, describe, getStates, isInViewport, isVisible, } from './a11y.js';
|
|
5
|
-
import { isIgnored, isReticleOverlay } from './dom-ignore.js';
|
|
4
|
+
import { getAccessibleName, getRole, describe, getStates, isHiddenByAriaOnly, isInViewport, isRenderedOutsideAriaHidden, isVisible, } from './a11y.js';
|
|
5
|
+
import { dialogCovering, isIgnored, isReticleOverlay, isWordsIgnored } from './dom-ignore.js';
|
|
6
6
|
import { isSensitiveKey } from '../security/serialization.js';
|
|
7
7
|
import { declaredTestids } from '../registry/capabilities.js';
|
|
8
8
|
import { identifyComponent } from '../registry/stores/adapters.js';
|
|
9
9
|
import { refs } from './addressing/refs.js';
|
|
10
10
|
import { getTestIdAttr, readTestId, testIdSelector } from './addressing/testid-attr.js';
|
|
11
|
+
import { stableSelectorFor } from './stable-selector.js';
|
|
12
|
+
import { exactVisibleText, fuzzyVisibleText, normaliseVisibleText, comparableText, foldTypography, paintedCase, textMatches, unreadableMatch, } from './text-match.js';
|
|
13
|
+
import { pageRoute } from '../security/page-location.js';
|
|
11
14
|
const SOURCE_ATTR = DATA_RETICLE_SOURCE_ATTR;
|
|
12
15
|
const MAX_PRESENT_TESTIDS = 12;
|
|
13
16
|
const MAX_COMPONENT_CANDIDATES = 2000;
|
|
@@ -16,17 +19,6 @@ function elementsUnder(container) {
|
|
|
16
19
|
const self = Node.ELEMENT_NODE === container.nodeType ? [container] : [];
|
|
17
20
|
return [...self, ...Array.from(container.querySelectorAll('*'))];
|
|
18
21
|
}
|
|
19
|
-
function normaliseVisibleText(value) {
|
|
20
|
-
return value.replace(/\s+/g, ' ').trim().normalize('NFC');
|
|
21
|
-
}
|
|
22
|
-
function fuzzyVisibleText(actual, expected) {
|
|
23
|
-
return normaliseVisibleText(actual)
|
|
24
|
-
.toLowerCase()
|
|
25
|
-
.includes(normaliseVisibleText(expected).toLowerCase());
|
|
26
|
-
}
|
|
27
|
-
function exactVisibleText(actual, expected) {
|
|
28
|
-
return normaliseVisibleText(actual) === normaliseVisibleText(expected);
|
|
29
|
-
}
|
|
30
22
|
const NON_RENDERED_TAGS = ['script', 'style', 'template', 'noscript'];
|
|
31
23
|
function isNonRendered(el) {
|
|
32
24
|
return NON_RENDERED_TAGS.includes(el.tagName.toLowerCase());
|
|
@@ -45,17 +37,29 @@ function directText(el) {
|
|
|
45
37
|
.join('');
|
|
46
38
|
}
|
|
47
39
|
function visibleTextOf(el, memo) {
|
|
48
|
-
if (isNonRendered(el) ||
|
|
40
|
+
if (isNonRendered(el) || isWordsIgnored(el))
|
|
49
41
|
return '';
|
|
42
|
+
const visible = isVisible(el, memo);
|
|
43
|
+
const apart = laysOutApart(el);
|
|
50
44
|
let text = '';
|
|
51
45
|
for (const node of Array.from(el.childNodes)) {
|
|
52
|
-
if (Node.TEXT_NODE === node.nodeType)
|
|
46
|
+
if (Node.TEXT_NODE === node.nodeType && visible)
|
|
53
47
|
text += node.textContent ?? '';
|
|
54
|
-
else if (isElement(node))
|
|
55
|
-
|
|
48
|
+
else if (isElement(node)) {
|
|
49
|
+
const own = visibleTextOf(node, memo);
|
|
50
|
+
text += apart || ownBox(node) ? ` ${own} ` : own;
|
|
51
|
+
}
|
|
56
52
|
}
|
|
57
53
|
return text;
|
|
58
54
|
}
|
|
55
|
+
function laysOutApart(el) {
|
|
56
|
+
const display = el.ownerDocument.defaultView?.getComputedStyle(el).display ?? '';
|
|
57
|
+
return display.includes('flex') || display.includes('grid');
|
|
58
|
+
}
|
|
59
|
+
function ownBox(el) {
|
|
60
|
+
const display = el.ownerDocument.defaultView?.getComputedStyle(el).display ?? 'inline';
|
|
61
|
+
return !display.startsWith('inline') && 'contents' !== display;
|
|
62
|
+
}
|
|
59
63
|
function semanticNameTarget(el) {
|
|
60
64
|
if (isInput(el) || isTextArea(el) || isSelect(el))
|
|
61
65
|
return true;
|
|
@@ -122,15 +126,62 @@ function matchesRole(actual, queried) {
|
|
|
122
126
|
return true;
|
|
123
127
|
return false;
|
|
124
128
|
}
|
|
129
|
+
const OPTION_ROLE = 'option';
|
|
130
|
+
const HTML_OPTION_TAG = 'option';
|
|
131
|
+
const HTML_SELECT_TAG = 'select';
|
|
132
|
+
const VISUALLY_HIDDEN_MAX_PX = 1;
|
|
133
|
+
export function isHiddenNativeOption(el) {
|
|
134
|
+
if (HTML_OPTION_TAG !== el.localName)
|
|
135
|
+
return false;
|
|
136
|
+
const select = el.closest(HTML_SELECT_TAG);
|
|
137
|
+
if (!isSelect(select))
|
|
138
|
+
return false;
|
|
139
|
+
if (!isVisible(select))
|
|
140
|
+
return true;
|
|
141
|
+
return isVisuallyHiddenBox(select);
|
|
142
|
+
}
|
|
143
|
+
function isVisuallyHiddenBox(select) {
|
|
144
|
+
const box = select.getBoundingClientRect();
|
|
145
|
+
return (box.width > 0 &&
|
|
146
|
+
box.height > 0 &&
|
|
147
|
+
box.width <= VISUALLY_HIDDEN_MAX_PX &&
|
|
148
|
+
box.height <= VISUALLY_HIDDEN_MAX_PX);
|
|
149
|
+
}
|
|
150
|
+
function preferVisibleActionableOption(role, matches) {
|
|
151
|
+
if (OPTION_ROLE !== role)
|
|
152
|
+
return matches;
|
|
153
|
+
const kept = matches.filter((el) => !isHiddenNativeOption(el));
|
|
154
|
+
return kept.length === matches.length ? matches : kept;
|
|
155
|
+
}
|
|
125
156
|
function queryByRoleAndName(container, role, name) {
|
|
126
|
-
|
|
157
|
+
const matched = elementsUnder(container).filter((el) => matchesRole(getRole(el), role) &&
|
|
127
158
|
(name === undefined || exactVisibleText(getAccessibleName(el), name)));
|
|
159
|
+
return preferVisibleActionableOption(role, matched);
|
|
128
160
|
}
|
|
129
|
-
function queryByText(container, value) {
|
|
130
|
-
return elementsUnder(container).filter((el) =>
|
|
161
|
+
function queryByText(container, value, query) {
|
|
162
|
+
return elementsUnder(container).filter((el) => {
|
|
163
|
+
const own = directText(el);
|
|
164
|
+
if (textMatches(own, query, value))
|
|
165
|
+
return true;
|
|
166
|
+
if (true !== query.matchCase || !fuzzyVisibleText(own, value))
|
|
167
|
+
return false;
|
|
168
|
+
const painted = paintedCase(el, own);
|
|
169
|
+
return painted !== undefined && textMatches(painted, query, value);
|
|
170
|
+
});
|
|
131
171
|
}
|
|
132
172
|
function queryByLabel(container, value) {
|
|
133
|
-
|
|
173
|
+
const expected = comparableText(value).toLowerCase();
|
|
174
|
+
const exact = [];
|
|
175
|
+
const fuzzy = [];
|
|
176
|
+
for (const el of elementsUnder(container)) {
|
|
177
|
+
if (!semanticNameTarget(el))
|
|
178
|
+
continue;
|
|
179
|
+
const name = comparableText(getAccessibleName(el)).toLowerCase();
|
|
180
|
+
if (!name.includes(expected))
|
|
181
|
+
continue;
|
|
182
|
+
(name === expected ? exact : fuzzy).push(el);
|
|
183
|
+
}
|
|
184
|
+
return exact.length > 0 ? exact : fuzzy;
|
|
134
185
|
}
|
|
135
186
|
function queryByPlaceholder(container, value) {
|
|
136
187
|
return elementsUnder(container).filter((el) => {
|
|
@@ -155,7 +206,7 @@ function findIn(container, query) {
|
|
|
155
206
|
case QueryBy.ROLE:
|
|
156
207
|
return queryByRoleAndName(container, value, query.name);
|
|
157
208
|
case QueryBy.TEXT:
|
|
158
|
-
return queryByText(container, value);
|
|
209
|
+
return queryByText(container, value, query);
|
|
159
210
|
case QueryBy.LABEL:
|
|
160
211
|
return queryByLabel(container, value);
|
|
161
212
|
case QueryBy.PLACEHOLDER:
|
|
@@ -177,7 +228,7 @@ function findIn(container, query) {
|
|
|
177
228
|
return queryByRoleAndName(container, query.role, query.name);
|
|
178
229
|
}
|
|
179
230
|
if (query.text !== undefined)
|
|
180
|
-
return queryByText(container, query.text);
|
|
231
|
+
return queryByText(container, query.text, query);
|
|
181
232
|
if (query.label !== undefined)
|
|
182
233
|
return queryByLabel(container, query.label);
|
|
183
234
|
if (query.placeholder !== undefined)
|
|
@@ -229,18 +280,19 @@ function findCandidates(query) {
|
|
|
229
280
|
if (query.scope === undefined)
|
|
230
281
|
return { candidates: [], scopeMissing };
|
|
231
282
|
if (query.text !== undefined &&
|
|
232
|
-
!
|
|
283
|
+
!textMatches(visibleTextOf(container, new Map()), query, query.text)) {
|
|
233
284
|
return { candidates: [], scopeMissing };
|
|
234
285
|
}
|
|
235
286
|
return { candidates: isIgnored(container) ? [] : [container], scopeMissing };
|
|
236
287
|
}
|
|
237
288
|
const seen = new Set();
|
|
238
289
|
const out = [];
|
|
290
|
+
const ignored = wantedTextOf(query) === undefined ? isIgnored : isWordsIgnored;
|
|
239
291
|
const collect = (els) => {
|
|
240
292
|
for (const el of els) {
|
|
241
293
|
if (seen.has(el))
|
|
242
294
|
continue;
|
|
243
|
-
if (
|
|
295
|
+
if (ignored(el))
|
|
244
296
|
continue;
|
|
245
297
|
seen.add(el);
|
|
246
298
|
out.push(el);
|
|
@@ -269,10 +321,13 @@ function inState(el, state, memo) {
|
|
|
269
321
|
return getStates(el, isVisible(el, memo)).includes(state);
|
|
270
322
|
}
|
|
271
323
|
const MAX_DESCRIBED = TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS;
|
|
272
|
-
export function matchQuery(query, state, limit = MAX_DESCRIBED) {
|
|
324
|
+
export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = true, diagnose = true) {
|
|
273
325
|
const found = findCandidates(query);
|
|
274
|
-
const elements = found.candidates;
|
|
275
326
|
const scopeMissing = found.scopeMissing;
|
|
327
|
+
const wanted = wholePhrase && textOnly(query) ? wantedTextOf(query) : undefined;
|
|
328
|
+
const scope = 0 === found.candidates.length ? resolveContainer(query.scope).container : null;
|
|
329
|
+
const owner = null === scope || wanted === undefined ? undefined : splitTextOwner(scope, wanted, query);
|
|
330
|
+
const elements = owner === undefined ? found.candidates : [owner];
|
|
276
331
|
const visMemo = new Map();
|
|
277
332
|
const filtered = state === undefined ? elements : elements.filter((el) => inState(el, state, visMemo));
|
|
278
333
|
const attrs = query.attrs;
|
|
@@ -290,12 +345,16 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED) {
|
|
|
290
345
|
const projected = projectAttrs(el, attrs);
|
|
291
346
|
return projected === undefined ? base : { ...base, attrs: projected };
|
|
292
347
|
});
|
|
348
|
+
const covered = dialogCovering(filtered);
|
|
349
|
+
const unreadable = wantedTextOf(query) === undefined ? undefined : unreadableMatch(described);
|
|
293
350
|
return {
|
|
294
351
|
matched: filtered.length > 0,
|
|
295
352
|
count: filtered.length,
|
|
296
353
|
elements: descriptors,
|
|
297
354
|
...(scopeMissing ? { scopeMissing: true } : {}),
|
|
298
|
-
...(
|
|
355
|
+
...(covered === undefined ? {} : { coveredBy: covered }),
|
|
356
|
+
...(unreadable === undefined ? {} : { unreadable }),
|
|
357
|
+
...(0 === filtered.length ? { hint: buildEmptyHint(query, state, diagnose) } : {}),
|
|
299
358
|
};
|
|
300
359
|
}
|
|
301
360
|
function resolveLabelledBy(el) {
|
|
@@ -363,14 +422,14 @@ function buildPresentRegions(query) {
|
|
|
363
422
|
}
|
|
364
423
|
return regions;
|
|
365
424
|
}
|
|
366
|
-
function splitTextOwner(container, wanted) {
|
|
425
|
+
function splitTextOwner(container, wanted, query) {
|
|
367
426
|
let best;
|
|
368
427
|
let bestDepth = -1;
|
|
369
428
|
const memo = new Map();
|
|
370
429
|
for (const el of elementsUnder(container)) {
|
|
371
430
|
if (isIgnored(el))
|
|
372
431
|
continue;
|
|
373
|
-
if (!
|
|
432
|
+
if (!textMatches(visibleTextOf(el, memo), query, wanted))
|
|
374
433
|
continue;
|
|
375
434
|
let depth = 0;
|
|
376
435
|
for (let parent = el.parentElement; parent !== null; parent = parent.parentElement)
|
|
@@ -382,6 +441,32 @@ function splitTextOwner(container, wanted) {
|
|
|
382
441
|
}
|
|
383
442
|
return best;
|
|
384
443
|
}
|
|
444
|
+
function caseOnlyMatch(container, wanted) {
|
|
445
|
+
const target = comparableText(wanted).toLowerCase();
|
|
446
|
+
if (0 === target.length)
|
|
447
|
+
return undefined;
|
|
448
|
+
const memo = new Map();
|
|
449
|
+
for (const el of elementsUnder(container)) {
|
|
450
|
+
if (isIgnored(el))
|
|
451
|
+
continue;
|
|
452
|
+
const text = normaliseVisibleText(visibleTextOf(el, memo));
|
|
453
|
+
const at = foldTypography(text).toLowerCase().indexOf(target);
|
|
454
|
+
if (-1 !== at)
|
|
455
|
+
return text.slice(at, at + target.length);
|
|
456
|
+
}
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
const TEXT_ONLY_FIELDS = new Set([
|
|
460
|
+
'text',
|
|
461
|
+
'matchCase',
|
|
462
|
+
'scope',
|
|
463
|
+
'by',
|
|
464
|
+
'value',
|
|
465
|
+
]);
|
|
466
|
+
function textOnly(query) {
|
|
467
|
+
return ((query.by === undefined || QueryBy.TEXT === query.by) &&
|
|
468
|
+
Object.keys(query).every((key) => TEXT_ONLY_FIELDS.has(key)));
|
|
469
|
+
}
|
|
385
470
|
function wantedTextOf(query) {
|
|
386
471
|
if (query.text !== undefined)
|
|
387
472
|
return query.text;
|
|
@@ -393,7 +478,7 @@ function nameNearMisses(container, query) {
|
|
|
393
478
|
const wanted = query.name;
|
|
394
479
|
if (role === undefined || wanted === undefined || 0 === wanted.length)
|
|
395
480
|
return [];
|
|
396
|
-
const target =
|
|
481
|
+
const target = comparableText(wanted).toLowerCase();
|
|
397
482
|
if (0 === target.length)
|
|
398
483
|
return [];
|
|
399
484
|
const out = [];
|
|
@@ -403,7 +488,7 @@ function nameNearMisses(container, query) {
|
|
|
403
488
|
const name = normaliseVisibleText(getAccessibleName(el));
|
|
404
489
|
if (0 === name.length)
|
|
405
490
|
continue;
|
|
406
|
-
const folded = name.toLowerCase();
|
|
491
|
+
const folded = foldTypography(name).toLowerCase();
|
|
407
492
|
if (!folded.includes(target) && !target.includes(folded))
|
|
408
493
|
continue;
|
|
409
494
|
if (!out.includes(name))
|
|
@@ -426,7 +511,32 @@ function testidFoundUnder(container, query) {
|
|
|
426
511
|
}
|
|
427
512
|
return undefined;
|
|
428
513
|
}
|
|
429
|
-
function
|
|
514
|
+
function textInAriaHidden(container, wanted) {
|
|
515
|
+
let inAriaHidden = false;
|
|
516
|
+
for (const el of elementsUnder(container)) {
|
|
517
|
+
if (isIgnored(el))
|
|
518
|
+
continue;
|
|
519
|
+
if (!isHiddenByAriaOnly(el))
|
|
520
|
+
continue;
|
|
521
|
+
if (fuzzyVisibleText(el.textContent ?? '', wanted)) {
|
|
522
|
+
inAriaHidden = true;
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (!inAriaHidden)
|
|
527
|
+
return false;
|
|
528
|
+
const memo = new Map();
|
|
529
|
+
for (const el of elementsUnder(container)) {
|
|
530
|
+
if (isIgnored(el))
|
|
531
|
+
continue;
|
|
532
|
+
if (!isRenderedOutsideAriaHidden(el))
|
|
533
|
+
continue;
|
|
534
|
+
if (fuzzyVisibleText(visibleTextOf(el, memo), wanted))
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
function buildEmptyHint(query, state, diagnose = true) {
|
|
430
540
|
const container = resolveContainer(query.scope).container ?? document.body;
|
|
431
541
|
const all = container.querySelectorAll(testIdSelector());
|
|
432
542
|
const present = [];
|
|
@@ -443,7 +553,7 @@ function buildEmptyHint(query) {
|
|
|
443
553
|
}
|
|
444
554
|
const registered = declaredTestids();
|
|
445
555
|
const knownEmptyState = present.some((id) => registered.includes(id));
|
|
446
|
-
const route =
|
|
556
|
+
const route = pageRoute(false);
|
|
447
557
|
const hint = {
|
|
448
558
|
route,
|
|
449
559
|
presentTestids: present,
|
|
@@ -453,9 +563,24 @@ function buildEmptyHint(query) {
|
|
|
453
563
|
};
|
|
454
564
|
const wanted = wantedTextOf(query);
|
|
455
565
|
if (wanted !== undefined) {
|
|
456
|
-
const owner = splitTextOwner(container, wanted);
|
|
457
|
-
if (owner !== undefined)
|
|
458
|
-
|
|
566
|
+
const owner = splitTextOwner(container, wanted, query);
|
|
567
|
+
if (owner !== undefined) {
|
|
568
|
+
const described = describe(owner);
|
|
569
|
+
const selector = stableSelectorFor(owner);
|
|
570
|
+
hint.splitText = selector === undefined ? described : { ...described, selector };
|
|
571
|
+
}
|
|
572
|
+
else if (true === query.matchCase) {
|
|
573
|
+
const shown = caseOnlyMatch(container, wanted);
|
|
574
|
+
if (shown !== undefined)
|
|
575
|
+
hint.caseMiss = shown;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (diagnose &&
|
|
579
|
+
wanted !== undefined &&
|
|
580
|
+
hint.splitText === undefined &&
|
|
581
|
+
(state === undefined || ElementState.VISIBLE === state || ElementState.IN_VIEWPORT === state)) {
|
|
582
|
+
if (textInAriaHidden(container, wanted))
|
|
583
|
+
hint.ariaHiddenMatch = true;
|
|
459
584
|
}
|
|
460
585
|
const near = nameNearMisses(container, query);
|
|
461
586
|
if (near.length > 0)
|
|
@@ -469,7 +594,7 @@ export function elementsMatching(query) {
|
|
|
469
594
|
return findCandidates(query).candidates;
|
|
470
595
|
}
|
|
471
596
|
export function runQuery(query, limit) {
|
|
472
|
-
const result = matchQuery(query, undefined, limit);
|
|
597
|
+
const result = matchQuery(query, undefined, limit, false);
|
|
473
598
|
const scopeFields = true === result.scopeMissing ? { scopeMissing: true } : {};
|
|
474
599
|
if (0 === result.elements.length) {
|
|
475
600
|
return {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const MASK = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
2
|
+
export type RenderResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
chunks: string[];
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
reason: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function cloneForRender(doc: Document): HTMLElement;
|
|
12
|
+
export declare function renderPage(): Promise<RenderResult>;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { isSensitiveKey, STEP_SCREENSHOT } from '@reticlehq/core';
|
|
2
|
+
import { RETICLE_OVERLAY } from './dom-ignore.js';
|
|
3
|
+
export const MASK = '••••••••';
|
|
4
|
+
const BUDGET_CHARS = 240_000;
|
|
5
|
+
const CHUNK_CHARS = 60_000;
|
|
6
|
+
const SHRINK = 0.75;
|
|
7
|
+
const MAX_ATTEMPTS = 4;
|
|
8
|
+
const LOAD_TIMEOUT_MS = 5_000;
|
|
9
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
10
|
+
const FALLBACK_BACKGROUND = '#ffffff';
|
|
11
|
+
const JPEG_MIME = 'image/jpeg';
|
|
12
|
+
const DATA_URL_PREFIX = /^data:[^,]*,/;
|
|
13
|
+
function isSecretField(el) {
|
|
14
|
+
if (el instanceof HTMLInputElement && 'password' === el.type)
|
|
15
|
+
return true;
|
|
16
|
+
return [el.name, el.id, el.getAttribute('autocomplete') ?? ''].some((key) => key.length > 0 && isSensitiveKey(key));
|
|
17
|
+
}
|
|
18
|
+
function carryState(from, to) {
|
|
19
|
+
if (from instanceof HTMLInputElement || from instanceof HTMLTextAreaElement) {
|
|
20
|
+
const value = isSecretField(from) && from.value.length > 0 ? MASK : from.value;
|
|
21
|
+
if (from instanceof HTMLTextAreaElement)
|
|
22
|
+
to.textContent = value;
|
|
23
|
+
else
|
|
24
|
+
to.setAttribute('value', value);
|
|
25
|
+
}
|
|
26
|
+
if (from instanceof HTMLInputElement) {
|
|
27
|
+
if (from.checked)
|
|
28
|
+
to.setAttribute('checked', '');
|
|
29
|
+
else
|
|
30
|
+
to.removeAttribute('checked');
|
|
31
|
+
}
|
|
32
|
+
if (from instanceof HTMLOptionElement) {
|
|
33
|
+
if (from.selected)
|
|
34
|
+
to.setAttribute('selected', '');
|
|
35
|
+
else
|
|
36
|
+
to.removeAttribute('selected');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function readableCss(doc) {
|
|
40
|
+
const sheets = [...Array.from(doc.styleSheets), ...(doc.adoptedStyleSheets ?? [])];
|
|
41
|
+
const parts = [];
|
|
42
|
+
for (const sheet of sheets) {
|
|
43
|
+
try {
|
|
44
|
+
for (const rule of Array.from(sheet.cssRules))
|
|
45
|
+
parts.push(rule.cssText);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return parts.join('\n');
|
|
51
|
+
}
|
|
52
|
+
export function cloneForRender(doc) {
|
|
53
|
+
const root = doc.documentElement;
|
|
54
|
+
const clone = root.cloneNode(true);
|
|
55
|
+
const live = root.querySelectorAll('*');
|
|
56
|
+
const copied = clone.querySelectorAll('*');
|
|
57
|
+
live.forEach((el, i) => {
|
|
58
|
+
const twin = copied[i];
|
|
59
|
+
if (twin !== undefined)
|
|
60
|
+
carryState(el, twin);
|
|
61
|
+
});
|
|
62
|
+
const dropped = [RETICLE_OVERLAY, 'script', 'noscript', 'link[rel~="stylesheet"]', 'style'];
|
|
63
|
+
clone.querySelectorAll(dropped.join(',')).forEach((el) => el.remove());
|
|
64
|
+
const style = doc.createElement('style');
|
|
65
|
+
style.textContent = readableCss(doc);
|
|
66
|
+
(clone.querySelector('head') ?? clone).append(style);
|
|
67
|
+
return clone;
|
|
68
|
+
}
|
|
69
|
+
function backgroundOf(doc) {
|
|
70
|
+
const view = doc.defaultView;
|
|
71
|
+
for (const el of [doc.body, doc.documentElement]) {
|
|
72
|
+
if (null === view || null === el)
|
|
73
|
+
continue;
|
|
74
|
+
const colour = view.getComputedStyle(el).backgroundColor;
|
|
75
|
+
if (colour.length > 0 && 'transparent' !== colour && 'rgba(0, 0, 0, 0)' !== colour)
|
|
76
|
+
return colour;
|
|
77
|
+
}
|
|
78
|
+
return FALLBACK_BACKGROUND;
|
|
79
|
+
}
|
|
80
|
+
function loadImage(url) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const img = new Image();
|
|
83
|
+
const timer = setTimeout(() => reject(new Error('the page did not render in time')), LOAD_TIMEOUT_MS);
|
|
84
|
+
img.onload = () => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
resolve(img);
|
|
87
|
+
};
|
|
88
|
+
img.onerror = () => {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
reject(new Error('the page could not be drawn as an image'));
|
|
91
|
+
};
|
|
92
|
+
img.src = url;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function chunked(base64) {
|
|
96
|
+
const out = [];
|
|
97
|
+
for (let i = 0; i < base64.length; i += CHUNK_CHARS)
|
|
98
|
+
out.push(base64.slice(i, i + CHUNK_CHARS));
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
export async function renderPage() {
|
|
102
|
+
try {
|
|
103
|
+
const width = Math.max(1, window.innerWidth);
|
|
104
|
+
const height = Math.max(1, window.innerHeight);
|
|
105
|
+
const clone = cloneForRender(document);
|
|
106
|
+
clone.style.transform = `translate(${String(-window.scrollX)}px, ${String(-window.scrollY)}px)`;
|
|
107
|
+
const xhtml = new XMLSerializer().serializeToString(clone);
|
|
108
|
+
const svg = `<svg xmlns="${SVG_NS}" width="${String(width)}" height="${String(height)}">` +
|
|
109
|
+
`<foreignObject x="0" y="0" width="100%" height="100%">${xhtml}</foreignObject></svg>`;
|
|
110
|
+
const img = await loadImage(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`);
|
|
111
|
+
const background = backgroundOf(document);
|
|
112
|
+
let scale = Math.min(1, STEP_SCREENSHOT.MAX_SIDE_PX / Math.max(width, height));
|
|
113
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1, scale *= SHRINK) {
|
|
114
|
+
const canvas = document.createElement('canvas');
|
|
115
|
+
canvas.width = Math.max(1, Math.round(width * scale));
|
|
116
|
+
canvas.height = Math.max(1, Math.round(height * scale));
|
|
117
|
+
const ctx = canvas.getContext('2d');
|
|
118
|
+
if (null === ctx)
|
|
119
|
+
return { ok: false, reason: 'this page has no 2D canvas to draw into' };
|
|
120
|
+
ctx.fillStyle = background;
|
|
121
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
122
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
123
|
+
const base64 = canvas
|
|
124
|
+
.toDataURL(JPEG_MIME, STEP_SCREENSHOT.JPEG_QUALITY)
|
|
125
|
+
.replace(DATA_URL_PREFIX, '');
|
|
126
|
+
if (base64.length <= BUDGET_CHARS) {
|
|
127
|
+
return { ok: true, width: canvas.width, height: canvas.height, chunks: chunked(base64) };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { ok: false, reason: 'the rendered page was too large to send, even scaled down' };
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -5,3 +5,4 @@ export declare function isCaptured(host: Element): boolean;
|
|
|
5
5
|
export declare function capturedRootOf(host: Element): ShadowRoot | null;
|
|
6
6
|
export declare function onShadowRoot(listener: (root: ShadowRoot) => void): () => void;
|
|
7
7
|
export declare function installShadowRegistry(): () => void;
|
|
8
|
+
export declare function parentAcrossShadowBoundary(el: Element): Element | null;
|
|
@@ -93,3 +93,30 @@ export function installShadowRegistry() {
|
|
|
93
93
|
listeners.clear();
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
|
+
function slotInCapturedClosedRoot(el) {
|
|
97
|
+
const host = el.parentElement;
|
|
98
|
+
if (null === host || null !== host.shadowRoot) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const root = capturedRootOf(host);
|
|
102
|
+
if (null === root) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
for (const slot of Array.from(root.querySelectorAll('slot'))) {
|
|
106
|
+
if (slot.assignedElements().includes(el)) {
|
|
107
|
+
return slot;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
export function parentAcrossShadowBoundary(el) {
|
|
113
|
+
const slot = el.assignedSlot ?? slotInCapturedClosedRoot(el);
|
|
114
|
+
if (null !== slot) {
|
|
115
|
+
return slot;
|
|
116
|
+
}
|
|
117
|
+
if (null !== el.parentElement) {
|
|
118
|
+
return el.parentElement;
|
|
119
|
+
}
|
|
120
|
+
const host = el.getRootNode().host;
|
|
121
|
+
return host ?? null;
|
|
122
|
+
}
|
package/dist/dom/snapshot.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { SnapshotMode } from '@reticlehq/core';
|
|
2
|
+
import { type LayoutFinding } from './layout-audit.js';
|
|
3
|
+
export declare const INTERACTIVE: Set<string>;
|
|
2
4
|
interface SnapshotStatus {
|
|
3
5
|
route: string;
|
|
4
6
|
title: string;
|
|
@@ -11,6 +13,7 @@ interface SnapshotResult {
|
|
|
11
13
|
nodes: number;
|
|
12
14
|
truncated: boolean;
|
|
13
15
|
scopeMissing?: boolean;
|
|
16
|
+
audit?: LayoutFinding[];
|
|
14
17
|
leanSkipped?: number;
|
|
15
18
|
hiddenSkipped?: number;
|
|
16
19
|
domElements?: number;
|
|
@@ -23,6 +26,8 @@ interface SnapshotOptions {
|
|
|
23
26
|
maxNodes?: number | undefined;
|
|
24
27
|
maxDepth?: number | undefined;
|
|
25
28
|
includeRoot?: boolean | undefined;
|
|
29
|
+
ids?: boolean | undefined;
|
|
30
|
+
audit?: boolean | undefined;
|
|
26
31
|
}
|
|
27
32
|
export declare const MAX_UNREAD_BRANCHES = 50;
|
|
28
33
|
export declare function buildSnapshot(options?: SnapshotOptions): SnapshotResult;
|