@reticlehq/browser 3.7.0 → 3.8.1

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.
Files changed (104) hide show
  1. package/dist/actions/action-report.d.ts +10 -0
  2. package/dist/actions/action-report.js +59 -14
  3. package/dist/actions/actions-dom.d.ts +4 -0
  4. package/dist/actions/actions-dom.js +20 -4
  5. package/dist/actions/actions.d.ts +1 -0
  6. package/dist/actions/actions.js +94 -46
  7. package/dist/actions/appeared-text.js +2 -2
  8. package/dist/actions/click-target.d.ts +1 -0
  9. package/dist/actions/click-target.js +14 -0
  10. package/dist/actions/danger-context.d.ts +2 -1
  11. package/dist/actions/danger-context.js +43 -17
  12. package/dist/actions/scroll.d.ts +2 -0
  13. package/dist/actions/scroll.js +10 -2
  14. package/dist/activate.d.ts +14 -0
  15. package/dist/activate.js +43 -0
  16. package/dist/busy.d.ts +3 -0
  17. package/dist/busy.js +42 -0
  18. package/dist/commands/commands.js +45 -17
  19. package/dist/connect-options.d.ts +12 -1
  20. package/dist/connect-options.js +18 -1
  21. package/dist/dom/a11y.d.ts +0 -2
  22. package/dist/dom/a11y.js +103 -363
  23. package/dist/dom/chart.d.ts +3 -2
  24. package/dist/dom/chart.js +3 -2
  25. package/dist/dom/dom-ignore.d.ts +6 -0
  26. package/dist/dom/dom-ignore.js +60 -5
  27. package/dist/dom/layout-audit.d.ts +15 -0
  28. package/dist/dom/layout-audit.js +208 -0
  29. package/dist/dom/paint-context.d.ts +15 -0
  30. package/dist/dom/paint-context.js +78 -0
  31. package/dist/dom/query-finders.d.ts +6 -0
  32. package/dist/dom/query-finders.js +216 -0
  33. package/dist/dom/query.d.ts +1 -1
  34. package/dist/dom/query.js +154 -227
  35. package/dist/dom/shadow-registry.d.ts +1 -0
  36. package/dist/dom/shadow-registry.js +27 -0
  37. package/dist/dom/snapshot.d.ts +5 -0
  38. package/dist/dom/snapshot.js +87 -35
  39. package/dist/dom/stable-selector.d.ts +1 -0
  40. package/dist/dom/stable-selector.js +19 -0
  41. package/dist/dom/text-match.d.ts +12 -0
  42. package/dist/dom/text-match.js +115 -0
  43. package/dist/dom/theme.d.ts +1 -0
  44. package/dist/dom/theme.js +21 -9
  45. package/dist/dom/virtualized.js +5 -1
  46. package/dist/dom/visibility.d.ts +7 -0
  47. package/dist/dom/visibility.js +322 -0
  48. package/dist/index.d.ts +4 -2
  49. package/dist/index.js +7 -1
  50. package/dist/observers/blind-spots.js +3 -2
  51. package/dist/observers/canvas.d.ts +18 -0
  52. package/dist/observers/canvas.js +76 -0
  53. package/dist/observers/dom.js +68 -19
  54. package/dist/observers/field.d.ts +11 -0
  55. package/dist/observers/field.js +117 -13
  56. package/dist/observers/install-all.js +4 -0
  57. package/dist/observers/media.d.ts +2 -0
  58. package/dist/observers/media.js +32 -0
  59. package/dist/observers/net-detail/network-body.d.ts +2 -0
  60. package/dist/observers/net-detail/network-body.js +36 -9
  61. package/dist/observers/net-detail/network-event-stream.d.ts +5 -0
  62. package/dist/observers/net-detail/network-event-stream.js +55 -0
  63. package/dist/observers/net-detail/network-frames.d.ts +2 -0
  64. package/dist/observers/net-detail/network-frames.js +39 -0
  65. package/dist/observers/net-detail/network-response-body.d.ts +3 -0
  66. package/dist/observers/net-detail/network-response-body.js +97 -0
  67. package/dist/observers/network.js +111 -101
  68. package/dist/observers/route.d.ts +2 -0
  69. package/dist/observers/route.js +33 -4
  70. package/dist/presenter/chrome/presenter-report-copy.d.ts +0 -5
  71. package/dist/presenter/chrome/presenter-report-copy.js +0 -14
  72. package/dist/presenter/hud-telemetry.js +1 -1
  73. package/dist/presenter/presenter-chat-views.d.ts +3 -4
  74. package/dist/presenter/presenter-chat-views.js +47 -20
  75. package/dist/presenter/presenter-config.d.ts +1 -0
  76. package/dist/presenter/presenter-config.js +1 -0
  77. package/dist/presenter/presenter-frame.d.ts +3 -2
  78. package/dist/presenter/presenter-frame.js +24 -15
  79. package/dist/presenter/presenter-harness-row.d.ts +4 -15
  80. package/dist/presenter/presenter-harness-row.js +11 -50
  81. package/dist/presenter/presenter-report-styles.js +0 -17
  82. package/dist/presenter/presenter-report.d.ts +1 -2
  83. package/dist/presenter/presenter-report.js +4 -54
  84. package/dist/presenter/presenter-run-state.js +2 -1
  85. package/dist/presenter/presenter-settings.d.ts +2 -3
  86. package/dist/presenter/presenter-settings.js +14 -17
  87. package/dist/presenter/presenter-shell.d.ts +6 -4
  88. package/dist/presenter/presenter-shell.js +37 -7
  89. package/dist/presenter/presenter.js +32 -11
  90. package/dist/registry/store-adapters.d.ts +5 -2
  91. package/dist/registry/store-adapters.js +40 -10
  92. package/dist/reticle-inject.js +700 -645
  93. package/dist/reticle.d.ts +4 -2
  94. package/dist/reticle.js +36 -6
  95. package/dist/security/page-location.d.ts +2 -0
  96. package/dist/security/page-location.js +7 -0
  97. package/dist/security/serialization.js +1 -1
  98. package/dist/storage-keys.d.ts +1 -0
  99. package/dist/storage-keys.js +1 -0
  100. package/dist/timers/native/native-timers.d.ts +1 -0
  101. package/dist/timers/native/native-timers.js +5 -0
  102. package/dist/transport/transport.d.ts +1 -0
  103. package/dist/transport/transport.js +18 -0
  104. package/package.json +6 -2
package/dist/dom/query.js CHANGED
@@ -1,63 +1,29 @@
1
- import { ALTERNATIVE_TESTID_ATTRS, DEFAULT_TESTID_ATTR, DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
2
- import { isActionTarget, isElement, isFrame, isInput, isSelect, isTextArea } from './realm.js';
1
+ import { ALTERNATIVE_TESTID_ATTRS, DEFAULT_TESTID_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
2
+ import { isActionTarget, isElement, isFrame } 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 } from './a11y.js';
5
+ import { isHiddenByAriaOnly, isInViewport, isRenderedOutsideAriaHidden, isVisible, } from './visibility.js';
6
+ import { dialogCovering, isIgnored, isReticleOverlay, isWordsIgnored } from './dom-ignore.js';
6
7
  import { isSensitiveKey } from '../security/serialization.js';
7
8
  import { declaredTestids } from '../registry/capabilities.js';
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
- const SOURCE_ATTR = DATA_RETICLE_SOURCE_ATTR;
11
+ import { stableSelectorFor } from './stable-selector.js';
12
+ import { fuzzyVisibleText, normaliseVisibleText, comparableText, foldTypography, textMatches, unreadableMatch, } from './text-match.js';
13
+ import { pageRoute } from '../security/page-location.js';
14
+ import { elementsUnder, findIn, isNonRendered, queryByRoleAndName } from './query-finders.js';
12
15
  const MAX_PRESENT_TESTIDS = 12;
13
- const MAX_COMPONENT_CANDIDATES = 2000;
14
- const componentCandidateSelector = () => `[${SOURCE_ATTR}], ${testIdSelector()}, button, a, input, select, textarea, [role]`;
15
- function elementsUnder(container) {
16
- const self = Node.ELEMENT_NODE === container.nodeType ? [container] : [];
17
- return [...self, ...Array.from(container.querySelectorAll('*'))];
18
- }
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 textMatches(actual, query, expected) {
28
- if (true !== query.matchCase)
29
- return fuzzyVisibleText(actual, expected);
30
- return normaliseVisibleText(actual).includes(normaliseVisibleText(expected));
31
- }
32
- function exactVisibleText(actual, expected) {
33
- return normaliseVisibleText(actual) === normaliseVisibleText(expected);
34
- }
35
- const NON_RENDERED_TAGS = ['script', 'style', 'template', 'noscript'];
36
- function isNonRendered(el) {
37
- return NON_RENDERED_TAGS.includes(el.tagName.toLowerCase());
38
- }
39
- function directText(el) {
40
- if (isInput(el)) {
41
- const type = el.type.toLowerCase();
42
- if ('submit' === type || 'button' === type || 'reset' === type)
43
- return el.value;
44
- }
45
- if (isNonRendered(el))
46
- return '';
47
- return Array.from(el.childNodes)
48
- .filter((node) => Node.TEXT_NODE === node.nodeType)
49
- .map((node) => node.textContent ?? '')
50
- .join('');
51
- }
52
16
  function visibleTextOf(el, memo) {
53
- if (isNonRendered(el) || isIgnored(el))
17
+ if (isNonRendered(el) || isWordsIgnored(el)) {
54
18
  return '';
19
+ }
55
20
  const visible = isVisible(el, memo);
56
21
  const apart = laysOutApart(el);
57
22
  let text = '';
58
23
  for (const node of Array.from(el.childNodes)) {
59
- if (Node.TEXT_NODE === node.nodeType && visible)
24
+ if (Node.TEXT_NODE === node.nodeType && visible) {
60
25
  text += node.textContent ?? '';
26
+ }
61
27
  else if (isElement(node)) {
62
28
  const own = visibleTextOf(node, memo);
63
29
  text += apart || ownBox(node) ? ` ${own} ` : own;
@@ -73,149 +39,24 @@ function ownBox(el) {
73
39
  const display = el.ownerDocument.defaultView?.getComputedStyle(el).display ?? 'inline';
74
40
  return !display.startsWith('inline') && 'contents' !== display;
75
41
  }
76
- function semanticNameTarget(el) {
77
- if (isInput(el) || isTextArea(el) || isSelect(el))
78
- return true;
79
- const tag = el.tagName.toLowerCase();
80
- return ('button' === tag ||
81
- 'meter' === tag ||
82
- 'output' === tag ||
83
- 'progress' === tag ||
84
- el.hasAttribute('aria-label') ||
85
- el.hasAttribute('aria-labelledby'));
86
- }
87
42
  function resolveContainer(scope) {
88
- if (scope === undefined)
43
+ if (scope === undefined) {
89
44
  return { container: document.body, scopeMissing: false };
45
+ }
90
46
  const byRef = refs.resolve(scope);
91
- if (isActionTarget(byRef))
47
+ if (isActionTarget(byRef)) {
92
48
  return { container: byRef, scopeMissing: false };
49
+ }
93
50
  try {
94
51
  const found = document.querySelector(scope);
95
- if (isActionTarget(found))
52
+ if (isActionTarget(found)) {
96
53
  return { container: found, scopeMissing: false };
54
+ }
97
55
  }
98
56
  catch {
99
57
  }
100
58
  return { container: null, scopeMissing: true };
101
59
  }
102
- function findBySource(container, source) {
103
- const prefix = `${source.file}:${source.line}:`.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
104
- try {
105
- return Array.from(container.querySelectorAll(`[${SOURCE_ATTR}^="${prefix}"]`));
106
- }
107
- catch {
108
- return [];
109
- }
110
- }
111
- function findByComponentName(container, component) {
112
- const out = [];
113
- let scanned = 0;
114
- for (const el of Array.from(container.querySelectorAll(componentCandidateSelector()))) {
115
- if (scanned >= MAX_COMPONENT_CANDIDATES)
116
- break;
117
- scanned += 1;
118
- const info = identifyComponent(el);
119
- if (info !== null && info.componentStack[0] === component)
120
- out.push(el);
121
- }
122
- return out;
123
- }
124
- function findByComponent(container, query) {
125
- if (query.source !== undefined) {
126
- const bySource = findBySource(container, query.source);
127
- if (bySource.length > 0)
128
- return bySource;
129
- }
130
- if (query.component !== undefined && query.component.length > 0) {
131
- return findByComponentName(container, query.component);
132
- }
133
- return [];
134
- }
135
- function matchesRole(actual, queried) {
136
- if (actual === queried)
137
- return true;
138
- if ('textbox' === queried && 'searchbox' === actual)
139
- return true;
140
- return false;
141
- }
142
- function queryByRoleAndName(container, role, name) {
143
- return elementsUnder(container).filter((el) => matchesRole(getRole(el), role) &&
144
- (name === undefined || exactVisibleText(getAccessibleName(el), name)));
145
- }
146
- function queryByText(container, value, query) {
147
- return elementsUnder(container).filter((el) => textMatches(directText(el), query, value));
148
- }
149
- function queryByLabel(container, value) {
150
- const expected = normaliseVisibleText(value).toLowerCase();
151
- const exact = [];
152
- const fuzzy = [];
153
- for (const el of elementsUnder(container)) {
154
- if (!semanticNameTarget(el))
155
- continue;
156
- const name = normaliseVisibleText(getAccessibleName(el)).toLowerCase();
157
- if (!name.includes(expected))
158
- continue;
159
- (name === expected ? exact : fuzzy).push(el);
160
- }
161
- return exact.length > 0 ? exact : fuzzy;
162
- }
163
- function queryByPlaceholder(container, value) {
164
- return elementsUnder(container).filter((el) => {
165
- const placeholder = el.getAttribute('placeholder');
166
- return placeholder !== null && fuzzyVisibleText(placeholder, value);
167
- });
168
- }
169
- function queryByTestId(container, value) {
170
- return elementsUnder(container).filter((el) => readTestId(el) === value);
171
- }
172
- function queryByAlt(container, value) {
173
- return elementsUnder(container).filter((el) => {
174
- const alt = el.getAttribute('alt');
175
- return alt !== null && fuzzyVisibleText(alt, value);
176
- });
177
- }
178
- function findIn(container, query) {
179
- const by = query.by;
180
- const value = query.value;
181
- if (by !== undefined && value !== undefined) {
182
- switch (by) {
183
- case QueryBy.ROLE:
184
- return queryByRoleAndName(container, value, query.name);
185
- case QueryBy.TEXT:
186
- return queryByText(container, value, query);
187
- case QueryBy.LABEL:
188
- return queryByLabel(container, value);
189
- case QueryBy.PLACEHOLDER:
190
- return queryByPlaceholder(container, value);
191
- case QueryBy.TESTID:
192
- return queryByTestId(container, value);
193
- case QueryBy.ALT:
194
- return queryByAlt(container, value);
195
- case QueryBy.COMPONENT:
196
- return findByComponent(container, { ...query, component: query.component ?? value });
197
- default:
198
- throw new Error(`unsupported query strategy '${String(by)}' - use one of: ${Object.values(QueryBy).join(', ')}`);
199
- }
200
- }
201
- if (query.component !== undefined || query.source !== undefined) {
202
- return findByComponent(container, query);
203
- }
204
- if (query.role !== undefined) {
205
- return queryByRoleAndName(container, query.role, query.name);
206
- }
207
- if (query.text !== undefined)
208
- return queryByText(container, query.text, query);
209
- if (query.label !== undefined)
210
- return queryByLabel(container, query.label);
211
- if (query.placeholder !== undefined)
212
- return queryByPlaceholder(container, query.placeholder);
213
- if (query.testid !== undefined)
214
- return queryByTestId(container, query.testid);
215
- if (query.alt !== undefined)
216
- return queryByAlt(container, query.alt);
217
- return [];
218
- }
219
60
  function readableFrameBody(frame) {
220
61
  try {
221
62
  return frame.contentDocument?.body ?? null;
@@ -235,13 +76,16 @@ function embeddedRootsUnder(root) {
235
76
  walk(shadow, depth);
236
77
  continue;
237
78
  }
238
- if (depth >= FRAME_DEPTH_MAX)
79
+ if (depth >= FRAME_DEPTH_MAX) {
239
80
  continue;
240
- if (!isFrame(el))
81
+ }
82
+ if (!isFrame(el)) {
241
83
  continue;
84
+ }
242
85
  const body = readableFrameBody(el);
243
- if (null === body)
86
+ if (null === body) {
244
87
  continue;
88
+ }
245
89
  found.push(body);
246
90
  walk(body, depth + 1);
247
91
  }
@@ -251,11 +95,13 @@ function embeddedRootsUnder(root) {
251
95
  }
252
96
  function findCandidates(query) {
253
97
  const { container, scopeMissing } = resolveContainer(query.scope);
254
- if (null === container)
98
+ if (null === container) {
255
99
  return { candidates: [], scopeMissing: true };
100
+ }
256
101
  if (true === query.self) {
257
- if (query.scope === undefined)
102
+ if (query.scope === undefined) {
258
103
  return { candidates: [], scopeMissing };
104
+ }
259
105
  if (query.text !== undefined &&
260
106
  !textMatches(visibleTextOf(container, new Map()), query, query.text)) {
261
107
  return { candidates: [], scopeMissing };
@@ -264,19 +110,23 @@ function findCandidates(query) {
264
110
  }
265
111
  const seen = new Set();
266
112
  const out = [];
113
+ const ignored = wantedTextOf(query) === undefined ? isIgnored : isWordsIgnored;
267
114
  const collect = (els) => {
268
115
  for (const el of els) {
269
- if (seen.has(el))
116
+ if (seen.has(el)) {
270
117
  continue;
271
- if (isIgnored(el))
118
+ }
119
+ if (ignored(el)) {
272
120
  continue;
121
+ }
273
122
  seen.add(el);
274
123
  out.push(el);
275
124
  }
276
125
  };
277
126
  collect(findIn(container, query));
278
- for (const embedded of embeddedRootsUnder(container))
127
+ for (const embedded of embeddedRootsUnder(container)) {
279
128
  collect(findIn(embedded, query));
129
+ }
280
130
  return { candidates: out, scopeMissing };
281
131
  }
282
132
  export const ATTR_VALUE_MAX = 512;
@@ -285,19 +135,21 @@ function projectAttrs(el, keys) {
285
135
  const out = {};
286
136
  for (const key of keys.slice(0, ATTR_KEYS_MAX)) {
287
137
  const raw = el.getAttribute(key);
288
- if (null === raw)
138
+ if (null === raw) {
289
139
  continue;
140
+ }
290
141
  out[key] = isSensitiveKey(key) ? REDACTED_VALUE : raw.slice(0, ATTR_VALUE_MAX);
291
142
  }
292
143
  return Object.keys(out).length > 0 ? out : undefined;
293
144
  }
294
145
  function inState(el, state, memo) {
295
- if (ElementState.IN_VIEWPORT === state)
146
+ if (ElementState.IN_VIEWPORT === state) {
296
147
  return isInViewport(el, memo);
148
+ }
297
149
  return getStates(el, isVisible(el, memo)).includes(state);
298
150
  }
299
151
  const MAX_DESCRIBED = TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS;
300
- export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = true) {
152
+ export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = true, diagnose = true) {
301
153
  const found = findCandidates(query);
302
154
  const scopeMissing = found.scopeMissing;
303
155
  const wanted = wholePhrase && textOnly(query) ? wantedTextOf(query) : undefined;
@@ -316,23 +168,29 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = tr
316
168
  !base.states.includes(ElementState.IN_VIEWPORT)) {
317
169
  base = { ...base, states: [...base.states, ElementState.IN_VIEWPORT] };
318
170
  }
319
- if (attrs === undefined || 0 === attrs.length)
171
+ if (attrs === undefined || 0 === attrs.length) {
320
172
  return base;
173
+ }
321
174
  const projected = projectAttrs(el, attrs);
322
175
  return projected === undefined ? base : { ...base, attrs: projected };
323
176
  });
177
+ const covered = dialogCovering(filtered);
178
+ const unreadable = wantedTextOf(query) === undefined ? undefined : unreadableMatch(described);
324
179
  return {
325
180
  matched: filtered.length > 0,
326
181
  count: filtered.length,
327
182
  elements: descriptors,
328
183
  ...(scopeMissing ? { scopeMissing: true } : {}),
329
- ...(0 === filtered.length ? { hint: buildEmptyHint(query) } : {}),
184
+ ...(covered === undefined ? {} : { coveredBy: covered }),
185
+ ...(unreadable === undefined ? {} : { unreadable }),
186
+ ...(0 === filtered.length ? { hint: buildEmptyHint(query, state, diagnose) } : {}),
330
187
  };
331
188
  }
332
189
  function resolveLabelledBy(el) {
333
190
  const ids = el.getAttribute('aria-labelledby');
334
- if (null === ids)
191
+ if (null === ids) {
335
192
  return undefined;
193
+ }
336
194
  const text = ids
337
195
  .split(/\s+/)
338
196
  .map((id) => id.length > 0 ? (el.ownerDocument.getElementById(id)?.textContent?.trim() ?? '') : '')
@@ -364,8 +222,9 @@ function buildPresentRegions(query) {
364
222
  for (const role of CONTAINER_ROLES) {
365
223
  const containers = queryByRoleAndName(container, role, undefined);
366
224
  for (const el of containers) {
367
- if (isReticleOverlay(el))
225
+ if (isReticleOverlay(el)) {
368
226
  continue;
227
+ }
369
228
  const name = el.getAttribute('aria-label') ??
370
229
  resolveLabelledBy(el) ??
371
230
  readTestId(el) ??
@@ -373,8 +232,9 @@ function buildPresentRegions(query) {
373
232
  const children = el.querySelectorAll('[role]');
374
233
  const sample = [];
375
234
  for (const child of Array.from(children)) {
376
- if (sample.length >= 3)
235
+ if (sample.length >= 3) {
377
236
  break;
237
+ }
378
238
  const childRole = child.getAttribute('role');
379
239
  const childName = child.getAttribute('aria-label') ??
380
240
  readTestId(child) ??
@@ -385,11 +245,13 @@ function buildPresentRegions(query) {
385
245
  }
386
246
  }
387
247
  const region = { role, childCount: children.length, sample };
388
- if (name !== undefined && name.length > 0)
248
+ if (name !== undefined && name.length > 0) {
389
249
  region.name = name;
250
+ }
390
251
  regions.push(region);
391
- if (regions.length >= 10)
252
+ if (regions.length >= 10) {
392
253
  return regions;
254
+ }
393
255
  }
394
256
  }
395
257
  return regions;
@@ -399,13 +261,16 @@ function splitTextOwner(container, wanted, query) {
399
261
  let bestDepth = -1;
400
262
  const memo = new Map();
401
263
  for (const el of elementsUnder(container)) {
402
- if (isIgnored(el))
264
+ if (isIgnored(el)) {
403
265
  continue;
404
- if (!textMatches(visibleTextOf(el, memo), query, wanted))
266
+ }
267
+ if (!textMatches(visibleTextOf(el, memo), query, wanted)) {
405
268
  continue;
269
+ }
406
270
  let depth = 0;
407
- for (let parent = el.parentElement; parent !== null; parent = parent.parentElement)
271
+ for (let parent = el.parentElement; parent !== null; parent = parent.parentElement) {
408
272
  depth++;
273
+ }
409
274
  if (depth > bestDepth) {
410
275
  best = el;
411
276
  bestDepth = depth;
@@ -414,17 +279,20 @@ function splitTextOwner(container, wanted, query) {
414
279
  return best;
415
280
  }
416
281
  function caseOnlyMatch(container, wanted) {
417
- const target = normaliseVisibleText(wanted).toLowerCase();
418
- if (0 === target.length)
282
+ const target = comparableText(wanted).toLowerCase();
283
+ if (0 === target.length) {
419
284
  return undefined;
285
+ }
420
286
  const memo = new Map();
421
287
  for (const el of elementsUnder(container)) {
422
- if (isIgnored(el))
288
+ if (isIgnored(el)) {
423
289
  continue;
290
+ }
424
291
  const text = normaliseVisibleText(visibleTextOf(el, memo));
425
- const at = text.toLowerCase().indexOf(target);
426
- if (-1 !== at)
292
+ const at = foldTypography(text).toLowerCase().indexOf(target);
293
+ if (-1 !== at) {
427
294
  return text.slice(at, at + target.length);
295
+ }
428
296
  }
429
297
  return undefined;
430
298
  }
@@ -440,67 +308,112 @@ function textOnly(query) {
440
308
  Object.keys(query).every((key) => TEXT_ONLY_FIELDS.has(key)));
441
309
  }
442
310
  function wantedTextOf(query) {
443
- if (query.text !== undefined)
311
+ if (query.text !== undefined) {
444
312
  return query.text;
313
+ }
445
314
  return QueryBy.TEXT === query.by ? query.value : undefined;
446
315
  }
447
316
  const MAX_NAME_NEAR_MISSES = 5;
448
317
  function nameNearMisses(container, query) {
449
318
  const role = QueryBy.ROLE === query.by ? query.value : query.role;
450
319
  const wanted = query.name;
451
- if (role === undefined || wanted === undefined || 0 === wanted.length)
320
+ if (role === undefined || wanted === undefined || 0 === wanted.length) {
452
321
  return [];
453
- const target = normaliseVisibleText(wanted).toLowerCase();
454
- if (0 === target.length)
322
+ }
323
+ const target = comparableText(wanted).toLowerCase();
324
+ if (0 === target.length) {
455
325
  return [];
326
+ }
456
327
  const out = [];
457
328
  for (const el of elementsUnder(container)) {
458
- if (isIgnored(el) || getRole(el) !== role)
329
+ if (isIgnored(el) || getRole(el) !== role) {
459
330
  continue;
331
+ }
460
332
  const name = normaliseVisibleText(getAccessibleName(el));
461
- if (0 === name.length)
333
+ if (0 === name.length) {
462
334
  continue;
463
- const folded = name.toLowerCase();
464
- if (!folded.includes(target) && !target.includes(folded))
335
+ }
336
+ const folded = foldTypography(name).toLowerCase();
337
+ if (!folded.includes(target) && !target.includes(folded)) {
465
338
  continue;
466
- if (!out.includes(name))
339
+ }
340
+ if (!out.includes(name)) {
467
341
  out.push(name);
468
- if (out.length >= MAX_NAME_NEAR_MISSES)
342
+ }
343
+ if (out.length >= MAX_NAME_NEAR_MISSES) {
469
344
  break;
345
+ }
470
346
  }
471
347
  return out;
472
348
  }
473
349
  function testidFoundUnder(container, query) {
474
- if (QueryBy.TESTID !== query.by || undefined === query.value)
350
+ if (QueryBy.TESTID !== query.by || undefined === query.value) {
475
351
  return undefined;
352
+ }
476
353
  const configured = getTestIdAttr();
477
354
  for (const attr of [DEFAULT_TESTID_ATTR, ...ALTERNATIVE_TESTID_ATTRS]) {
478
- if (attr === configured)
355
+ if (attr === configured) {
479
356
  continue;
357
+ }
480
358
  const found = elementsUnder(container).some((el) => el.getAttribute(attr) === query.value && !isIgnored(el));
481
- if (found)
359
+ if (found) {
482
360
  return attr;
361
+ }
483
362
  }
484
363
  return undefined;
485
364
  }
486
- function buildEmptyHint(query) {
365
+ function textInAriaHidden(container, wanted) {
366
+ let inAriaHidden = false;
367
+ for (const el of elementsUnder(container)) {
368
+ if (isIgnored(el)) {
369
+ continue;
370
+ }
371
+ if (!isHiddenByAriaOnly(el)) {
372
+ continue;
373
+ }
374
+ if (fuzzyVisibleText(el.textContent ?? '', wanted)) {
375
+ inAriaHidden = true;
376
+ break;
377
+ }
378
+ }
379
+ if (!inAriaHidden) {
380
+ return false;
381
+ }
382
+ const memo = new Map();
383
+ for (const el of elementsUnder(container)) {
384
+ if (isIgnored(el)) {
385
+ continue;
386
+ }
387
+ if (!isRenderedOutsideAriaHidden(el)) {
388
+ continue;
389
+ }
390
+ if (fuzzyVisibleText(visibleTextOf(el, memo), wanted)) {
391
+ return false;
392
+ }
393
+ }
394
+ return true;
395
+ }
396
+ function buildEmptyHint(query, state, diagnose = true) {
487
397
  const container = resolveContainer(query.scope).container ?? document.body;
488
398
  const all = container.querySelectorAll(testIdSelector());
489
399
  const present = [];
490
400
  const seen = new Set();
491
401
  for (const el of Array.from(all)) {
492
- if (isIgnored(el))
402
+ if (isIgnored(el)) {
493
403
  continue;
404
+ }
494
405
  const id = readTestId(el);
495
- if (null === id || 0 === id.length || seen.has(id))
406
+ if (null === id || 0 === id.length || seen.has(id)) {
496
407
  continue;
408
+ }
497
409
  seen.add(id);
498
- if (present.length < MAX_PRESENT_TESTIDS)
410
+ if (present.length < MAX_PRESENT_TESTIDS) {
499
411
  present.push(id);
412
+ }
500
413
  }
501
414
  const registered = declaredTestids();
502
415
  const knownEmptyState = present.some((id) => registered.includes(id));
503
- const route = `${location.pathname}${location.search}`;
416
+ const route = pageRoute(false);
504
417
  const hint = {
505
418
  route,
506
419
  presentTestids: present,
@@ -511,20 +424,34 @@ function buildEmptyHint(query) {
511
424
  const wanted = wantedTextOf(query);
512
425
  if (wanted !== undefined) {
513
426
  const owner = splitTextOwner(container, wanted, query);
514
- if (owner !== undefined)
515
- hint.splitText = describe(owner);
427
+ if (owner !== undefined) {
428
+ const described = describe(owner);
429
+ const selector = stableSelectorFor(owner);
430
+ hint.splitText = selector === undefined ? described : { ...described, selector };
431
+ }
516
432
  else if (true === query.matchCase) {
517
433
  const shown = caseOnlyMatch(container, wanted);
518
- if (shown !== undefined)
434
+ if (shown !== undefined) {
519
435
  hint.caseMiss = shown;
436
+ }
437
+ }
438
+ }
439
+ if (diagnose &&
440
+ wanted !== undefined &&
441
+ hint.splitText === undefined &&
442
+ (state === undefined || ElementState.VISIBLE === state || ElementState.IN_VIEWPORT === state)) {
443
+ if (textInAriaHidden(container, wanted)) {
444
+ hint.ariaHiddenMatch = true;
520
445
  }
521
446
  }
522
447
  const near = nameNearMisses(container, query);
523
- if (near.length > 0)
448
+ if (near.length > 0) {
524
449
  hint.nameNearMiss = near;
450
+ }
525
451
  const elsewhere = testidFoundUnder(container, query);
526
- if (elsewhere !== undefined)
452
+ if (elsewhere !== undefined) {
527
453
  hint.testidFoundUnder = elsewhere;
454
+ }
528
455
  return hint;
529
456
  }
530
457
  export function elementsMatching(query) {
@@ -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
+ }
@@ -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;