@reticlehq/browser 3.5.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/actions/action-report.d.ts +54 -0
  2. package/dist/actions/action-report.js +60 -0
  3. package/dist/actions/actions-dom.d.ts +1 -0
  4. package/dist/actions/actions-dom.js +56 -43
  5. package/dist/actions/actions-press.d.ts +11 -4
  6. package/dist/actions/actions-press.js +132 -17
  7. package/dist/actions/actions.d.ts +1 -35
  8. package/dist/actions/actions.js +32 -65
  9. package/dist/actions/appeared-text.js +3 -0
  10. package/dist/actions/danger-context.d.ts +2 -0
  11. package/dist/actions/danger-context.js +29 -3
  12. package/dist/actions/value-input.d.ts +1 -0
  13. package/dist/actions/value-input.js +11 -0
  14. package/dist/commands/commands.js +4 -0
  15. package/dist/connect-options.d.ts +1 -0
  16. package/dist/dom/a11y.js +264 -14
  17. package/dist/dom/addressing/coverage-markers.d.ts +6 -0
  18. package/dist/dom/addressing/coverage-markers.js +13 -0
  19. package/dist/dom/addressing/refs.js +2 -1
  20. package/dist/dom/addressing/testid-attr.d.ts +4 -0
  21. package/dist/dom/addressing/testid-attr.js +14 -0
  22. package/dist/dom/dom-ignore.d.ts +2 -0
  23. package/dist/dom/dom-ignore.js +15 -2
  24. package/dist/dom/query.d.ts +2 -1
  25. package/dist/dom/query.js +116 -27
  26. package/dist/dom/render-page.d.ts +12 -0
  27. package/dist/dom/render-page.js +135 -0
  28. package/dist/dom/snapshot.js +56 -7
  29. package/dist/observers/departure.js +51 -9
  30. package/dist/observers/dom.js +35 -18
  31. package/dist/observers/field.js +5 -21
  32. package/dist/observers/health.js +63 -2
  33. package/dist/observers/network-stream.d.ts +1 -1
  34. package/dist/observers/network-stream.js +3 -3
  35. package/dist/observers/network.js +57 -8
  36. package/dist/observers/perf.js +53 -1
  37. package/dist/observers/route.js +6 -5
  38. package/dist/observers/storage.js +6 -1
  39. package/dist/presenter/carousel/carousel.d.ts +5 -2
  40. package/dist/presenter/carousel/carousel.js +30 -8
  41. package/dist/presenter/carousel/panel-slides.d.ts +3 -4
  42. package/dist/presenter/carousel/panel-slides.js +48 -9
  43. package/dist/presenter/carousel/talk-card.d.ts +1 -1
  44. package/dist/presenter/chrome/log-kinds.d.ts +5 -0
  45. package/dist/presenter/chrome/log-kinds.js +1 -0
  46. package/dist/presenter/chrome/log-memory.d.ts +13 -0
  47. package/dist/presenter/chrome/log-memory.js +39 -0
  48. package/dist/presenter/chrome/presenter-hud-chrome.d.ts +1 -0
  49. package/dist/presenter/chrome/presenter-hud-chrome.js +7 -1
  50. package/dist/presenter/chrome/presenter-log.d.ts +12 -4
  51. package/dist/presenter/chrome/presenter-log.js +100 -10
  52. package/dist/presenter/chrome/presenter-morph.d.ts +1 -0
  53. package/dist/presenter/chrome/presenter-morph.js +34 -0
  54. package/dist/presenter/chrome/presenter-report-copy.d.ts +28 -5
  55. package/dist/presenter/chrome/presenter-report-copy.js +40 -5
  56. package/dist/presenter/hud-telemetry.js +83 -3
  57. package/dist/presenter/icons/presenter-heroicons-data.d.ts +1 -0
  58. package/dist/presenter/icons/presenter-heroicons-data.js +4 -3
  59. package/dist/presenter/icons/presenter-icons.d.ts +2 -0
  60. package/dist/presenter/icons/presenter-icons.js +2 -0
  61. package/dist/presenter/presenter-account-styles.js +3 -3
  62. package/dist/presenter/presenter-account.d.ts +31 -8
  63. package/dist/presenter/presenter-account.js +105 -15
  64. package/dist/presenter/presenter-agent-link.d.ts +9 -0
  65. package/dist/presenter/presenter-agent-link.js +62 -0
  66. package/dist/presenter/presenter-chat-views.d.ts +43 -0
  67. package/dist/presenter/presenter-chat-views.js +622 -0
  68. package/dist/presenter/presenter-config.d.ts +5 -2
  69. package/dist/presenter/presenter-config.js +5 -3
  70. package/dist/presenter/presenter-controls.d.ts +3 -2
  71. package/dist/presenter/presenter-controls.js +163 -61
  72. package/dist/presenter/presenter-design-tokens.d.ts +1 -0
  73. package/dist/presenter/presenter-design-tokens.js +34 -0
  74. package/dist/presenter/presenter-dock-layout.js +1 -1
  75. package/dist/presenter/presenter-frame.d.ts +12 -0
  76. package/dist/presenter/presenter-frame.js +187 -0
  77. package/dist/presenter/presenter-harness-row.d.ts +67 -0
  78. package/dist/presenter/presenter-harness-row.js +209 -0
  79. package/dist/presenter/presenter-personas.d.ts +35 -0
  80. package/dist/presenter/presenter-personas.js +95 -0
  81. package/dist/presenter/presenter-plan.d.ts +10 -0
  82. package/dist/presenter/presenter-plan.js +186 -0
  83. package/dist/presenter/presenter-report-styles.js +46 -21
  84. package/dist/presenter/presenter-report.d.ts +2 -1
  85. package/dist/presenter/presenter-report.js +123 -20
  86. package/dist/presenter/presenter-settings-styles.js +18 -4
  87. package/dist/presenter/presenter-settings.d.ts +8 -2
  88. package/dist/presenter/presenter-settings.js +134 -33
  89. package/dist/presenter/presenter-shell-styles.js +48 -42
  90. package/dist/presenter/presenter-shell.d.ts +17 -4
  91. package/dist/presenter/presenter-shell.js +139 -79
  92. package/dist/presenter/presenter-styles.js +9 -3
  93. package/dist/presenter/presenter-workspace.js +10 -3
  94. package/dist/presenter/presenter.d.ts +6 -4
  95. package/dist/presenter/presenter.js +171 -22
  96. package/dist/presenter/tour/tour.d.ts +4 -1
  97. package/dist/presenter/tour/tour.js +6 -4
  98. package/dist/recorder/recorder.js +2 -2
  99. package/dist/registry/auto-testids.js +3 -4
  100. package/dist/registry/declared-platform.js +4 -0
  101. package/dist/reticle-inject.js +938 -494
  102. package/dist/reticle.d.ts +10 -1
  103. package/dist/reticle.js +109 -11
  104. package/dist/review/annotator-styles.d.ts +1 -1
  105. package/dist/review/annotator.d.ts +7 -0
  106. package/dist/review/annotator.js +20 -2
  107. package/dist/review/mark-anchor.js +2 -2
  108. package/dist/session-continuity.js +2 -1
  109. package/dist/storage-keys.d.ts +15 -0
  110. package/dist/storage-keys.js +21 -0
  111. package/dist/transport/transport.js +5 -3
  112. package/dist/transport/unreachable-message.d.ts +1 -1
  113. package/dist/transport/unreachable-message.js +3 -5
  114. package/package.json +3 -2
  115. package/dist/presenter/carousel/offer-card.d.ts +0 -21
  116. package/dist/presenter/carousel/offer-card.js +0 -55
@@ -0,0 +1,13 @@
1
+ import { CoverageMarker, SOURCE_MAPPING_CAPABILITY, DATA_RETICLE_SOURCE_ATTR, RETICLE_OVERLAY_SELECTOR, } from '@reticlehq/core';
2
+ import { testIdSelector } from './testid-attr.js';
3
+ const appCarries = (doc, selector) => Array.from(doc.querySelectorAll(selector)).some((el) => null === el.closest(RETICLE_OVERLAY_SELECTOR));
4
+ export function coverageMarkers(doc) {
5
+ return [
6
+ ...(appCarries(doc, `[${DATA_RETICLE_SOURCE_ATTR}]`) ? [CoverageMarker.SOURCE] : []),
7
+ ...(appCarries(doc, testIdSelector()) ? [CoverageMarker.TESTID] : []),
8
+ ];
9
+ }
10
+ export function notApplicableFor(page) {
11
+ const couldStamp = page.buildPlugin || 0 < page.adapters.length || page.markers.includes(CoverageMarker.SOURCE);
12
+ return couldStamp ? [] : [SOURCE_MAPPING_CAPABILITY];
13
+ }
@@ -1,4 +1,5 @@
1
1
  import { TRANSPORT_LIMITS, asRef } from '@reticlehq/core';
2
+ import { ReticleStorageKey } from '../../storage-keys.js';
2
3
  export const MAX_TRACKED_REFS = 10000;
3
4
  const REF_ELISION = '…';
4
5
  const REF_PREFIX = 'e';
@@ -7,7 +8,7 @@ export function echoRef(ref) {
7
8
  return max >= ref.length ? ref : `${ref.slice(0, max)}${REF_ELISION}`;
8
9
  }
9
10
  const SWEEP_EVERY_MINTS = 1000;
10
- const REF_BASE_KEY = '__reticle_ref_base';
11
+ const REF_BASE_KEY = ReticleStorageKey.REF_BASE;
11
12
  export const REF_BLOCK = 100;
12
13
  function readBase(store) {
13
14
  try {
@@ -0,0 +1,4 @@
1
+ export declare function getTestIdAttr(): string;
2
+ export declare function testIdSelector(): string;
3
+ export declare function readTestId(el: Element): string | null;
4
+ export declare function setTestIdAttr(name: string | undefined): void;
@@ -0,0 +1,14 @@
1
+ import { DEFAULT_TESTID_ATTR, TESTID_ATTR_PATTERN } from '@reticlehq/core';
2
+ let testIdAttr = DEFAULT_TESTID_ATTR;
3
+ export function getTestIdAttr() {
4
+ return testIdAttr;
5
+ }
6
+ export function testIdSelector() {
7
+ return `[${testIdAttr}]`;
8
+ }
9
+ export function readTestId(el) {
10
+ return el.getAttribute(testIdAttr);
11
+ }
12
+ export function setTestIdAttr(name) {
13
+ testIdAttr = undefined !== name && TESTID_ATTR_PATTERN.test(name) ? name : DEFAULT_TESTID_ATTR;
14
+ }
@@ -1,8 +1,10 @@
1
1
  export declare const RETICLE_OVERLAY: string;
2
2
  export declare function setPresenterVisible(visible: boolean): void;
3
+ export declare function setPresenterViewer(harness: boolean): void;
3
4
  export declare function isPresenterVisible(): boolean;
4
5
  export declare function setIgnoreSelectors(selectors: string[]): void;
5
6
  export declare function isReticleOverlay(el: Element): boolean;
7
+ export declare function isUnobserved(el: Element): boolean;
6
8
  export declare function isReticleUi(node: Element | null): boolean;
7
9
  export declare function isIgnored(el: Element): boolean;
8
10
  export declare function appModalOpen(doc: Document): boolean;
@@ -1,10 +1,16 @@
1
- export const RETICLE_OVERLAY = '[data-reticle-overlay],[data-reticle-cursor],[data-reticle-hud],[data-reticle-glow],[data-reticle-mark],[data-reticle-blocker],[data-reticle-tour]';
1
+ import { RETICLE_OVERLAY_SELECTOR } from '@reticlehq/core';
2
+ export const RETICLE_OVERLAY = RETICLE_OVERLAY_SELECTOR;
2
3
  const DEV_OVERLAYS = '[data-agentation],#__next-build-watcher,nextjs-portal,[data-nextjs-dialog],[data-nextjs-toast]';
3
4
  let extraIgnore = '';
4
5
  let presenterVisible = false;
5
6
  export function setPresenterVisible(visible) {
6
7
  presenterVisible = visible;
7
8
  }
9
+ let harnessViewing = false;
10
+ export function setPresenterViewer(harness) {
11
+ harnessViewing = harness;
12
+ }
13
+ const exposed = () => presenterVisible && !harnessViewing;
8
14
  export function isPresenterVisible() {
9
15
  return presenterVisible;
10
16
  }
@@ -14,11 +20,18 @@ export function setIgnoreSelectors(selectors) {
14
20
  export function isReticleOverlay(el) {
15
21
  return el.closest(RETICLE_OVERLAY) !== null;
16
22
  }
23
+ const HUD_DOCK = '[data-reticle-dock]';
24
+ export function isUnobserved(el) {
25
+ if (!isReticleOverlay(el))
26
+ return false;
27
+ return !exposed() || null === el.closest(HUD_DOCK);
28
+ }
17
29
  export function isReticleUi(node) {
18
30
  return node !== null && node.closest(RETICLE_OVERLAY) !== null;
19
31
  }
32
+ const HUD_NARRATION = '[data-reticle-log],[data-reticle-harness-log],[data-reticle-chat-pill-text],[data-reticle-agent-said],[data-reticle-agent-note]';
20
33
  export function isIgnored(el) {
21
- const ours = presenterVisible ? '' : RETICLE_OVERLAY;
34
+ const ours = exposed() ? HUD_NARRATION : RETICLE_OVERLAY;
22
35
  const sel = [ours, DEV_OVERLAYS, extraIgnore].filter((part) => part.length > 0).join(',');
23
36
  return el.closest(sel) !== null;
24
37
  }
@@ -1,4 +1,5 @@
1
1
  import { ElementState, type ElementQuery, type MatchResult, type QueryResult } from '@reticlehq/core';
2
2
  export declare const ATTR_VALUE_MAX = 512;
3
- export declare function matchQuery(query: ElementQuery, state?: ElementState, limit?: number): MatchResult;
3
+ export declare function matchQuery(query: ElementQuery, state?: ElementState, limit?: number, wholePhrase?: boolean): MatchResult;
4
+ export declare function elementsMatching(query: ElementQuery): HTMLElement[];
4
5
  export declare function runQuery(query: ElementQuery, limit?: number): QueryResult;
package/dist/dom/query.js CHANGED
@@ -1,4 +1,4 @@
1
- import { DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
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
4
  import { getAccessibleName, getRole, describe, getStates, isInViewport, isVisible, } from './a11y.js';
@@ -7,11 +7,11 @@ 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
- const TESTID_ATTR = 'data-testid';
10
+ import { getTestIdAttr, readTestId, testIdSelector } from './addressing/testid-attr.js';
11
11
  const SOURCE_ATTR = DATA_RETICLE_SOURCE_ATTR;
12
12
  const MAX_PRESENT_TESTIDS = 12;
13
13
  const MAX_COMPONENT_CANDIDATES = 2000;
14
- const COMPONENT_CANDIDATE_SELECTOR = `[${SOURCE_ATTR}], [${TESTID_ATTR}], button, a, input, select, textarea, [role]`;
14
+ const componentCandidateSelector = () => `[${SOURCE_ATTR}], ${testIdSelector()}, button, a, input, select, textarea, [role]`;
15
15
  function elementsUnder(container) {
16
16
  const self = Node.ELEMENT_NODE === container.nodeType ? [container] : [];
17
17
  return [...self, ...Array.from(container.querySelectorAll('*'))];
@@ -24,6 +24,11 @@ function fuzzyVisibleText(actual, expected) {
24
24
  .toLowerCase()
25
25
  .includes(normaliseVisibleText(expected).toLowerCase());
26
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
+ }
27
32
  function exactVisibleText(actual, expected) {
28
33
  return normaliseVisibleText(actual) === normaliseVisibleText(expected);
29
34
  }
@@ -45,17 +50,29 @@ function directText(el) {
45
50
  .join('');
46
51
  }
47
52
  function visibleTextOf(el, memo) {
48
- if (isNonRendered(el) || isIgnored(el) || !isVisible(el, memo))
53
+ if (isNonRendered(el) || isIgnored(el))
49
54
  return '';
55
+ const visible = isVisible(el, memo);
56
+ const apart = laysOutApart(el);
50
57
  let text = '';
51
58
  for (const node of Array.from(el.childNodes)) {
52
- if (Node.TEXT_NODE === node.nodeType)
59
+ if (Node.TEXT_NODE === node.nodeType && visible)
53
60
  text += node.textContent ?? '';
54
- else if (isElement(node))
55
- text += visibleTextOf(node, memo);
61
+ else if (isElement(node)) {
62
+ const own = visibleTextOf(node, memo);
63
+ text += apart || ownBox(node) ? ` ${own} ` : own;
64
+ }
56
65
  }
57
66
  return text;
58
67
  }
68
+ function laysOutApart(el) {
69
+ const display = el.ownerDocument.defaultView?.getComputedStyle(el).display ?? '';
70
+ return display.includes('flex') || display.includes('grid');
71
+ }
72
+ function ownBox(el) {
73
+ const display = el.ownerDocument.defaultView?.getComputedStyle(el).display ?? 'inline';
74
+ return !display.startsWith('inline') && 'contents' !== display;
75
+ }
59
76
  function semanticNameTarget(el) {
60
77
  if (isInput(el) || isTextArea(el) || isSelect(el))
61
78
  return true;
@@ -94,7 +111,7 @@ function findBySource(container, source) {
94
111
  function findByComponentName(container, component) {
95
112
  const out = [];
96
113
  let scanned = 0;
97
- for (const el of Array.from(container.querySelectorAll(COMPONENT_CANDIDATE_SELECTOR))) {
114
+ for (const el of Array.from(container.querySelectorAll(componentCandidateSelector()))) {
98
115
  if (scanned >= MAX_COMPONENT_CANDIDATES)
99
116
  break;
100
117
  scanned += 1;
@@ -115,14 +132,33 @@ function findByComponent(container, query) {
115
132
  }
116
133
  return [];
117
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
+ }
118
142
  function queryByRoleAndName(container, role, name) {
119
- return elementsUnder(container).filter((el) => getRole(el) === role && (name === undefined || exactVisibleText(getAccessibleName(el), name)));
143
+ return elementsUnder(container).filter((el) => matchesRole(getRole(el), role) &&
144
+ (name === undefined || exactVisibleText(getAccessibleName(el), name)));
120
145
  }
121
- function queryByText(container, value) {
122
- return elementsUnder(container).filter((el) => fuzzyVisibleText(directText(el), value));
146
+ function queryByText(container, value, query) {
147
+ return elementsUnder(container).filter((el) => textMatches(directText(el), query, value));
123
148
  }
124
149
  function queryByLabel(container, value) {
125
- return elementsUnder(container).filter((el) => semanticNameTarget(el) && fuzzyVisibleText(getAccessibleName(el), 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;
126
162
  }
127
163
  function queryByPlaceholder(container, value) {
128
164
  return elementsUnder(container).filter((el) => {
@@ -131,7 +167,7 @@ function queryByPlaceholder(container, value) {
131
167
  });
132
168
  }
133
169
  function queryByTestId(container, value) {
134
- return elementsUnder(container).filter((el) => el.getAttribute(TESTID_ATTR) === value);
170
+ return elementsUnder(container).filter((el) => readTestId(el) === value);
135
171
  }
136
172
  function queryByAlt(container, value) {
137
173
  return elementsUnder(container).filter((el) => {
@@ -147,7 +183,7 @@ function findIn(container, query) {
147
183
  case QueryBy.ROLE:
148
184
  return queryByRoleAndName(container, value, query.name);
149
185
  case QueryBy.TEXT:
150
- return queryByText(container, value);
186
+ return queryByText(container, value, query);
151
187
  case QueryBy.LABEL:
152
188
  return queryByLabel(container, value);
153
189
  case QueryBy.PLACEHOLDER:
@@ -169,7 +205,7 @@ function findIn(container, query) {
169
205
  return queryByRoleAndName(container, query.role, query.name);
170
206
  }
171
207
  if (query.text !== undefined)
172
- return queryByText(container, query.text);
208
+ return queryByText(container, query.text, query);
173
209
  if (query.label !== undefined)
174
210
  return queryByLabel(container, query.label);
175
211
  if (query.placeholder !== undefined)
@@ -221,7 +257,7 @@ function findCandidates(query) {
221
257
  if (query.scope === undefined)
222
258
  return { candidates: [], scopeMissing };
223
259
  if (query.text !== undefined &&
224
- !fuzzyVisibleText(visibleTextOf(container, new Map()), query.text)) {
260
+ !textMatches(visibleTextOf(container, new Map()), query, query.text)) {
225
261
  return { candidates: [], scopeMissing };
226
262
  }
227
263
  return { candidates: isIgnored(container) ? [] : [container], scopeMissing };
@@ -261,10 +297,13 @@ function inState(el, state, memo) {
261
297
  return getStates(el, isVisible(el, memo)).includes(state);
262
298
  }
263
299
  const MAX_DESCRIBED = TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS;
264
- export function matchQuery(query, state, limit = MAX_DESCRIBED) {
300
+ export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = true) {
265
301
  const found = findCandidates(query);
266
- const elements = found.candidates;
267
302
  const scopeMissing = found.scopeMissing;
303
+ const wanted = wholePhrase && textOnly(query) ? wantedTextOf(query) : undefined;
304
+ const scope = 0 === found.candidates.length ? resolveContainer(query.scope).container : null;
305
+ const owner = null === scope || wanted === undefined ? undefined : splitTextOwner(scope, wanted, query);
306
+ const elements = owner === undefined ? found.candidates : [owner];
268
307
  const visMemo = new Map();
269
308
  const filtered = state === undefined ? elements : elements.filter((el) => inState(el, state, visMemo));
270
309
  const attrs = query.attrs;
@@ -329,7 +368,7 @@ function buildPresentRegions(query) {
329
368
  continue;
330
369
  const name = el.getAttribute('aria-label') ??
331
370
  resolveLabelledBy(el) ??
332
- el.getAttribute('data-testid') ??
371
+ readTestId(el) ??
333
372
  undefined;
334
373
  const children = el.querySelectorAll('[role]');
335
374
  const sample = [];
@@ -338,7 +377,7 @@ function buildPresentRegions(query) {
338
377
  break;
339
378
  const childRole = child.getAttribute('role');
340
379
  const childName = child.getAttribute('aria-label') ??
341
- child.getAttribute('data-testid') ??
380
+ readTestId(child) ??
342
381
  child.textContent?.trim().slice(0, 40) ??
343
382
  '';
344
383
  if (childRole !== null && childName.length > 0) {
@@ -355,14 +394,14 @@ function buildPresentRegions(query) {
355
394
  }
356
395
  return regions;
357
396
  }
358
- function splitTextOwner(container, wanted) {
397
+ function splitTextOwner(container, wanted, query) {
359
398
  let best;
360
399
  let bestDepth = -1;
361
400
  const memo = new Map();
362
401
  for (const el of elementsUnder(container)) {
363
402
  if (isIgnored(el))
364
403
  continue;
365
- if (!fuzzyVisibleText(visibleTextOf(el, memo), wanted))
404
+ if (!textMatches(visibleTextOf(el, memo), query, wanted))
366
405
  continue;
367
406
  let depth = 0;
368
407
  for (let parent = el.parentElement; parent !== null; parent = parent.parentElement)
@@ -374,6 +413,32 @@ function splitTextOwner(container, wanted) {
374
413
  }
375
414
  return best;
376
415
  }
416
+ function caseOnlyMatch(container, wanted) {
417
+ const target = normaliseVisibleText(wanted).toLowerCase();
418
+ if (0 === target.length)
419
+ return undefined;
420
+ const memo = new Map();
421
+ for (const el of elementsUnder(container)) {
422
+ if (isIgnored(el))
423
+ continue;
424
+ const text = normaliseVisibleText(visibleTextOf(el, memo));
425
+ const at = text.toLowerCase().indexOf(target);
426
+ if (-1 !== at)
427
+ return text.slice(at, at + target.length);
428
+ }
429
+ return undefined;
430
+ }
431
+ const TEXT_ONLY_FIELDS = new Set([
432
+ 'text',
433
+ 'matchCase',
434
+ 'scope',
435
+ 'by',
436
+ 'value',
437
+ ]);
438
+ function textOnly(query) {
439
+ return ((query.by === undefined || QueryBy.TEXT === query.by) &&
440
+ Object.keys(query).every((key) => TEXT_ONLY_FIELDS.has(key)));
441
+ }
377
442
  function wantedTextOf(query) {
378
443
  if (query.text !== undefined)
379
444
  return query.text;
@@ -381,7 +446,7 @@ function wantedTextOf(query) {
381
446
  }
382
447
  const MAX_NAME_NEAR_MISSES = 5;
383
448
  function nameNearMisses(container, query) {
384
- const role = QueryBy.ROLE === query.by ? query.value : undefined;
449
+ const role = QueryBy.ROLE === query.by ? query.value : query.role;
385
450
  const wanted = query.name;
386
451
  if (role === undefined || wanted === undefined || 0 === wanted.length)
387
452
  return [];
@@ -405,15 +470,28 @@ function nameNearMisses(container, query) {
405
470
  }
406
471
  return out;
407
472
  }
473
+ function testidFoundUnder(container, query) {
474
+ if (QueryBy.TESTID !== query.by || undefined === query.value)
475
+ return undefined;
476
+ const configured = getTestIdAttr();
477
+ for (const attr of [DEFAULT_TESTID_ATTR, ...ALTERNATIVE_TESTID_ATTRS]) {
478
+ if (attr === configured)
479
+ continue;
480
+ const found = elementsUnder(container).some((el) => el.getAttribute(attr) === query.value && !isIgnored(el));
481
+ if (found)
482
+ return attr;
483
+ }
484
+ return undefined;
485
+ }
408
486
  function buildEmptyHint(query) {
409
487
  const container = resolveContainer(query.scope).container ?? document.body;
410
- const all = container.querySelectorAll(`[${TESTID_ATTR}]`);
488
+ const all = container.querySelectorAll(testIdSelector());
411
489
  const present = [];
412
490
  const seen = new Set();
413
491
  for (const el of Array.from(all)) {
414
492
  if (isIgnored(el))
415
493
  continue;
416
- const id = el.getAttribute(TESTID_ATTR);
494
+ const id = readTestId(el);
417
495
  if (null === id || 0 === id.length || seen.has(id))
418
496
  continue;
419
497
  seen.add(id);
@@ -432,17 +510,28 @@ function buildEmptyHint(query) {
432
510
  };
433
511
  const wanted = wantedTextOf(query);
434
512
  if (wanted !== undefined) {
435
- const owner = splitTextOwner(container, wanted);
513
+ const owner = splitTextOwner(container, wanted, query);
436
514
  if (owner !== undefined)
437
515
  hint.splitText = describe(owner);
516
+ else if (true === query.matchCase) {
517
+ const shown = caseOnlyMatch(container, wanted);
518
+ if (shown !== undefined)
519
+ hint.caseMiss = shown;
520
+ }
438
521
  }
439
522
  const near = nameNearMisses(container, query);
440
523
  if (near.length > 0)
441
524
  hint.nameNearMiss = near;
525
+ const elsewhere = testidFoundUnder(container, query);
526
+ if (elsewhere !== undefined)
527
+ hint.testidFoundUnder = elsewhere;
442
528
  return hint;
443
529
  }
530
+ export function elementsMatching(query) {
531
+ return findCandidates(query).candidates;
532
+ }
444
533
  export function runQuery(query, limit) {
445
- const result = matchQuery(query, undefined, limit);
534
+ const result = matchQuery(query, undefined, limit, false);
446
535
  const scopeFields = true === result.scopeMissing ? { scopeMissing: true } : {};
447
536
  if (0 === result.elements.length) {
448
537
  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
+ }
@@ -1,6 +1,6 @@
1
1
  import { ElementState, SnapshotMode } from '@reticlehq/core';
2
2
  import { capturedRootOf } from './shadow-registry.js';
3
- import { isFrame } from './realm.js';
3
+ import { isElement, isFrame } from './realm.js';
4
4
  import { getAccessibleName, getRole, getStates, getValue, isVisible } from './a11y.js';
5
5
  import { refs } from './addressing/refs.js';
6
6
  import { isIgnored, isReticleOverlay } from './dom-ignore.js';
@@ -8,6 +8,7 @@ const INTERACTIVE = new Set([
8
8
  'button',
9
9
  'link',
10
10
  'textbox',
11
+ 'searchbox',
11
12
  'checkbox',
12
13
  'radio',
13
14
  'combobox',
@@ -29,13 +30,44 @@ function announces(el, role) {
29
30
  }
30
31
  const TEXT_MAX = 80;
31
32
  function directText(el) {
32
- let out = '';
33
+ const parts = readingParts(el);
34
+ const collapsed = (parts.own ? parts.text : '').replace(/\s+/g, ' ').trim();
35
+ return collapsed.length > TEXT_MAX ? `${collapsed.slice(0, TEXT_MAX)}…` : collapsed;
36
+ }
37
+ function readingParts(el) {
38
+ let text = '';
39
+ let own = false;
33
40
  for (const node of el.childNodes) {
34
- if (3 === node.nodeType)
35
- out += node.textContent ?? '';
41
+ if (3 === node.nodeType) {
42
+ text += node.textContent ?? '';
43
+ own ||= 0 < (node.textContent ?? '').trim().length;
44
+ }
45
+ else if (isElement(node) && foldsIntoParent(node)) {
46
+ const inner = readingParts(node);
47
+ text += inner.text;
48
+ own ||= inner.own;
49
+ }
50
+ else if (isElement(node) && isInlineIn(node) && INTERACTIVE.has(getRole(node))) {
51
+ text += getAccessibleName(node);
52
+ }
36
53
  }
37
- const collapsed = out.replace(/\s+/g, ' ').trim();
38
- return collapsed.length > TEXT_MAX ? `${collapsed.slice(0, TEXT_MAX)}…` : collapsed;
54
+ return { text, own };
55
+ }
56
+ function isInlineIn(el) {
57
+ const view = el.ownerDocument.defaultView;
58
+ const parent = el.parentElement;
59
+ if (null === view || null === parent)
60
+ return false;
61
+ const around = view.getComputedStyle(parent).display;
62
+ if (around.includes('flex') || around.includes('grid'))
63
+ return false;
64
+ return 'inline' === view.getComputedStyle(el).display;
65
+ }
66
+ function foldsIntoParent(el) {
67
+ if (skipEarly(el) || !isInlineIn(el))
68
+ return false;
69
+ const role = getRole(el);
70
+ return !INTERACTIVE.has(role) && !announces(el, role) && 0 === getAccessibleName(el).length;
39
71
  }
40
72
  export const MAX_UNREAD_BRANCHES = 50;
41
73
  function hiddenByMarkup(el) {
@@ -82,6 +114,7 @@ function layoutSignature(style) {
82
114
  }
83
115
  return '';
84
116
  }
117
+ const overlayStamped = (el) => el.hasAttribute('data-aria-hidden') && 'true' === el.getAttribute('aria-hidden');
85
118
  function recordUnread(branches, ctx) {
86
119
  for (const branch of branches) {
87
120
  if (ctx.unread.length >= MAX_UNREAD_BRANCHES) {
@@ -108,7 +141,11 @@ function pierceChildren(parent) {
108
141
  return out;
109
142
  }
110
143
  function visit(child, depth, ctx, inLive) {
111
- if (skipEarly(child)) {
144
+ const stampedOnly = true === ctx.throughOverlay &&
145
+ overlayStamped(child) &&
146
+ !isIgnored(child) &&
147
+ !(child instanceof HTMLElement && child.hidden);
148
+ if (!stampedOnly && skipEarly(child)) {
112
149
  if (hiddenByMarkup(child))
113
150
  ctx.hiddenSkipped += 1;
114
151
  return;
@@ -119,6 +156,10 @@ function visit(child, depth, ctx, inLive) {
119
156
  ctx.hiddenSkipped += 1;
120
157
  return;
121
158
  }
159
+ if (style !== null && 'inline' === style.display && !inLive && foldsIntoParent(child)) {
160
+ walk(child, depth, ctx, inLive);
161
+ return;
162
+ }
122
163
  const role = getRole(child);
123
164
  const name = getAccessibleName(child);
124
165
  const interactive = INTERACTIVE.has(role);
@@ -173,6 +214,7 @@ function collectDialogs(root) {
173
214
  }
174
215
  return names;
175
216
  }
217
+ const MODAL_SELECTOR = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"]';
176
218
  function overlayHidingPage(root) {
177
219
  if (root !== document.body)
178
220
  return undefined;
@@ -241,6 +283,13 @@ export function buildSnapshot(options = {}) {
241
283
  visit(root, 0, ctx, false);
242
284
  else
243
285
  walk(root, 0, ctx);
286
+ if (0 === ctx.nodes && root === document.body) {
287
+ ctx.throughOverlay = true;
288
+ for (const child of Array.from(root.children))
289
+ if (overlayStamped(child) && null !== child.querySelector(MODAL_SELECTOR))
290
+ visit(child, 0, ctx, false);
291
+ ctx.throughOverlay = false;
292
+ }
244
293
  return {
245
294
  tree: ctx.lines.join('\n'),
246
295
  status,