@reticlehq/browser 3.2.0 → 3.4.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 (83) hide show
  1. package/dist/actions/actions-dom.d.ts +6 -5
  2. package/dist/actions/actions-dom.js +5 -4
  3. package/dist/actions/actions-press.d.ts +3 -3
  4. package/dist/actions/actions-press.js +0 -3
  5. package/dist/actions/actions.d.ts +0 -1
  6. package/dist/actions/actions.js +17 -13
  7. package/dist/actions/check-state.d.ts +5 -0
  8. package/dist/actions/check-state.js +24 -0
  9. package/dist/actions/danger-context.d.ts +3 -2
  10. package/dist/actions/danger-context.js +2 -1
  11. package/dist/actions/value-input.d.ts +2 -1
  12. package/dist/actions/value-input.js +15 -6
  13. package/dist/connect-options.d.ts +1 -0
  14. package/dist/dom/a11y.js +43 -3
  15. package/dist/dom/addressing/source.d.ts +2 -1
  16. package/dist/dom/dom-ignore.d.ts +1 -0
  17. package/dist/dom/dom-ignore.js +13 -0
  18. package/dist/dom/query.js +6 -4
  19. package/dist/dom/realm.d.ts +6 -0
  20. package/dist/dom/realm.js +5 -0
  21. package/dist/dom/snapshot.d.ts +1 -1
  22. package/dist/dom/snapshot.js +8 -2
  23. package/dist/observers/console.js +38 -13
  24. package/dist/observers/dom.js +3 -1
  25. package/dist/observers/download.js +3 -2
  26. package/dist/observers/error-repeats.d.ts +18 -0
  27. package/dist/observers/error-repeats.js +41 -0
  28. package/dist/observers/field.d.ts +2 -0
  29. package/dist/observers/field.js +86 -0
  30. package/dist/observers/frames.js +3 -4
  31. package/dist/observers/install-all.js +2 -0
  32. package/dist/observers/net-detail/network-body.d.ts +2 -0
  33. package/dist/observers/net-detail/network-body.js +24 -7
  34. package/dist/observers/network.js +14 -1
  35. package/dist/observers/route.js +4 -4
  36. package/dist/observers/state.js +21 -8
  37. package/dist/presenter/carousel/carousel.d.ts +13 -0
  38. package/dist/presenter/carousel/carousel.js +126 -0
  39. package/dist/presenter/carousel/offer-card.d.ts +21 -0
  40. package/dist/presenter/carousel/offer-card.js +55 -0
  41. package/dist/presenter/carousel/panel-slides.d.ts +7 -0
  42. package/dist/presenter/carousel/panel-slides.js +10 -0
  43. package/dist/presenter/carousel/talk-card.d.ts +8 -0
  44. package/dist/presenter/carousel/talk-card.js +20 -0
  45. package/dist/presenter/chrome/presenter-log.d.ts +2 -1
  46. package/dist/presenter/chrome/presenter-log.js +14 -3
  47. package/dist/presenter/chrome/presenter-report-copy.d.ts +0 -1
  48. package/dist/presenter/chrome/presenter-report-copy.js +1 -1
  49. package/dist/presenter/hud-telemetry.d.ts +3 -0
  50. package/dist/presenter/hud-telemetry.js +87 -0
  51. package/dist/presenter/presenter-account.js +1 -1
  52. package/dist/presenter/presenter-config.d.ts +5 -1
  53. package/dist/presenter/presenter-config.js +4 -1
  54. package/dist/presenter/presenter-controls.d.ts +1 -0
  55. package/dist/presenter/presenter-controls.js +12 -1
  56. package/dist/presenter/presenter-report.js +6 -6
  57. package/dist/presenter/presenter-settings-styles.js +2 -1
  58. package/dist/presenter/presenter-settings.d.ts +2 -4
  59. package/dist/presenter/presenter-settings.js +13 -4
  60. package/dist/presenter/presenter-shell-styles.js +4 -0
  61. package/dist/presenter/presenter-shell.d.ts +4 -1
  62. package/dist/presenter/presenter-shell.js +49 -11
  63. package/dist/presenter/presenter-styles.js +5 -1
  64. package/dist/presenter/presenter-workspace.d.ts +0 -7
  65. package/dist/presenter/presenter-workspace.js +7 -7
  66. package/dist/presenter/presenter.js +43 -10
  67. package/dist/presenter/tour/tour.d.ts +3 -0
  68. package/dist/presenter/tour/tour.js +5 -0
  69. package/dist/recorder/recorder.js +25 -22
  70. package/dist/registry/store-adapters.js +32 -2
  71. package/dist/registry/stores/adapters.js +8 -2
  72. package/dist/reticle-inject.d.ts +2 -0
  73. package/dist/reticle-inject.js +1337 -0
  74. package/dist/reticle.js +6 -0
  75. package/dist/review/annotator.d.ts +3 -1
  76. package/dist/review/annotator.js +37 -40
  77. package/dist/review/marks-for-agent.d.ts +9 -0
  78. package/dist/review/marks-for-agent.js +12 -0
  79. package/dist/util/captured-method.d.ts +0 -1
  80. package/dist/util/captured-method.js +1 -1
  81. package/package.json +15 -11
  82. package/dist/presenter/presenter-offer.d.ts +0 -25
  83. package/dist/presenter/presenter-offer.js +0 -87
@@ -1,3 +1,4 @@
1
+ import type { ActionTarget } from '../dom/realm.js';
1
2
  interface ClickGeometry {
2
3
  occluded: boolean;
3
4
  occludedBy: string | null;
@@ -5,19 +6,19 @@ interface ClickGeometry {
5
6
  }
6
7
  export declare const NO_GEOMETRY: ClickGeometry;
7
8
  export declare function mouseEventFor(el: Element, type: string, init?: MouseEventInit): MouseEvent;
8
- export declare function fireClickSequence(el: HTMLElement, hold?: {
9
+ export declare function fireClickSequence(el: ActionTarget, hold?: {
9
10
  ms: number;
10
11
  sleep: (ms: number) => Promise<void>;
11
12
  now: () => number;
12
- }): Promise<{
13
+ }, detail?: number): Promise<{
13
14
  prevented: boolean;
14
15
  heldMs: number;
15
16
  }>;
16
- export declare function clickGeometry(el: HTMLElement): ClickGeometry;
17
+ export declare function clickGeometry(el: ActionTarget): ClickGeometry;
17
18
  export declare function firePointer(el: Element, type: string, relatedTarget?: EventTarget | null): void;
18
19
  export declare function firePointerNonBubbling(el: Element, type: string, relatedTarget?: EventTarget | null): void;
19
- export declare function dragElement(source: HTMLElement, target: HTMLElement | null, data: unknown): Promise<boolean>;
20
- export declare function fireTapSequence(el: HTMLElement, hold: {
20
+ export declare function dragElement(source: ActionTarget, target: ActionTarget | null, data: unknown): Promise<boolean>;
21
+ export declare function fireTapSequence(el: ActionTarget, hold: {
21
22
  ms: number;
22
23
  sleep: (ms: number) => Promise<void>;
23
24
  now: () => number;
@@ -16,11 +16,12 @@ function pointerEventFor(el, type, init) {
16
16
  ? new PointerEvent(type, full)
17
17
  : new MouseEvent(type, full);
18
18
  }
19
- export async function fireClickSequence(el, hold) {
19
+ export async function fireClickSequence(el, hold, detail) {
20
20
  const doc = el.ownerDocument;
21
21
  const from = doc.activeElement ?? doc.body;
22
+ const init = { bubbles: true, cancelable: true, detail: detail ?? 0 };
22
23
  firePointer(el, 'pointerdown', from);
23
- asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'mousedown', { bubbles: true, cancelable: true })));
24
+ asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'mousedown', init)));
24
25
  if (el.tabIndex >= 0 && 'function' === typeof el.focus)
25
26
  el.focus();
26
27
  let heldMs = 0;
@@ -30,8 +31,8 @@ export async function fireClickSequence(el, hold) {
30
31
  heldMs = hold.now() - startedAt;
31
32
  }
32
33
  firePointer(el, 'pointerup', from);
33
- asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'mouseup', { bubbles: true, cancelable: true })));
34
- const notPrevented = asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'click', { bubbles: true, cancelable: true })));
34
+ asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'mouseup', init)));
35
+ const notPrevented = asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'click', init)));
35
36
  return { prevented: !notPrevented, heldMs };
36
37
  }
37
38
  function isMeasurable(rect) {
@@ -1,3 +1,4 @@
1
+ import { type ActionTarget } from '../dom/realm.js';
1
2
  export declare function pressKey(args: Record<string, unknown>): string;
2
3
  export declare function pressModifiers(args: Record<string, unknown>): {
3
4
  metaKey: boolean;
@@ -9,6 +10,5 @@ export declare function pressCode(args: Record<string, unknown>, key: string): s
9
10
  export declare function focusedOrDocument(): HTMLElement;
10
11
  export declare function isReflessDocumentPress(ref: string, action: string, args: Record<string, unknown>): boolean;
11
12
  export declare function pressKeys(args: Record<string, unknown>): string[];
12
- export declare function holdKey(el: HTMLElement, key: string, code: string, mods: Record<string, boolean>, ms: number): Promise<void>;
13
- export declare function pressCombo(el: HTMLElement, keys: readonly string[], mods: Record<string, boolean>, holdMs: number): Promise<boolean>;
14
- export declare const ZOOM_NEEDS_REAL_BROWSER_MSG: string;
13
+ export declare function holdKey(el: ActionTarget, key: string, code: string, mods: Record<string, boolean>, ms: number): Promise<void>;
14
+ export declare function pressCombo(el: ActionTarget, keys: readonly string[], mods: Record<string, boolean>, holdMs: number): Promise<boolean>;
@@ -118,6 +118,3 @@ export async function pressCombo(el, keys, mods, holdMs) {
118
118
  }
119
119
  return prevented;
120
120
  }
121
- export const ZOOM_NEEDS_REAL_BROWSER_MSG = 'cannot zoom from inside the page — CSS zoom changes how it LOOKS without changing the layout ' +
122
- 'viewport, visualViewport or media queries, so a layout that breaks at 200% would be reported as ' +
123
- 'checked and passing. Drive a real browser (reticle drive <url>, or reticle_lease) and zoom there.';
@@ -34,7 +34,6 @@ interface ActionResult {
34
34
  };
35
35
  warning?: string;
36
36
  }
37
- export declare const MAX_HOLD_MS = 30000;
38
37
  export declare const ZOOM_NEEDS_REAL_BROWSER_MSG: string;
39
38
  export declare function executeAction(ref: string, action: string, args?: Record<string, unknown>): Promise<ActionResult>;
40
39
  export declare function dispatchWebMcp(tool: string, params: Record<string, unknown>, confirmDangerous?: boolean): Promise<unknown>;
@@ -5,9 +5,10 @@ import { dangerousActionContext, requiresDangerousConfirmation, submitControlFor
5
5
  import { assertEditable, assertNotRichText, setNativeValue } from './value-input.js';
6
6
  import { getAccessibleName, getRole, isVisible, getStates } from '../dom/a11y.js';
7
7
  import { elementHasHoverHandlers, identifyComponent } from '../registry/stores/adapters.js';
8
- import { isForm, isHtmlElement, isInput, isSelect, isTextArea } from '../dom/realm.js';
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
+ import { alreadyAtCheckedState, assertAriaToggleLanded, isAriaToggle } from './check-state.js';
11
12
  import { focusedOrDocument, isReflessDocumentPress, pressCode, pressKey, pressModifiers, pressKeys, holdKey, pressCombo, } from './actions-press.js';
12
13
  function asString(value, fallback = '') {
13
14
  return 'string' === typeof value ? value : fallback;
@@ -16,8 +17,8 @@ function requireElement(ref) {
16
17
  const el = refs.resolve(ref);
17
18
  if (null === el)
18
19
  throw new Error(`ref '${echoRef(ref)}' no longer resolves to an element`);
19
- if (!isHtmlElement(el))
20
- throw new Error(`ref '${echoRef(ref)}' is not an HTMLElement`);
20
+ if (!isActionTarget(el))
21
+ throw new Error(`ref '${echoRef(ref)}' is not an HTML or SVG element`);
21
22
  return el;
22
23
  }
23
24
  function anchorOf(el) {
@@ -76,11 +77,6 @@ const CLICK_LIKE = new Set([
76
77
  ActionType.CHECK,
77
78
  ActionType.UNCHECK,
78
79
  ]);
79
- function alreadyAtCheckedState(el, action) {
80
- if (action !== ActionType.CHECK && action !== ActionType.UNCHECK)
81
- return false;
82
- return isInput(el) && el.checked === (action === ActionType.CHECK);
83
- }
84
80
  function dragTargetRef(args) {
85
81
  const named = asString(args['toRef']);
86
82
  return '' !== named ? named : asString(args['target']);
@@ -116,7 +112,7 @@ function assertActionAllowed(el, action, args) {
116
112
  const sourceDangerous = requiresDangerousConfirmation(dangerousActionContext(el), getRole(el)) ||
117
113
  (submitter !== null &&
118
114
  requiresDangerousConfirmation(dangerousActionContext(submitter), getRole(submitter)));
119
- const targetDangerous = isHtmlElement(dragTarget) &&
115
+ const targetDangerous = isActionTarget(dragTarget) &&
120
116
  requiresDangerousConfirmation(dangerousActionContext(dragTarget), getRole(dragTarget));
121
117
  if (canTrigger &&
122
118
  (sourceDangerous || targetDangerous) &&
@@ -141,7 +137,7 @@ function activeRef(el) {
141
137
  return null;
142
138
  return refs.refFor(active);
143
139
  }
144
- export const MAX_HOLD_MS = 30_000;
140
+ const MAX_HOLD_MS = 30_000;
145
141
  function clampHold(raw) {
146
142
  if ('number' !== typeof raw || !Number.isFinite(raw) || raw <= 0)
147
143
  return 0;
@@ -167,7 +163,9 @@ function asFiniteNumber(raw) {
167
163
  async function dispatchOther(el, action, args) {
168
164
  switch (action) {
169
165
  case ActionType.DBLCLICK:
170
- return !asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'dblclick', { bubbles: true, cancelable: true })));
166
+ await fireClickSequence(el, undefined, 1);
167
+ await fireClickSequence(el, undefined, 2);
168
+ return !asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'dblclick', { bubbles: true, cancelable: true, detail: 2 })));
171
169
  case ActionType.HOVER: {
172
170
  const doc = el.ownerDocument;
173
171
  const from = doc.activeElement ?? doc.body;
@@ -242,11 +240,16 @@ async function dispatchOther(el, action, args) {
242
240
  throw new Error(`cannot select on a <${el.tagName.toLowerCase()}>`);
243
241
  case ActionType.CHECK:
244
242
  case ActionType.UNCHECK: {
245
- if (!isInput(el))
243
+ const aria = isAriaToggle(el);
244
+ if (!isInput(el) && !aria) {
246
245
  throw new Error(`cannot (un)check a <${el.tagName.toLowerCase()}>`);
246
+ }
247
247
  if (!enabledOf(el)) {
248
248
  throw new Error(`cannot ${action} a disabled control — a real user could not, so neither will Reticle`);
249
249
  }
250
+ if (!isInput(el)) {
251
+ return alreadyAtCheckedState(el, action) ? false : (await fireClickSequence(el)).prevented;
252
+ }
250
253
  if ('radio' === el.type && action === ActionType.UNCHECK) {
251
254
  throw new Error('cannot uncheck a radio button — a real user could not; select another radio in the group');
252
255
  }
@@ -322,7 +325,7 @@ async function dispatchOther(el, action, args) {
322
325
  if ('' === toRef)
323
326
  return await dragElement(el, null, args['data']);
324
327
  const resolved = refs.resolve(toRef);
325
- if (!isHtmlElement(resolved)) {
328
+ if (!isActionTarget(resolved)) {
326
329
  throw new Error(`drag target '${echoRef(toRef)}' did not resolve to an element — pass a ref from ` +
327
330
  'reticle_snapshot or reticle_query as args.toRef (alias: args.target)');
328
331
  }
@@ -378,6 +381,7 @@ export async function executeAction(ref, action, args = {}) {
378
381
  settleReason = outcome.settled ? null : SettleReason.TIMEOUT;
379
382
  obs.disconnect();
380
383
  }
384
+ assertAriaToggleLanded(el, action, alreadyAtValue);
381
385
  const valueAfter = valueOf(el);
382
386
  const nextFocus = activeRef(el);
383
387
  const effect = {
@@ -0,0 +1,5 @@
1
+ import { type ActionTarget } from '../dom/realm.js';
2
+ export declare const isAriaToggle: (el: Element) => boolean;
3
+ export declare const ariaChecked: (el: Element) => boolean;
4
+ export declare function assertAriaToggleLanded(el: Element, action: string, skipped: boolean): void;
5
+ export declare function alreadyAtCheckedState(el: ActionTarget, action: string): boolean;
@@ -0,0 +1,24 @@
1
+ import { ActionType } from '@reticlehq/core';
2
+ import { isInput } from '../dom/realm.js';
3
+ const ARIA_TOGGLE_ROLES = new Set(['checkbox', 'switch']);
4
+ const ARIA_CHECKED = 'aria-checked';
5
+ export const isAriaToggle = (el) => !isInput(el) && ARIA_TOGGLE_ROLES.has(el.getAttribute('role') ?? '');
6
+ export const ariaChecked = (el) => 'true' === el.getAttribute(ARIA_CHECKED);
7
+ export function assertAriaToggleLanded(el, action, skipped) {
8
+ if (skipped || !isAriaToggle(el))
9
+ return;
10
+ if (action !== ActionType.CHECK && action !== ActionType.UNCHECK)
11
+ return;
12
+ const wanted = action === ActionType.CHECK;
13
+ if (ariaChecked(el) === wanted)
14
+ return;
15
+ throw new Error(`${action} clicked the ${el.getAttribute('role') ?? ''} but ${ARIA_CHECKED} is still "${String(!wanted)}" — the app did not toggle it`);
16
+ }
17
+ export function alreadyAtCheckedState(el, action) {
18
+ if (action !== ActionType.CHECK && action !== ActionType.UNCHECK)
19
+ return false;
20
+ const wanted = action === ActionType.CHECK;
21
+ if (isInput(el))
22
+ return el.checked === wanted;
23
+ return isAriaToggle(el) && ariaChecked(el) === wanted;
24
+ }
@@ -1,3 +1,4 @@
1
- export declare function dangerousActionContext(el: HTMLElement): string;
2
- export declare function submitControlFor(el: HTMLElement): HTMLElement | null;
1
+ import { type ActionTarget } from '../dom/realm.js';
2
+ export declare function dangerousActionContext(el: ActionTarget): string;
3
+ export declare function submitControlFor(el: ActionTarget): HTMLElement | null;
3
4
  export declare function requiresDangerousConfirmation(text: string, role?: string): boolean;
@@ -1,5 +1,6 @@
1
1
  import { isDangerousActionText } from '@reticlehq/core';
2
2
  import { getAccessibleName } from '../dom/a11y.js';
3
+ import { isHtmlElement } from '../dom/realm.js';
3
4
  const LABEL_VALUED_INPUT_TYPES = new Set([
4
5
  'submit',
5
6
  'button',
@@ -7,7 +8,7 @@ const LABEL_VALUED_INPUT_TYPES = new Set([
7
8
  'image',
8
9
  ]);
9
10
  function holdsUserText(el) {
10
- if (el.isContentEditable)
11
+ if (isHtmlElement(el) && el.isContentEditable)
11
12
  return true;
12
13
  if (el instanceof HTMLTextAreaElement)
13
14
  return true;
@@ -1,3 +1,4 @@
1
+ import { type ActionTarget } from '../dom/realm.js';
1
2
  export declare function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): boolean;
2
- export declare function assertNotRichText(el: HTMLElement, action: string): void;
3
+ export declare function assertNotRichText(el: ActionTarget, action: string): void;
3
4
  export declare function assertEditable(el: HTMLInputElement | HTMLTextAreaElement, action: string): void;
@@ -1,5 +1,5 @@
1
1
  import { captureValueSetter } from '../patching/capture-method.js';
2
- import { valuePrototypeOf } from '../dom/realm.js';
2
+ import { isHtmlElement, valuePrototypeOf } from '../dom/realm.js';
3
3
  export function setNativeValue(el, value) {
4
4
  const proto = valuePrototypeOf(el);
5
5
  const setter = proto === undefined ? undefined : captureValueSetter(proto);
@@ -13,12 +13,21 @@ export function setNativeValue(el, value) {
13
13
  el.dispatchEvent(new Event('change', { bubbles: true }));
14
14
  return !notPrevented;
15
15
  }
16
- export function assertNotRichText(el, action) {
16
+ function isRichText(el) {
17
17
  const flag = el.getAttribute('contenteditable');
18
- if (el.isContentEditable || (flag !== null && flag !== 'false')) {
19
- throw new Error(`cannot ${action} a contenteditable element — rich-text editors keep their own document model, ` +
20
- 'so writing to the DOM here would look right and submit the old content. This surface is not supported yet.');
21
- }
18
+ if ((isHtmlElement(el) && el.isContentEditable) || (null !== flag && 'false' !== flag))
19
+ return true;
20
+ if ('textbox' === el.getAttribute('role'))
21
+ return true;
22
+ if (!('editContext' in el))
23
+ return false;
24
+ const surface = el;
25
+ return null !== surface.editContext && undefined !== surface.editContext;
26
+ }
27
+ export function assertNotRichText(el, action) {
28
+ if (!isRichText(el))
29
+ return;
30
+ throw new Error(`cannot ${action} a contenteditable or EditContext editor; use press or an input`);
22
31
  }
23
32
  export function assertEditable(el, action) {
24
33
  if (el.disabled) {
@@ -8,6 +8,7 @@ export interface ReticleConnectOptions {
8
8
  allowInProduction?: boolean;
9
9
  overlay?: boolean;
10
10
  captureNetworkBodies?: boolean;
11
+ networkBodyMaxChars?: number;
11
12
  sourceMapping?: boolean;
12
13
  captureErrorBodies?: boolean;
13
14
  exposePresenter?: boolean;
package/dist/dom/a11y.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { ElementState, REDACTED_VALUE } from '@reticlehq/core';
2
- import { isButton, isHtmlElement, isImage, isInput, isSelect, isTextArea } from './realm.js';
2
+ import { isButton, isHtmlElement, isImage, isInput, isMeter, isOutput, isProgress, isSelect, isTextArea, } from './realm.js';
3
3
  import { refs } from './addressing/refs.js';
4
4
  import { inspectChart } from './chart.js';
5
5
  import { isSensitiveKey } from '../security/serialization.js';
6
6
  import { formatSource, sourceFromDom } from './addressing/source.js';
7
+ const HTML_DETAILS_TAG = 'details';
8
+ const HTML_DETAILS_OPEN_ATTRIBUTE = 'open';
9
+ const HTML_DIRECT_SUMMARY_SELECTOR = ':scope > summary';
7
10
  const NAME_FROM_CONTENT = new Set([
8
11
  'button',
9
12
  'link',
@@ -190,7 +193,14 @@ export function getAccessibleName(el) {
190
193
  if (alt !== null)
191
194
  return alt.trim();
192
195
  }
193
- if (isInput(el) || isTextArea(el) || isSelect(el)) {
196
+ const isLabelable = isInput(el) ||
197
+ isTextArea(el) ||
198
+ isSelect(el) ||
199
+ isButton(el) ||
200
+ isMeter(el) ||
201
+ isOutput(el) ||
202
+ isProgress(el);
203
+ if (isLabelable) {
194
204
  const labels = el.labels;
195
205
  if (labels !== null && labels.length > 0) {
196
206
  const text = [...labels]
@@ -200,6 +210,16 @@ export function getAccessibleName(el) {
200
210
  if (text.length > 0)
201
211
  return text;
202
212
  }
213
+ }
214
+ if ('fieldset' === el.tagName.toLowerCase()) {
215
+ const legend = [...el.children].find((child) => 'legend' === child.tagName.toLowerCase());
216
+ if (legend !== undefined) {
217
+ const text = collapse(textWithoutHidden(legend));
218
+ if (text.length > 0)
219
+ return text;
220
+ }
221
+ }
222
+ if (isInput(el) || isTextArea(el) || isSelect(el)) {
203
223
  if (isInput(el)) {
204
224
  const type = el.type.toLowerCase();
205
225
  if ('submit' === type || 'button' === type || 'reset' === type) {
@@ -241,6 +261,8 @@ export function getStates(el, visible = isVisible(el)) {
241
261
  states.push(ElementState.CHECKED);
242
262
  if (true === ariaBool(el, 'aria-expanded'))
243
263
  states.push(ElementState.EXPANDED);
264
+ if (true === ariaBool(el, 'aria-pressed'))
265
+ states.push(ElementState.PRESSED);
244
266
  if (el.ownerDocument.activeElement === el)
245
267
  states.push(ElementState.FOCUSED);
246
268
  return states;
@@ -270,11 +292,23 @@ export function getValue(el) {
270
292
  const valueNow = el.getAttribute('aria-valuenow');
271
293
  return valueNow ?? undefined;
272
294
  }
295
+ function hiddenInsideClosedDetails(el) {
296
+ const parent = el.parentElement;
297
+ if (null === parent)
298
+ return false;
299
+ const details = parent.closest(HTML_DETAILS_TAG);
300
+ if (null === details || details.hasAttribute(HTML_DETAILS_OPEN_ATTRIBUTE))
301
+ return false;
302
+ const summary = details.querySelector(HTML_DIRECT_SUMMARY_SELECTOR);
303
+ return null === summary || !summary.contains(el);
304
+ }
273
305
  function selfHidden(el) {
274
306
  if ('true' === el.getAttribute('aria-hidden'))
275
307
  return true;
276
308
  if (isHtmlElement(el) && el.hidden)
277
309
  return true;
310
+ if (hiddenInsideClosedDetails(el))
311
+ return true;
278
312
  const view = el.ownerDocument.defaultView;
279
313
  if (view !== null) {
280
314
  const style = view.getComputedStyle(el);
@@ -288,6 +322,12 @@ function selfHidden(el) {
288
322
  }
289
323
  return false;
290
324
  }
325
+ function parentAcrossShadowBoundary(el) {
326
+ if (null !== el.parentElement)
327
+ return el.parentElement;
328
+ const host = el.getRootNode().host;
329
+ return host ?? null;
330
+ }
291
331
  export function isInViewport(el, memo) {
292
332
  if (!isVisible(el, memo))
293
333
  return false;
@@ -305,7 +345,7 @@ export function isVisible(el, memo) {
305
345
  const cached = memo?.get(el);
306
346
  if (cached !== undefined)
307
347
  return cached;
308
- const parent = el.parentElement;
348
+ const parent = parentAcrossShadowBoundary(el);
309
349
  const result = !selfHidden(el) && (null === parent || isVisible(parent, memo));
310
350
  if (memo !== undefined)
311
351
  memo.set(el, result);
@@ -1,4 +1,4 @@
1
- export interface SourceLocation {
1
+ interface SourceLocation {
2
2
  file: string;
3
3
  line: number;
4
4
  }
@@ -7,3 +7,4 @@ export declare function sourceFromDom(el: Element): SourceLocation | undefined;
7
7
  export declare function sourceFor(el: Element, adapterSource: SourceLocation | undefined): SourceLocation | undefined;
8
8
  export declare function documentHasSourceStamps(doc: Document): boolean;
9
9
  export declare function formatSource(source: SourceLocation | undefined): string | undefined;
10
+ export {};
@@ -5,3 +5,4 @@ export declare function setIgnoreSelectors(selectors: string[]): void;
5
5
  export declare function isReticleOverlay(el: Element): boolean;
6
6
  export declare function isReticleUi(node: Element | null): boolean;
7
7
  export declare function isIgnored(el: Element): boolean;
8
+ export declare function appModalOpen(doc: Document): boolean;
@@ -22,3 +22,16 @@ export function isIgnored(el) {
22
22
  const sel = [ours, DEV_OVERLAYS, extraIgnore].filter((part) => part.length > 0).join(',');
23
23
  return el.closest(sel) !== null;
24
24
  }
25
+ const APP_MODAL = 'dialog[open], [aria-modal="true"]';
26
+ export function appModalOpen(doc) {
27
+ for (const el of Array.from(doc.querySelectorAll(APP_MODAL))) {
28
+ if (el.closest(RETICLE_OVERLAY) !== null)
29
+ continue;
30
+ if (el instanceof HTMLElement && (el.hidden || null !== el.closest('[hidden]')))
31
+ continue;
32
+ if (el instanceof HTMLElement && 'none' === getComputedStyle(el).display)
33
+ continue;
34
+ return true;
35
+ }
36
+ return false;
37
+ }
package/dist/dom/query.js CHANGED
@@ -409,15 +409,16 @@ function buildEmptyHint(query) {
409
409
  const container = resolveContainer(query.scope).container ?? document.body;
410
410
  const all = container.querySelectorAll(`[${TESTID_ATTR}]`);
411
411
  const present = [];
412
+ const seen = new Set();
412
413
  for (const el of Array.from(all)) {
413
414
  if (isIgnored(el))
414
415
  continue;
415
416
  const id = el.getAttribute(TESTID_ATTR);
416
- if (id !== null && id.length > 0 && !present.includes(id)) {
417
+ if (null === id || 0 === id.length || seen.has(id))
418
+ continue;
419
+ seen.add(id);
420
+ if (present.length < MAX_PRESENT_TESTIDS)
417
421
  present.push(id);
418
- if (present.length >= MAX_PRESENT_TESTIDS)
419
- break;
420
- }
421
422
  }
422
423
  const registered = declaredTestids();
423
424
  const knownEmptyState = present.some((id) => registered.includes(id));
@@ -425,6 +426,7 @@ function buildEmptyHint(query) {
425
426
  const hint = {
426
427
  route,
427
428
  presentTestids: present,
429
+ ...(seen.size > present.length ? { presentTestidsTotal: seen.size } : {}),
428
430
  presentRegions: buildPresentRegions(query),
429
431
  knownEmptyState,
430
432
  };
@@ -1,10 +1,16 @@
1
1
  export declare const isElement: (n: unknown) => n is Element;
2
2
  export declare const isImage: (n: unknown) => n is HTMLImageElement;
3
3
  export declare const isHtmlElement: (n: unknown) => n is HTMLElement;
4
+ export declare const isSvgElement: (n: unknown) => n is SVGElement;
5
+ export type ActionTarget = HTMLElement | SVGElement;
6
+ export declare const isActionTarget: (n: unknown) => n is ActionTarget;
4
7
  export declare const isInput: (n: unknown) => n is HTMLInputElement;
5
8
  export declare const isTextArea: (n: unknown) => n is HTMLTextAreaElement;
6
9
  export declare const isSelect: (n: unknown) => n is HTMLSelectElement;
7
10
  export declare const isButton: (n: unknown) => n is HTMLButtonElement;
11
+ export declare const isMeter: (n: unknown) => n is HTMLMeterElement;
12
+ export declare const isOutput: (n: unknown) => n is HTMLOutputElement;
13
+ export declare const isProgress: (n: unknown) => n is HTMLProgressElement;
8
14
  export declare const isForm: (n: unknown) => n is HTMLFormElement;
9
15
  export declare const isFrame: (n: unknown) => n is HTMLIFrameElement;
10
16
  export declare function valuePrototypeOf(el: Element): object | undefined;
package/dist/dom/realm.js CHANGED
@@ -14,10 +14,15 @@ function isIn(node, name, ambient) {
14
14
  export const isElement = (n) => isIn(n, 'Element', 'undefined' === typeof Element ? undefined : Element);
15
15
  export const isImage = (n) => isIn(n, 'HTMLImageElement', 'undefined' === typeof HTMLImageElement ? undefined : HTMLImageElement);
16
16
  export const isHtmlElement = (n) => isIn(n, 'HTMLElement', 'undefined' === typeof HTMLElement ? undefined : HTMLElement);
17
+ export const isSvgElement = (n) => isIn(n, 'SVGElement', 'undefined' === typeof SVGElement ? undefined : SVGElement);
18
+ export const isActionTarget = (n) => isHtmlElement(n) || isSvgElement(n);
17
19
  export const isInput = (n) => isIn(n, 'HTMLInputElement', 'undefined' === typeof HTMLInputElement ? undefined : HTMLInputElement);
18
20
  export const isTextArea = (n) => isIn(n, 'HTMLTextAreaElement', 'undefined' === typeof HTMLTextAreaElement ? undefined : HTMLTextAreaElement);
19
21
  export const isSelect = (n) => isIn(n, 'HTMLSelectElement', 'undefined' === typeof HTMLSelectElement ? undefined : HTMLSelectElement);
20
22
  export const isButton = (n) => isIn(n, 'HTMLButtonElement', 'undefined' === typeof HTMLButtonElement ? undefined : HTMLButtonElement);
23
+ export const isMeter = (n) => isIn(n, 'HTMLMeterElement', 'undefined' === typeof HTMLMeterElement ? undefined : HTMLMeterElement);
24
+ export const isOutput = (n) => isIn(n, 'HTMLOutputElement', 'undefined' === typeof HTMLOutputElement ? undefined : HTMLOutputElement);
25
+ export const isProgress = (n) => isIn(n, 'HTMLProgressElement', 'undefined' === typeof HTMLProgressElement ? undefined : HTMLProgressElement);
21
26
  export const isForm = (n) => isIn(n, 'HTMLFormElement', 'undefined' === typeof HTMLFormElement ? undefined : HTMLFormElement);
22
27
  export const isFrame = (n) => isIn(n, 'HTMLIFrameElement', 'undefined' === typeof HTMLIFrameElement ? undefined : HTMLIFrameElement);
23
28
  export function valuePrototypeOf(el) {
@@ -5,7 +5,7 @@ interface SnapshotStatus {
5
5
  visibleDialogs?: string[];
6
6
  overlayHidingPage?: string;
7
7
  }
8
- export interface SnapshotResult {
8
+ interface SnapshotResult {
9
9
  tree: string;
10
10
  status: SnapshotStatus;
11
11
  nodes: number;
@@ -54,6 +54,7 @@ function stateSuffix(el) {
54
54
  const states = getStates(el).filter((s) => s === ElementState.DISABLED ||
55
55
  s === ElementState.CHECKED ||
56
56
  s === ElementState.EXPANDED ||
57
+ s === ElementState.PRESSED ||
57
58
  s === ElementState.FOCUSED);
58
59
  return states.length > 0 ? ` [${states.join(',')}]` : '';
59
60
  }
@@ -142,9 +143,14 @@ function visit(child, depth, ctx, inLive) {
142
143
  }
143
144
  }
144
145
  function walk(parent, depth, ctx, inLive = false) {
145
- if (depth > ctx.maxDepth)
146
- return;
147
146
  const children = pierceChildren(parent);
147
+ if (depth > ctx.maxDepth) {
148
+ if (children.length > 0) {
149
+ ctx.truncated = true;
150
+ recordUnread([parent], ctx);
151
+ }
152
+ return;
153
+ }
148
154
  for (let index = 0; index < children.length; index += 1) {
149
155
  if (ctx.nodes >= ctx.maxNodes) {
150
156
  ctx.truncated = true;
@@ -2,6 +2,9 @@ import { EventType, TRANSPORT_LIMITS } from '@reticlehq/core';
2
2
  import { observeSafely } from './types.js';
3
3
  import { safeStringify } from '../security/serialization.js';
4
4
  import { requireCapturedMethod } from '../util/captured-method.js';
5
+ import { createRepeatLimiter } from './error-repeats.js';
6
+ const CSP_VIOLATION_EVENT = 'securitypolicyviolation';
7
+ const REPORT_ONLY = 'report';
5
8
  const METHOD_EVENT = {
6
9
  log: EventType.CONSOLE_LOG,
7
10
  warn: EventType.CONSOLE_WARN,
@@ -54,6 +57,16 @@ export function installConsole(emit) {
54
57
  patched.set(method, wrapper);
55
58
  console[method] = wrapper;
56
59
  }
60
+ const repeats = createRepeatLimiter();
61
+ const emitError = (payload) => {
62
+ const decision = repeats.admit(payload);
63
+ if (!decision.emit)
64
+ return;
65
+ emit(EventType.ERROR_UNCAUGHT, {
66
+ ...payload,
67
+ ...(undefined === decision.repeats ? {} : { repeats: decision.repeats }),
68
+ });
69
+ };
57
70
  const onError = (event) => {
58
71
  if (!(event instanceof ErrorEvent)) {
59
72
  const target = event.target;
@@ -62,43 +75,55 @@ export function installConsole(emit) {
62
75
  const tag = target.tagName.toLowerCase();
63
76
  const url = target.getAttribute('src') ?? target.getAttribute('href') ?? '';
64
77
  observeSafely(() => {
65
- emit(EventType.ERROR_UNCAUGHT, {
66
- message: `<${tag}> failed to load${0 === url.length ? '' : `: ${url}`}`,
67
- kind: 'resource',
68
- ...(0 === url.length ? {} : { source: url }),
69
- });
78
+ const message = `<${tag}> failed to load${0 === url.length ? '' : `: ${url}`}`;
79
+ emitError({ message, kind: 'resource', ...(0 === url.length ? {} : { source: url }) });
70
80
  });
71
81
  return;
72
82
  }
73
83
  observeSafely(() => {
74
84
  const stack = capStack(event.error instanceof Error ? event.error.stack : undefined);
75
- emit(EventType.ERROR_UNCAUGHT, {
85
+ const payload = {
76
86
  message: event.message,
77
87
  source: event.filename,
78
88
  line: event.lineno,
79
89
  ...(stack === undefined ? {} : { stack }),
80
- });
90
+ };
91
+ emitError(payload);
81
92
  });
82
93
  };
83
94
  const onRejection = (event) => {
84
95
  observeSafely(() => {
85
96
  const reason = event.reason;
86
97
  const stack = capStack(reason instanceof Error ? reason.stack : undefined);
87
- emit(EventType.ERROR_UNCAUGHT, {
98
+ const payload = {
88
99
  message: reason instanceof Error ? reason.message : String(reason),
89
100
  kind: 'unhandledrejection',
90
101
  ...(stack === undefined ? {} : { stack }),
91
- });
102
+ };
103
+ emitError(payload);
104
+ });
105
+ };
106
+ const onViolation = (event) => {
107
+ const what = 0 === event.blockedURI.length ? 'inline content' : event.blockedURI;
108
+ const reportOnly = REPORT_ONLY === event.disposition;
109
+ emit(reportOnly ? EventType.CONSOLE_WARN : EventType.CONSOLE_ERROR, {
110
+ message: reportOnly
111
+ ? `[Report Only] Content Security Policy directive: ${event.violatedDirective} would block ${what}`
112
+ : `Content Security Policy directive: ${event.violatedDirective} blocked ${what}`,
113
+ kind: CSP_VIOLATION_EVENT,
114
+ ...(0 === event.blockedURI.length ? {} : { source: event.blockedURI }),
92
115
  });
93
116
  };
94
- window.addEventListener('error', onError, true);
95
- window.addEventListener('unhandledrejection', onRejection);
117
+ const listeners = new AbortController();
118
+ const { signal } = listeners;
119
+ window.addEventListener('error', onError, { capture: true, signal });
120
+ window.addEventListener('unhandledrejection', onRejection, { signal });
121
+ window.addEventListener(CSP_VIOLATION_EVENT, onViolation, { capture: true, signal });
96
122
  return () => {
97
123
  for (const [method, original] of originals) {
98
124
  if (console[method] === patched.get(method))
99
125
  console[method] = original;
100
126
  }
101
- window.removeEventListener('error', onError, true);
102
- window.removeEventListener('unhandledrejection', onRejection);
127
+ listeners.abort();
103
128
  };
104
129
  }
@@ -153,8 +153,10 @@ export function installDom(emit) {
153
153
  dropped += 1;
154
154
  continue;
155
155
  }
156
- if (isReticleOverlay(node))
156
+ if (isReticleOverlay(node) ||
157
+ (isElement(record.target) && isReticleOverlay(record.target))) {
157
158
  continue;
159
+ }
158
160
  const gone = describeMount(node);
159
161
  if (gone === undefined)
160
162
  continue;