@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
@@ -1,15 +1,16 @@
1
- import { ActionType, ActionWarning, DANGEROUS_ACTION_CONFIRM_ARG, ElementState, NATIVE_INPUT_ARG, SettleReason, } from '@reticlehq/core';
1
+ import { ActionType, ActionWarning, clampHoldMs, DANGEROUS_ACTION_CONFIRM_ARG, ElementState, NATIVE_INPUT_ARG, SettleReason, } from '@reticlehq/core';
2
2
  import { asSyntheticInput } from './synthetic/synthetic-input.js';
3
3
  import { echoRef, refs } from '../dom/addressing/refs.js';
4
- import { dangerousActionContext, requiresDangerousConfirmation, submitControlFor, } from './danger-context.js';
5
- import { assertEditable, assertNotRichText, setNativeValue } from './value-input.js';
6
- import { getAccessibleName, getRole, isVisible, getStates } from '../dom/a11y.js';
7
- import { elementHasHoverHandlers, identifyComponent } from '../registry/stores/adapters.js';
4
+ import { dangerousActionContext, requiresDangerousConfirmation, assertOutwardAllowed, submitControlFor, } from './danger-context.js';
5
+ import { assertEditable, assertNotRichText, isAutofillSafeTarget, setNativeValue, } from './value-input.js';
6
+ import { getRole, isVisible, getStates } from '../dom/a11y.js';
7
+ import { elementHasHoverHandlers } from '../registry/stores/adapters.js';
8
8
  import { isActionTarget, isForm, isInput, isSelect, isTextArea, } from '../dom/realm.js';
9
9
  import { nativeSetTimeout, settle } from '../timers/native/native-timers.js';
10
10
  import { AppearedText } from './appeared-text.js';
11
11
  import { alreadyAtCheckedState, assertAriaToggleLanded, isAriaToggle } from './check-state.js';
12
- import { focusedOrDocument, isReflessDocumentPress, pressCode, pressKey, pressModifiers, pressKeys, holdKey, pressCombo, } from './actions-press.js';
12
+ import { focusedOrDocument, isReflessDocumentPress, pressCode, pressKey, pressModifiers, pressKeys, holdKey, pressCombo, dispatchKeypress, closeModalOnEscape, keyboardEvent, } from './actions-press.js';
13
+ import { anchorOf, result } from './action-report.js';
13
14
  function asString(value, fallback = '') {
14
15
  return 'string' === typeof value ? value : fallback;
15
16
  }
@@ -21,49 +22,6 @@ function requireElement(ref) {
21
22
  throw new Error(`ref '${echoRef(ref)}' is not an HTML or SVG element`);
22
23
  return el;
23
24
  }
24
- function anchorOf(el) {
25
- const testid = el.getAttribute('data-testid') ?? undefined;
26
- const info = identifyComponent(el);
27
- const out = {};
28
- if (testid !== undefined)
29
- out.testid = testid;
30
- const component = info?.componentStack[0];
31
- if (component !== undefined)
32
- out.component = component;
33
- if (info?.source !== undefined)
34
- out.source = info.source;
35
- const role = getRole(el);
36
- const name = getAccessibleName(el);
37
- if (role.length > 0)
38
- out.role = role;
39
- if (name.length > 0)
40
- out.name = name;
41
- return out;
42
- }
43
- const result = (ref, action, effect, settled, settleReason, anchor, warning) => {
44
- const base = {
45
- ok: true,
46
- ref,
47
- action,
48
- dispatched: true,
49
- settled,
50
- settleReason,
51
- effect,
52
- };
53
- if (anchor.testid !== undefined)
54
- base.testid = anchor.testid;
55
- if (anchor.component !== undefined)
56
- base.component = anchor.component;
57
- if (anchor.source !== undefined)
58
- base.source = anchor.source;
59
- if (anchor.role !== undefined)
60
- base.role = anchor.role;
61
- if (anchor.name !== undefined)
62
- base.name = anchor.name;
63
- if (warning !== undefined)
64
- base.warning = warning;
65
- return base;
66
- };
67
25
  const FILL_LIKE = new Set([
68
26
  ActionType.FILL,
69
27
  ActionType.TYPE,
@@ -109,6 +67,8 @@ function assertActionAllowed(el, action, args) {
109
67
  (action === ActionType.PRESS && 'Enter' === pressKey(args));
110
68
  const dragTarget = action === ActionType.DRAG ? refs.resolve(dragTargetRef(args)) : null;
111
69
  const submitter = action === ActionType.PRESS && 'Enter' === pressKey(args) ? submitControlFor(el) : null;
70
+ if (canTrigger)
71
+ assertOutwardAllowed([el, submitter, isActionTarget(dragTarget) ? dragTarget : null]);
112
72
  const sourceDangerous = requiresDangerousConfirmation(dangerousActionContext(el), getRole(el)) ||
113
73
  (submitter !== null &&
114
74
  requiresDangerousConfirmation(dangerousActionContext(submitter), getRole(submitter)));
@@ -120,7 +80,13 @@ function assertActionAllowed(el, action, args) {
120
80
  throw new Error(`potentially destructive action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`);
121
81
  }
122
82
  }
123
- import { NO_GEOMETRY, fireClickSequence, fireTapSequence, clickGeometry, firePointer, firePointerNonBubbling, dragElement, mouseEventFor, } from './actions-dom.js';
83
+ function optionValueFor(options, named) {
84
+ if (options.some((option) => option.value === named))
85
+ return named;
86
+ const byLabel = options.filter((option) => option.label.trim() === named.trim());
87
+ return 1 === byLabel.length ? (byLabel[0]?.value ?? named) : named;
88
+ }
89
+ import { NO_GEOMETRY, fireClickSequence, fireTapSequence, clickGeometry, firePointer, firePointerNonBubbling, dragElement, mouseEventFor, assertNotLeaving, } from './actions-dom.js';
124
90
  import { scrollFrom } from './scroll.js';
125
91
  function enabledOf(el) {
126
92
  return !getStates(el).includes(ElementState.DISABLED);
@@ -137,19 +103,13 @@ function activeRef(el) {
137
103
  return null;
138
104
  return refs.refFor(active);
139
105
  }
140
- const MAX_HOLD_MS = 30_000;
141
- function clampHold(raw) {
142
- if ('number' !== typeof raw || !Number.isFinite(raw) || raw <= 0)
143
- return 0;
144
- return Math.min(raw, MAX_HOLD_MS);
145
- }
146
106
  async function dispatchFor(el, action, args) {
147
107
  if (ActionType.CLICK === action) {
148
- const hold = clampHold(args['holdMs']);
108
+ const hold = clampHoldMs(args['holdMs']);
149
109
  return await fireClickSequence(el, 0 === hold ? undefined : { ms: hold, sleep, now: () => Date.now() });
150
110
  }
151
111
  if (ActionType.TAP === action) {
152
- const hold = clampHold(args['holdMs']);
112
+ const hold = clampHoldMs(args['holdMs']);
153
113
  return await fireTapSequence(el, 0 === hold ? undefined : { ms: hold, sleep, now: () => Date.now() });
154
114
  }
155
115
  return { prevented: await dispatchOther(el, action, args), heldMs: 0 };
@@ -175,7 +135,7 @@ async function dispatchOther(el, action, args) {
175
135
  el.dispatchEvent(mouseEventFor(el, 'mouseenter', { relatedTarget: from }));
176
136
  firePointer(el, 'pointermove', from);
177
137
  const moved = el.dispatchEvent(mouseEventFor(el, 'mousemove', { bubbles: true, cancelable: true }));
178
- const holdMs = clampHold(args['holdMs']);
138
+ const holdMs = clampHoldMs(args['holdMs']);
179
139
  if (holdMs > 0)
180
140
  await sleep(holdMs);
181
141
  return !moved;
@@ -201,7 +161,8 @@ async function dispatchOther(el, action, args) {
201
161
  'To empty a field on purpose use the `clear` action, or fill with an explicit "".');
202
162
  }
203
163
  assertEditable(el, 'fill');
204
- el.focus();
164
+ if (!isAutofillSafeTarget(el))
165
+ el.focus();
205
166
  return setNativeValue(el, args['value']);
206
167
  }
207
168
  assertNotRichText(el, 'fill');
@@ -227,8 +188,8 @@ async function dispatchOther(el, action, args) {
227
188
  if (typeof args['value'] !== 'string') {
228
189
  throw new Error("select requires a string `value` — pass it nested, as args: { value: '…' }.");
229
190
  }
230
- const wanted = args['value'];
231
191
  const options = Array.from(el.options);
192
+ const wanted = optionValueFor(options, args['value']);
232
193
  if (!options.some((option) => option.value === wanted)) {
233
194
  const available = options.map((option) => `${option.value} (${option.label})`).join(', ');
234
195
  throw new Error(`no <option> with value '${echoRef(wanted)}' — available: ${0 === options.length ? '(none)' : available}`);
@@ -274,14 +235,17 @@ async function dispatchOther(el, action, args) {
274
235
  const mods = pressModifiers(args);
275
236
  const combo = pressKeys(args);
276
237
  if (combo.length > 0)
277
- return await pressCombo(el, combo, mods, clampHold(args['holdMs']));
238
+ return await pressCombo(el, combo, mods, clampHoldMs(args['holdMs']));
278
239
  const key = pressKey(args);
279
240
  const code = pressCode(args, key);
280
- const down = asSyntheticInput(() => el.dispatchEvent(new KeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true, ...mods })));
281
- const hold = clampHold(args['holdMs']);
241
+ const down = asSyntheticInput(() => el.dispatchEvent(keyboardEvent('keydown', { key, code, bubbles: true, cancelable: true, ...mods })));
242
+ closeModalOnEscape(el, key, down);
243
+ if (down)
244
+ dispatchKeypress(el, key, code, mods);
245
+ const hold = clampHoldMs(args['holdMs']);
282
246
  if (hold > 0)
283
247
  await holdKey(el, key, code, mods, hold);
284
- asSyntheticInput(() => el.dispatchEvent(new KeyboardEvent('keyup', { key, code, bubbles: true, ...mods })));
248
+ asSyntheticInput(() => el.dispatchEvent(keyboardEvent('keyup', { key, code, bubbles: true, ...mods })));
285
249
  return !down;
286
250
  }
287
251
  case ActionType.SCROLL_INTO_VIEW:
@@ -346,6 +310,8 @@ export async function executeAction(ref, action, args = {}) {
346
310
  const el = isReflessDocumentPress(ref, action, args) ? focusedOrDocument() : requireElement(ref);
347
311
  if (named)
348
312
  assertActionAllowed(el, action, args);
313
+ if (named)
314
+ assertNotLeaving(el);
349
315
  const anchor = named ? anchorOf(el) : {};
350
316
  const visible = isVisible(el);
351
317
  const enabled = enabledOf(el);
@@ -392,6 +358,7 @@ export async function executeAction(ref, action, args = {}) {
392
358
  defaultPrevented,
393
359
  focusMoved: prevFocus !== nextFocus ? `${prevFocus ?? 'null'}->${nextFocus ?? 'null'}` : null,
394
360
  valueChanged: isFillLike(action) ? valueBefore !== valueAfter : false,
361
+ ...(ActionType.FILL === action && isAutofillSafeTarget(el) ? { autofillSafe: true } : {}),
395
362
  domMutatedWithin: mutated,
396
363
  ...said.effect(valueAfter),
397
364
  occluded: geometry.occluded,
@@ -1,4 +1,5 @@
1
1
  import { isIgnored } from '../dom/dom-ignore.js';
2
+ import { isSensitiveField } from '../dom/a11y.js';
2
3
  const APPEARED_MAX = 200;
3
4
  const JOIN = ' | ';
4
5
  const TEXT_NODE = 3;
@@ -46,6 +47,8 @@ export class AppearedText {
46
47
  #add(raw, owner) {
47
48
  if (null !== owner && isIgnored(owner))
48
49
  return;
50
+ if (null !== owner && isSensitiveField(owner))
51
+ return;
49
52
  const text = (raw ?? '').replace(/\s+/g, ' ').trim();
50
53
  if (0 === text.length)
51
54
  return;
@@ -2,3 +2,5 @@ import { type ActionTarget } from '../dom/realm.js';
2
2
  export declare function dangerousActionContext(el: ActionTarget): string;
3
3
  export declare function submitControlFor(el: ActionTarget): HTMLElement | null;
4
4
  export declare function requiresDangerousConfirmation(text: string, role?: string): boolean;
5
+ export declare function setOutwardPolicy(allowed: readonly string[] | undefined): void;
6
+ export declare function assertOutwardAllowed(targets: readonly (ActionTarget | null)[]): void;
@@ -1,5 +1,5 @@
1
- import { isDangerousActionText } from '@reticlehq/core';
2
- import { getAccessibleName } from '../dom/a11y.js';
1
+ import { isDangerousActionText, outwardActionOf, outwardRefusal } from '@reticlehq/core';
2
+ import { getAccessibleName, getRole } from '../dom/a11y.js';
3
3
  import { isHtmlElement } from '../dom/realm.js';
4
4
  const LABEL_VALUED_INPUT_TYPES = new Set([
5
5
  'submit',
@@ -16,7 +16,18 @@ function holdsUserText(el) {
16
16
  return !LABEL_VALUED_INPUT_TYPES.has(el.type.toLowerCase());
17
17
  return false;
18
18
  }
19
+ const ANCHOR_TAG = 'A';
20
+ const INERT_HREF = /^\s*(#.*|javascript:.*)?$/i;
21
+ function navigatingHref(el) {
22
+ if (ANCHOR_TAG !== el.tagName)
23
+ return undefined;
24
+ const href = el.getAttribute('href');
25
+ return null === href || INERT_HREF.test(href) ? undefined : href;
26
+ }
19
27
  export function dangerousActionContext(el) {
28
+ const href = navigatingHref(el);
29
+ if (href !== undefined)
30
+ return href;
20
31
  const form = el.closest('form');
21
32
  const ownText = holdsUserText(el)
22
33
  ? ['', '']
@@ -33,8 +44,23 @@ export function dangerousActionContext(el) {
33
44
  const SUBMIT_CONTROL_SELECTOR = 'button[type="submit"], input[type="submit"], button:not([type]):not([type=""])';
34
45
  export function submitControlFor(el) {
35
46
  const found = el.closest('form')?.querySelector(SUBMIT_CONTROL_SELECTOR);
36
- return found instanceof HTMLElement ? found : null;
47
+ return isHtmlElement(found) ? found : null;
37
48
  }
38
49
  export function requiresDangerousConfirmation(text, role) {
39
50
  return isDangerousActionText(text, role);
40
51
  }
52
+ let outwardAllowed;
53
+ export function setOutwardPolicy(allowed) {
54
+ outwardAllowed = allowed;
55
+ }
56
+ export function assertOutwardAllowed(targets) {
57
+ if (outwardAllowed === undefined)
58
+ return;
59
+ for (const el of targets) {
60
+ if (null === el)
61
+ continue;
62
+ const kind = outwardActionOf(dangerousActionContext(el), getRole(el));
63
+ if (kind !== undefined && !outwardAllowed.includes(kind))
64
+ throw new Error(outwardRefusal(kind));
65
+ }
66
+ }
@@ -1,4 +1,5 @@
1
1
  import { type ActionTarget } from '../dom/realm.js';
2
+ export declare function isAutofillSafeTarget(el: Element, ua?: string): boolean;
2
3
  export declare function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): boolean;
3
4
  export declare function assertNotRichText(el: ActionTarget, action: string): void;
4
5
  export declare function assertEditable(el: HTMLInputElement | HTMLTextAreaElement, action: string): void;
@@ -1,5 +1,16 @@
1
1
  import { captureValueSetter } from '../patching/capture-method.js';
2
2
  import { isHtmlElement, valuePrototypeOf } from '../dom/realm.js';
3
+ const PASSWORD_AUTOCOMPLETE = /\b(current|new)-password\b/;
4
+ const NOT_WEBKIT = /Chrome|Chromium|Edg\/|Firefox|Gecko\//;
5
+ export function isAutofillSafeTarget(el, ua = navigator.userAgent) {
6
+ if (!(el instanceof HTMLInputElement))
7
+ return false;
8
+ const password = 'password' === el.type.toLowerCase() ||
9
+ PASSWORD_AUTOCOMPLETE.test(el.getAttribute('autocomplete') ?? '');
10
+ if (!password || NOT_WEBKIT.test(ua))
11
+ return false;
12
+ return 600 <= Number(/AppleWebKit\/(\d+)/.exec(ua)?.[1] ?? 0);
13
+ }
3
14
  export function setNativeValue(el, value) {
4
15
  const proto = valuePrototypeOf(el);
5
16
  const setter = proto === undefined ? undefined : captureValueSetter(proto);
@@ -122,6 +122,9 @@ function readState(ref, store, path, depth) {
122
122
  const names = storeNames();
123
123
  if (path !== undefined || depth !== undefined) {
124
124
  const rawStores = readStoresRaw(store);
125
+ if (store !== undefined && !Object.hasOwn(rawStores, store)) {
126
+ return { store, path, found: false, value: null, storeNames: names };
127
+ }
125
128
  const base = store !== undefined ? rawStores[store] : { stores: rawStores, storeNames: names };
126
129
  const selection = path !== undefined ? selectPath(base, path) : { found: true, value: base };
127
130
  const selected = selection.found && depth !== undefined ? capDepth(selection.value, depth) : selection.value;
@@ -220,6 +223,7 @@ export function createCommandRegistry() {
220
223
  reg.set(ReticleCommand.STORAGE_READ, (args) => readStorage(str(args['area'])));
221
224
  reg.set(ReticleCommand.CAPABILITIES, () => getCapabilities());
222
225
  reg.set(ReticleCommand.CAPTURE, (args) => captureDesktopWindow(true === args['fullPage']));
226
+ reg.set(ReticleCommand.RENDER, () => import('../dom/render-page.js').then((m) => m.renderPage()));
223
227
  reg.set(ReticleCommand.SCROLL, (args) => {
224
228
  const dy = args['dy'];
225
229
  const fraction = args['fraction'];
@@ -6,6 +6,7 @@ export interface ReticleConnectOptions {
6
6
  token?: string;
7
7
  allowNonLocalhost?: boolean;
8
8
  allowInProduction?: boolean;
9
+ testIdAttribute?: string;
9
10
  overlay?: boolean;
10
11
  captureNetworkBodies?: boolean;
11
12
  networkBodyMaxChars?: number;
package/dist/dom/a11y.js CHANGED
@@ -1,4 +1,5 @@
1
- import { ElementState, REDACTED_VALUE } from '@reticlehq/core';
1
+ import { readTestId } from './addressing/testid-attr.js';
2
+ import { DEFAULT_TESTID_ATTR, ElementState, REDACTED_VALUE, } from '@reticlehq/core';
2
3
  import { isButton, isHtmlElement, isImage, isInput, isMeter, isOutput, isProgress, isSelect, isTextArea, } from './realm.js';
3
4
  import { refs } from './addressing/refs.js';
4
5
  import { capturedRootOf } from './shadow-registry.js';
@@ -31,9 +32,11 @@ const NAME_FROM_CONTENT = new Set([
31
32
  'status',
32
33
  'alert',
33
34
  ]);
34
- const INPUT_TEXT_TYPES = new Set(['text', 'email', 'tel', 'url', 'search', 'password', '']);
35
+ const INPUT_TEXT_TYPES = new Set(['text', 'email', 'tel', 'url', 'password', '']);
35
36
  function inputRole(input) {
36
37
  const type = input.type.toLowerCase();
38
+ if ('search' === type)
39
+ return 'searchbox';
37
40
  if (INPUT_TEXT_TYPES.has(type))
38
41
  return 'textbox';
39
42
  if ('checkbox' === type)
@@ -274,6 +277,7 @@ export function getStates(el, visible = isVisible(el)) {
274
277
  states.push(ElementState.FOCUSED);
275
278
  return states;
276
279
  }
280
+ const SENSITIVE_AUTOCOMPLETE = /\b(cc-|current-password|new-password|one-time-code)/i;
277
281
  export function isSensitiveField(el) {
278
282
  if (!isInput(el) && !isTextArea(el) && !isSelect(el)) {
279
283
  return false;
@@ -282,10 +286,11 @@ export function isSensitiveField(el) {
282
286
  const identifiers = [
283
287
  el.getAttribute('name') ?? '',
284
288
  el.id,
285
- el.getAttribute('data-testid') ?? '',
289
+ readTestId(el) ?? '',
290
+ el.getAttribute(DEFAULT_TESTID_ATTR) ?? '',
286
291
  el.getAttribute('aria-label') ?? '',
287
292
  ];
288
- const sensitiveAutocomplete = /current-password|new-password|cc-number|cc-csc|one-time-code/i.test(autocomplete);
293
+ const sensitiveAutocomplete = SENSITIVE_AUTOCOMPLETE.test(autocomplete);
289
294
  return ((isInput(el) && 'password' === el.type.toLowerCase()) ||
290
295
  sensitiveAutocomplete ||
291
296
  identifiers.some(isSensitiveKey));
@@ -293,7 +298,7 @@ export function isSensitiveField(el) {
293
298
  export function getValue(el) {
294
299
  if (isInput(el) || isTextArea(el) || isSelect(el)) {
295
300
  if (isSensitiveField(el))
296
- return REDACTED_VALUE;
301
+ return '' === el.value ? '' : REDACTED_VALUE;
297
302
  return el.value;
298
303
  }
299
304
  const valueNow = el.getAttribute('aria-valuenow');
@@ -309,22 +314,29 @@ function hiddenInsideClosedDetails(el) {
309
314
  const summary = Array.from(details.children).find((child) => HTML_SUMMARY_TAG === child.localName);
310
315
  return summary === undefined || !summary.contains(el);
311
316
  }
312
- function selfHidden(el) {
313
- if ('true' === el.getAttribute('aria-hidden'))
317
+ function stalledFade(el) {
318
+ if (!el.ownerDocument.hidden || !('getAnimations' in el))
319
+ return false;
320
+ return el.getAnimations().some((a) => 'running' === a.playState || a.pending);
321
+ }
322
+ const OVERLAY_ARIA_HIDDEN = 'data-aria-hidden';
323
+ function hiddenByAnOverlay(el) {
324
+ return el.hasAttribute(OVERLAY_ARIA_HIDDEN);
325
+ }
326
+ function selfHidden(el, style) {
327
+ if ('true' === el.getAttribute('aria-hidden') && !hiddenByAnOverlay(el))
314
328
  return true;
315
329
  if (isHtmlElement(el) && el.hidden)
316
330
  return true;
317
331
  if (hiddenInsideClosedDetails(el))
318
332
  return true;
319
- const view = el.ownerDocument.defaultView;
320
- if (view !== null) {
321
- const style = view.getComputedStyle(el);
333
+ if (style !== null) {
322
334
  if ('none' === style.display ||
323
335
  'hidden' === style.visibility ||
324
336
  'collapse' === style.visibility) {
325
337
  return true;
326
338
  }
327
- if (0 === Number.parseFloat(style.opacity || '1'))
339
+ if (0 === Number.parseFloat(style.opacity || '1') && !stalledFade(el))
328
340
  return true;
329
341
  }
330
342
  return false;
@@ -351,13 +363,252 @@ function parentAcrossShadowBoundary(el) {
351
363
  const host = el.getRootNode().host;
352
364
  return host ?? null;
353
365
  }
366
+ const CLIPPING_OVERFLOW = new Set(['hidden', 'clip']);
367
+ const SCROLLING_OVERFLOW = new Set(['auto', 'scroll']);
368
+ const NON_CLIPPING_DISPLAY = new Set([
369
+ 'contents',
370
+ 'table-row',
371
+ 'table-row-group',
372
+ 'table-header-group',
373
+ 'table-footer-group',
374
+ 'table-column',
375
+ 'table-column-group',
376
+ ]);
377
+ const TRANSFORM_CONTAINING_BLOCK_PROPERTIES = new Set([
378
+ 'transform',
379
+ 'translate',
380
+ 'rotate',
381
+ 'scale',
382
+ 'perspective',
383
+ ]);
384
+ const CONTAINING_BLOCK_PROPERTIES = new Set([
385
+ ...TRANSFORM_CONTAINING_BLOCK_PROPERTIES,
386
+ 'filter',
387
+ 'backdrop-filter',
388
+ ]);
389
+ const CONTAINING_BLOCK_CONTAIN = new Set(['layout', 'paint', 'strict', 'content']);
390
+ const REPLACED_INLINE_TAGS = new Set([
391
+ 'img',
392
+ 'video',
393
+ 'audio',
394
+ 'canvas',
395
+ 'iframe',
396
+ 'embed',
397
+ 'object',
398
+ 'input',
399
+ 'textarea',
400
+ 'select',
401
+ ]);
402
+ const visibilityInfoMemos = new WeakMap();
403
+ function visibilityInfoMemo(memo) {
404
+ if (memo === undefined)
405
+ return new Map();
406
+ let info = visibilityInfoMemos.get(memo);
407
+ if (info === undefined) {
408
+ info = new Map();
409
+ visibilityInfoMemos.set(memo, info);
410
+ }
411
+ return info;
412
+ }
413
+ function ownVisibilityInfo(el, memo) {
414
+ let info = memo.get(el);
415
+ if (info === undefined) {
416
+ const style = el.ownerDocument.defaultView?.getComputedStyle(el) ?? null;
417
+ if (selfHidden(el, style)) {
418
+ info = {
419
+ hidden: true,
420
+ style,
421
+ parent: null,
422
+ clipX: false,
423
+ clipY: false,
424
+ scrollX: false,
425
+ scrollY: false,
426
+ hasClipping: false,
427
+ };
428
+ memo.set(el, info);
429
+ return info;
430
+ }
431
+ const parent = parentAcrossShadowBoundary(el);
432
+ const inherited = null === parent ? undefined : ownVisibilityInfo(parent, memo);
433
+ const [overflowX, overflowY] = overflowAxes(style);
434
+ const root = el.ownerDocument.documentElement;
435
+ const viewportOverflow = el === root ||
436
+ (el === el.ownerDocument.body &&
437
+ overflowAxes(ownVisibilityInfo(root, memo).style).every((axis) => 'visible' === axis));
438
+ const hasClipBox = !viewportOverflow &&
439
+ !NON_CLIPPING_DISPLAY.has(style?.display ?? '') &&
440
+ !(isHtmlElement(el) && 'inline' === style?.display);
441
+ const clipX = hasClipBox && CLIPPING_OVERFLOW.has(overflowX);
442
+ const clipY = hasClipBox && CLIPPING_OVERFLOW.has(overflowY);
443
+ info = {
444
+ hidden: true === inherited?.hidden,
445
+ style,
446
+ parent,
447
+ clipX,
448
+ clipY,
449
+ scrollX: hasClipBox && SCROLLING_OVERFLOW.has(overflowX),
450
+ scrollY: hasClipBox && SCROLLING_OVERFLOW.has(overflowY),
451
+ hasClipping: clipX || clipY || true === inherited?.hasClipping,
452
+ };
453
+ memo.set(el, info);
454
+ }
455
+ return info;
456
+ }
457
+ function overflowAxes(style) {
458
+ const shorthand = style?.overflow ?? '';
459
+ const [x = 'visible', y = x] = ('' === shorthand ? 'visible' : shorthand).split(/\s+/);
460
+ return [
461
+ null === style || '' === style.overflowX ? x : style.overflowX,
462
+ null === style || '' === style.overflowY ? y : style.overflowY,
463
+ ];
464
+ }
465
+ function positionedContainingBlock(el, fixed, memo) {
466
+ for (let parent = ownVisibilityInfo(el, memo).parent; parent !== null; parent = ownVisibilityInfo(parent, memo).parent) {
467
+ const style = ownVisibilityInfo(parent, memo).style;
468
+ if (null === style || 'contents' === style.display)
469
+ continue;
470
+ if (!fixed && '' !== style.position && 'static' !== style.position)
471
+ return parent;
472
+ const transformable = !('inline' === style.display &&
473
+ isHtmlElement(parent) &&
474
+ !REPLACED_INLINE_TAGS.has(parent.localName)) &&
475
+ 'table-column' !== style.display &&
476
+ 'table-column-group' !== style.display;
477
+ const applies = (property) => transformable || !TRANSFORM_CONTAINING_BLOCK_PROPERTIES.has(property);
478
+ if ([...CONTAINING_BLOCK_PROPERTIES].some((property) => {
479
+ const value = style.getPropertyValue(property);
480
+ return applies(property) && '' !== value && 'none' !== value;
481
+ }) ||
482
+ style.willChange
483
+ .split(',')
484
+ .some((property) => CONTAINING_BLOCK_PROPERTIES.has(property.trim()) && applies(property.trim())) ||
485
+ (transformable &&
486
+ !NON_CLIPPING_DISPLAY.has(style.display) &&
487
+ (style.contain.split(/\s+/).some((value) => CONTAINING_BLOCK_CONTAIN.has(value)) ||
488
+ 'auto' === style.contentVisibility)))
489
+ return parent;
490
+ }
491
+ return null;
492
+ }
493
+ function clippingBox(el, info) {
494
+ if (info.clipBox !== undefined)
495
+ return info.clipBox;
496
+ const rect = el.getBoundingClientRect();
497
+ const style = info.style;
498
+ const px = (value) => Number.parseFloat(value ?? '') || 0;
499
+ const border = (value, kind) => kind === undefined || '' === kind || 'none' === kind || 'hidden' === kind ? 0 : px(value);
500
+ const scaleX = isHtmlElement(el) && el.offsetWidth > 0 ? rect.width / el.offsetWidth : 1;
501
+ const scaleY = isHtmlElement(el) && el.offsetHeight > 0 ? rect.height / el.offsetHeight : 1;
502
+ const left = isHtmlElement(el) && el.offsetWidth > 0
503
+ ? rect.left + el.clientLeft * scaleX
504
+ : rect.left + border(style?.borderLeftWidth, style?.borderLeftStyle);
505
+ const top = isHtmlElement(el) && el.offsetHeight > 0
506
+ ? rect.top + el.clientTop * scaleY
507
+ : rect.top + border(style?.borderTopWidth, style?.borderTopStyle);
508
+ const right = isHtmlElement(el) && el.offsetWidth > 0
509
+ ? left + el.clientWidth * scaleX
510
+ : rect.right - border(style?.borderRightWidth, style?.borderRightStyle);
511
+ const bottom = isHtmlElement(el) && el.offsetHeight > 0
512
+ ? top + el.clientHeight * scaleY
513
+ : rect.bottom - border(style?.borderBottomWidth, style?.borderBottomStyle);
514
+ const margin = (style?.overflowClipMargin ?? '').split(/\s+/);
515
+ const outset = px(margin.at(-1));
516
+ const borderBox = margin.includes('border-box');
517
+ const contentBox = margin.includes('content-box');
518
+ const [x, y] = overflowAxes(style);
519
+ const clipLeft = 'clip' === x
520
+ ? (borderBox ? rect.left : left + (contentBox ? px(style?.paddingLeft) * scaleX : 0)) -
521
+ outset * scaleX
522
+ : left;
523
+ const clipRight = 'clip' === x
524
+ ? (borderBox ? rect.right : right - (contentBox ? px(style?.paddingRight) * scaleX : 0)) +
525
+ outset * scaleX
526
+ : right;
527
+ const clipTop = 'clip' === y
528
+ ? (borderBox ? rect.top : top + (contentBox ? px(style?.paddingTop) * scaleY : 0)) -
529
+ outset * scaleY
530
+ : top;
531
+ const clipBottom = 'clip' === y
532
+ ? (borderBox ? rect.bottom : bottom - (contentBox ? px(style?.paddingBottom) * scaleY : 0)) +
533
+ outset * scaleY
534
+ : bottom;
535
+ info.clipBox = {
536
+ left: clipLeft,
537
+ right: clipRight,
538
+ top: clipTop,
539
+ bottom: clipBottom,
540
+ width: clipRight - clipLeft,
541
+ height: clipBottom - clipTop,
542
+ };
543
+ return info.clipBox;
544
+ }
545
+ function scrolledInto(b, port, alongX, alongY) {
546
+ const axis = (lo, hi, size, from, to) => {
547
+ if (hi > from && lo < to)
548
+ return [lo, hi];
549
+ const span = Math.min(size, to - from);
550
+ return lo >= to ? [to - span, to] : [from, from + span];
551
+ };
552
+ const [left, right] = alongX
553
+ ? axis(b.left, b.right, b.width, port.left, port.right)
554
+ : [b.left, b.right];
555
+ const [top, bottom] = alongY
556
+ ? axis(b.top, b.bottom, b.height, port.top, port.bottom)
557
+ : [b.top, b.bottom];
558
+ return { left, right, top, bottom, width: right - left, height: bottom - top };
559
+ }
560
+ function visibleBounds(el, memo, initial, throughScrollers = false) {
561
+ let bounds = initial;
562
+ const target = ownVisibilityInfo(el, memo);
563
+ if (target.hidden)
564
+ return null;
565
+ if (null === target.parent || !ownVisibilityInfo(target.parent, memo).hasClipping)
566
+ return bounds;
567
+ let clipFrom;
568
+ for (let current = el; current !== null; current = ownVisibilityInfo(current, memo).parent) {
569
+ const info = ownVisibilityInfo(current, memo);
570
+ if (info.hidden)
571
+ return null;
572
+ if (current === clipFrom)
573
+ clipFrom = undefined;
574
+ const style = info.style;
575
+ if (throughScrollers &&
576
+ current !== el &&
577
+ undefined === clipFrom &&
578
+ (info.scrollX || info.scrollY)) {
579
+ bounds ??= el.getBoundingClientRect();
580
+ if (bounds.width > 0 && bounds.height > 0) {
581
+ bounds = scrolledInto(bounds, clippingBox(current, info), info.scrollX, info.scrollY);
582
+ }
583
+ }
584
+ if (current !== el && undefined === clipFrom && (info.clipX || info.clipY)) {
585
+ bounds ??= el.getBoundingClientRect();
586
+ if (bounds.width > 0 && bounds.height > 0) {
587
+ const clip = clippingBox(current, info);
588
+ const left = info.clipX ? Math.max(bounds.left, clip.left) : bounds.left;
589
+ const right = info.clipX ? Math.min(bounds.right, clip.right) : bounds.right;
590
+ const top = info.clipY ? Math.max(bounds.top, clip.top) : bounds.top;
591
+ const bottom = info.clipY ? Math.min(bounds.bottom, clip.bottom) : bounds.bottom;
592
+ if (right <= left || bottom <= top)
593
+ return null;
594
+ bounds = { left, right, top, bottom, width: right - left, height: bottom - top };
595
+ }
596
+ }
597
+ if (clipFrom === undefined && ('absolute' === style?.position || 'fixed' === style?.position)) {
598
+ clipFrom = positionedContainingBlock(current, 'fixed' === style?.position, memo);
599
+ }
600
+ }
601
+ return bounds;
602
+ }
354
603
  export function isInViewport(el, memo) {
355
604
  if (!isVisible(el, memo))
356
605
  return false;
357
606
  const view = el.ownerDocument.defaultView;
358
607
  if (null === view)
359
608
  return false;
360
- const r = el.getBoundingClientRect();
609
+ const r = visibleBounds(el, visibilityInfoMemo(memo), el.getBoundingClientRect());
610
+ if (null === r || undefined === r)
611
+ return false;
361
612
  if (r.width <= 0 || r.height <= 0)
362
613
  return false;
363
614
  return r.bottom > 0 && r.right > 0 && r.top < view.innerHeight && r.left < view.innerWidth;
@@ -368,8 +619,7 @@ export function isVisible(el, memo) {
368
619
  const cached = memo?.get(el);
369
620
  if (cached !== undefined)
370
621
  return cached;
371
- const parent = parentAcrossShadowBoundary(el);
372
- const result = !selfHidden(el) && (null === parent || isVisible(parent, memo));
622
+ const result = visibleBounds(el, visibilityInfoMemo(memo), undefined, true) !== null;
373
623
  if (memo !== undefined)
374
624
  memo.set(el, result);
375
625
  return result;
@@ -0,0 +1,6 @@
1
+ export declare function coverageMarkers(doc: Document): string[];
2
+ export declare function notApplicableFor(page: {
3
+ buildPlugin: boolean;
4
+ adapters: readonly string[];
5
+ markers: readonly string[];
6
+ }): string[];