@reticlehq/browser 3.1.0 → 3.3.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 (71) hide show
  1. package/dist/actions/actions-dom.d.ts +2 -1
  2. package/dist/actions/actions-dom.js +28 -41
  3. package/dist/actions/actions.d.ts +0 -1
  4. package/dist/actions/actions.js +13 -7
  5. package/dist/actions/value-input.js +14 -5
  6. package/dist/connect-options.d.ts +1 -0
  7. package/dist/dom/addressing/source.d.ts +2 -1
  8. package/dist/dom/dom-ignore.d.ts +1 -0
  9. package/dist/dom/dom-ignore.js +13 -0
  10. package/dist/dom/query.js +28 -9
  11. package/dist/dom/snapshot.d.ts +1 -1
  12. package/dist/observers/console.js +38 -13
  13. package/dist/observers/departure.js +16 -8
  14. package/dist/observers/dom.js +3 -1
  15. package/dist/observers/download.js +3 -2
  16. package/dist/observers/error-repeats.d.ts +18 -0
  17. package/dist/observers/error-repeats.js +41 -0
  18. package/dist/observers/field.d.ts +2 -0
  19. package/dist/observers/field.js +86 -0
  20. package/dist/observers/frames.js +3 -4
  21. package/dist/observers/install-all.js +2 -0
  22. package/dist/observers/net-detail/network-body.d.ts +2 -0
  23. package/dist/observers/net-detail/network-body.js +24 -7
  24. package/dist/observers/network.js +49 -8
  25. package/dist/observers/route.js +4 -4
  26. package/dist/observers/state.js +18 -8
  27. package/dist/presenter/carousel/carousel.d.ts +13 -0
  28. package/dist/presenter/carousel/carousel.js +126 -0
  29. package/dist/presenter/carousel/offer-card.d.ts +21 -0
  30. package/dist/presenter/carousel/offer-card.js +55 -0
  31. package/dist/presenter/carousel/panel-slides.d.ts +7 -0
  32. package/dist/presenter/carousel/panel-slides.js +10 -0
  33. package/dist/presenter/carousel/talk-card.d.ts +8 -0
  34. package/dist/presenter/carousel/talk-card.js +20 -0
  35. package/dist/presenter/chrome/presenter-log.d.ts +2 -1
  36. package/dist/presenter/chrome/presenter-log.js +14 -3
  37. package/dist/presenter/chrome/presenter-report-copy.d.ts +0 -1
  38. package/dist/presenter/chrome/presenter-report-copy.js +1 -1
  39. package/dist/presenter/hud-telemetry.d.ts +3 -0
  40. package/dist/presenter/hud-telemetry.js +87 -0
  41. package/dist/presenter/presenter-account.js +1 -1
  42. package/dist/presenter/presenter-config.d.ts +5 -1
  43. package/dist/presenter/presenter-config.js +4 -1
  44. package/dist/presenter/presenter-controls.d.ts +1 -0
  45. package/dist/presenter/presenter-controls.js +12 -1
  46. package/dist/presenter/presenter-report-styles.js +4 -0
  47. package/dist/presenter/presenter-report.js +8 -7
  48. package/dist/presenter/presenter-settings-styles.js +2 -1
  49. package/dist/presenter/presenter-settings.d.ts +6 -4
  50. package/dist/presenter/presenter-settings.js +51 -5
  51. package/dist/presenter/presenter-shell-styles.js +16 -0
  52. package/dist/presenter/presenter-shell.d.ts +7 -0
  53. package/dist/presenter/presenter-shell.js +65 -5
  54. package/dist/presenter/presenter-styles.js +6 -0
  55. package/dist/presenter/presenter-workspace.d.ts +0 -7
  56. package/dist/presenter/presenter-workspace.js +7 -7
  57. package/dist/presenter/presenter.js +46 -10
  58. package/dist/presenter/tour/tour-view.js +7 -3
  59. package/dist/presenter/tour/tour.d.ts +3 -0
  60. package/dist/presenter/tour/tour.js +26 -2
  61. package/dist/recorder/recorder.js +25 -22
  62. package/dist/reticle-inject.d.ts +2 -0
  63. package/dist/reticle-inject.js +1337 -0
  64. package/dist/reticle.js +11 -1
  65. package/dist/review/annotator.d.ts +3 -0
  66. package/dist/review/annotator.js +37 -37
  67. package/dist/review/marks-for-agent.d.ts +9 -0
  68. package/dist/review/marks-for-agent.js +12 -0
  69. package/dist/util/captured-method.d.ts +0 -1
  70. package/dist/util/captured-method.js +1 -1
  71. package/package.json +8 -4
@@ -4,11 +4,12 @@ interface ClickGeometry {
4
4
  scrolledIntoView: boolean;
5
5
  }
6
6
  export declare const NO_GEOMETRY: ClickGeometry;
7
+ export declare function mouseEventFor(el: Element, type: string, init?: MouseEventInit): MouseEvent;
7
8
  export declare function fireClickSequence(el: HTMLElement, hold?: {
8
9
  ms: number;
9
10
  sleep: (ms: number) => Promise<void>;
10
11
  now: () => number;
11
- }): Promise<{
12
+ }, detail?: number): Promise<{
12
13
  prevented: boolean;
13
14
  heldMs: number;
14
15
  }>;
@@ -7,11 +7,21 @@ export const NO_GEOMETRY = {
7
7
  occludedBy: null,
8
8
  scrolledIntoView: false,
9
9
  };
10
- export async function fireClickSequence(el, hold) {
10
+ export function mouseEventFor(el, type, init = {}) {
11
+ return new MouseEvent(type, { ...init, view: el.ownerDocument.defaultView });
12
+ }
13
+ function pointerEventFor(el, type, init) {
14
+ const full = { ...init, view: el.ownerDocument.defaultView };
15
+ return 'function' === typeof PointerEvent
16
+ ? new PointerEvent(type, full)
17
+ : new MouseEvent(type, full);
18
+ }
19
+ export async function fireClickSequence(el, hold, detail) {
11
20
  const doc = el.ownerDocument;
12
21
  const from = doc.activeElement ?? doc.body;
22
+ const init = { bubbles: true, cancelable: true, detail: detail ?? 0 };
13
23
  firePointer(el, 'pointerdown', from);
14
- asSyntheticInput(() => el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })));
24
+ asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'mousedown', init)));
15
25
  if (el.tabIndex >= 0 && 'function' === typeof el.focus)
16
26
  el.focus();
17
27
  let heldMs = 0;
@@ -21,8 +31,8 @@ export async function fireClickSequence(el, hold) {
21
31
  heldMs = hold.now() - startedAt;
22
32
  }
23
33
  firePointer(el, 'pointerup', from);
24
- asSyntheticInput(() => el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true })));
25
- const notPrevented = asSyntheticInput(() => el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })));
34
+ asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'mouseup', init)));
35
+ const notPrevented = asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'click', init)));
26
36
  return { prevented: !notPrevented, heldMs };
27
37
  }
28
38
  function isMeasurable(rect) {
@@ -57,24 +67,10 @@ export function clickGeometry(el) {
57
67
  return { ...hitTest(el, rect), scrolledIntoView };
58
68
  }
59
69
  export function firePointer(el, type, relatedTarget = null) {
60
- asSyntheticInput(() => {
61
- if ('function' === typeof PointerEvent) {
62
- el.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, relatedTarget }));
63
- }
64
- else {
65
- el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, relatedTarget }));
66
- }
67
- });
70
+ asSyntheticInput(() => el.dispatchEvent(pointerEventFor(el, type, { bubbles: true, cancelable: true, relatedTarget })));
68
71
  }
69
72
  export function firePointerNonBubbling(el, type, relatedTarget = null) {
70
- asSyntheticInput(() => {
71
- if ('function' === typeof PointerEvent) {
72
- el.dispatchEvent(new PointerEvent(type, { bubbles: false, cancelable: true, relatedTarget }));
73
- }
74
- else {
75
- el.dispatchEvent(new MouseEvent(type, { bubbles: false, cancelable: true, relatedTarget }));
76
- }
77
- });
73
+ asSyntheticInput(() => el.dispatchEvent(pointerEventFor(el, type, { bubbles: false, cancelable: true, relatedTarget })));
78
74
  }
79
75
  function makeDataTransfer(data) {
80
76
  if (typeof DataTransfer !== 'function')
@@ -117,12 +113,9 @@ export async function dragElement(source, target, data) {
117
113
  button: 0,
118
114
  ...(related !== undefined ? { relatedTarget: related } : {}),
119
115
  };
120
- if ('function' === typeof PointerEvent && type.startsWith('pointer')) {
121
- el.dispatchEvent(new PointerEvent(type, { ...init, pointerId: 1, isPrimary: true }));
122
- }
123
- else {
124
- el.dispatchEvent(new MouseEvent(type, init));
125
- }
116
+ el.dispatchEvent(type.startsWith('pointer')
117
+ ? pointerEventFor(el, type, { ...init, pointerId: 1, isPrimary: true })
118
+ : mouseEventFor(el, type, init));
126
119
  };
127
120
  const sourceRect = source.getBoundingClientRect();
128
121
  const destRect = target !== null ? dest.getBoundingClientRect() : null;
@@ -190,24 +183,18 @@ export async function fireTapSequence(el, hold) {
190
183
  }
191
184
  asSyntheticInput(() => el.dispatchEvent(makeTouchEvent('touchend', [])));
192
185
  firePointerTouch(el, 'pointerup');
193
- const clicked = asSyntheticInput(() => el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })));
186
+ const clicked = asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'click', { bubbles: true, cancelable: true })));
194
187
  return { prevented: !clicked, heldMs };
195
188
  }
196
189
  function firePointerTouch(el, type) {
197
- asSyntheticInput(() => {
198
- if ('function' === typeof PointerEvent) {
199
- el.dispatchEvent(new PointerEvent(type, {
200
- bubbles: true,
201
- cancelable: true,
202
- pointerType: 'touch',
203
- isPrimary: true,
204
- width: 23,
205
- height: 23,
206
- }));
207
- return;
208
- }
209
- el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
210
- });
190
+ asSyntheticInput(() => el.dispatchEvent(pointerEventFor(el, type, {
191
+ bubbles: true,
192
+ cancelable: true,
193
+ pointerType: 'touch',
194
+ isPrimary: true,
195
+ width: 23,
196
+ height: 23,
197
+ })));
211
198
  }
212
199
  function touchListFor(el) {
213
200
  if ('function' !== typeof Touch || 'function' !== typeof el.getBoundingClientRect)
@@ -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>;
@@ -124,7 +124,7 @@ function assertActionAllowed(el, action, args) {
124
124
  throw new Error(`potentially destructive action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`);
125
125
  }
126
126
  }
127
- import { NO_GEOMETRY, fireClickSequence, fireTapSequence, clickGeometry, firePointer, firePointerNonBubbling, dragElement, } from './actions-dom.js';
127
+ import { NO_GEOMETRY, fireClickSequence, fireTapSequence, clickGeometry, firePointer, firePointerNonBubbling, dragElement, mouseEventFor, } from './actions-dom.js';
128
128
  import { scrollFrom } from './scroll.js';
129
129
  function enabledOf(el) {
130
130
  return !getStates(el).includes(ElementState.DISABLED);
@@ -141,7 +141,7 @@ function activeRef(el) {
141
141
  return null;
142
142
  return refs.refFor(active);
143
143
  }
144
- export const MAX_HOLD_MS = 30_000;
144
+ const MAX_HOLD_MS = 30_000;
145
145
  function clampHold(raw) {
146
146
  if ('number' !== typeof raw || !Number.isFinite(raw) || raw <= 0)
147
147
  return 0;
@@ -167,16 +167,18 @@ function asFiniteNumber(raw) {
167
167
  async function dispatchOther(el, action, args) {
168
168
  switch (action) {
169
169
  case ActionType.DBLCLICK:
170
- return !asSyntheticInput(() => el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })));
170
+ await fireClickSequence(el, undefined, 1);
171
+ await fireClickSequence(el, undefined, 2);
172
+ return !asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'dblclick', { bubbles: true, cancelable: true, detail: 2 })));
171
173
  case ActionType.HOVER: {
172
174
  const doc = el.ownerDocument;
173
175
  const from = doc.activeElement ?? doc.body;
174
176
  firePointer(el, 'pointerover', from);
175
- el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, relatedTarget: from }));
177
+ el.dispatchEvent(mouseEventFor(el, 'mouseover', { bubbles: true, relatedTarget: from }));
176
178
  firePointerNonBubbling(el, 'pointerenter', from);
177
- el.dispatchEvent(new MouseEvent('mouseenter', { relatedTarget: from }));
179
+ el.dispatchEvent(mouseEventFor(el, 'mouseenter', { relatedTarget: from }));
178
180
  firePointer(el, 'pointermove', from);
179
- const moved = el.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true }));
181
+ const moved = el.dispatchEvent(mouseEventFor(el, 'mousemove', { bubbles: true, cancelable: true }));
180
182
  const holdMs = clampHold(args['holdMs']);
181
183
  if (holdMs > 0)
182
184
  await sleep(holdMs);
@@ -252,7 +254,11 @@ async function dispatchOther(el, action, args) {
252
254
  }
253
255
  if (alreadyAtCheckedState(el, action))
254
256
  return false;
255
- const event = new MouseEvent('click', { bubbles: true, cancelable: true, composed: true });
257
+ const event = mouseEventFor(el, 'click', {
258
+ bubbles: true,
259
+ cancelable: true,
260
+ composed: true,
261
+ });
256
262
  const notPrevented = asSyntheticInput(() => el.dispatchEvent(event));
257
263
  return !notPrevented || event.defaultPrevented;
258
264
  }
@@ -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 (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;
@@ -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
@@ -1,5 +1,5 @@
1
1
  import { DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
2
- import { isFrame, isHtmlElement, isInput, isSelect, isTextArea } from './realm.js';
2
+ import { isElement, isFrame, isHtmlElement, 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';
5
5
  import { isIgnored, isReticleOverlay } from './dom-ignore.js';
@@ -27,20 +27,35 @@ function fuzzyVisibleText(actual, expected) {
27
27
  function exactVisibleText(actual, expected) {
28
28
  return normaliseVisibleText(actual) === normaliseVisibleText(expected);
29
29
  }
30
+ const NON_RENDERED_TAGS = ['script', 'style', 'template', 'noscript'];
31
+ function isNonRendered(el) {
32
+ return NON_RENDERED_TAGS.includes(el.tagName.toLowerCase());
33
+ }
30
34
  function directText(el) {
31
35
  if (isInput(el)) {
32
36
  const type = el.type.toLowerCase();
33
37
  if ('submit' === type || 'button' === type || 'reset' === type)
34
38
  return el.value;
35
39
  }
36
- const tag = el.tagName.toLowerCase();
37
- if ('script' === tag || 'style' === tag)
40
+ if (isNonRendered(el))
38
41
  return '';
39
42
  return Array.from(el.childNodes)
40
43
  .filter((node) => Node.TEXT_NODE === node.nodeType)
41
44
  .map((node) => node.textContent ?? '')
42
45
  .join('');
43
46
  }
47
+ function visibleTextOf(el, memo) {
48
+ if (isNonRendered(el) || isIgnored(el) || !isVisible(el, memo))
49
+ return '';
50
+ let text = '';
51
+ for (const node of Array.from(el.childNodes)) {
52
+ if (Node.TEXT_NODE === node.nodeType)
53
+ text += node.textContent ?? '';
54
+ else if (isElement(node))
55
+ text += visibleTextOf(node, memo);
56
+ }
57
+ return text;
58
+ }
44
59
  function semanticNameTarget(el) {
45
60
  if (isInput(el) || isTextArea(el) || isSelect(el))
46
61
  return true;
@@ -205,7 +220,8 @@ function findCandidates(query) {
205
220
  if (true === query.self) {
206
221
  if (query.scope === undefined)
207
222
  return { candidates: [], scopeMissing };
208
- if (query.text !== undefined && !fuzzyVisibleText(container.textContent ?? '', query.text)) {
223
+ if (query.text !== undefined &&
224
+ !fuzzyVisibleText(visibleTextOf(container, new Map()), query.text)) {
209
225
  return { candidates: [], scopeMissing };
210
226
  }
211
227
  return { candidates: isIgnored(container) ? [] : [container], scopeMissing };
@@ -342,10 +358,11 @@ function buildPresentRegions(query) {
342
358
  function splitTextOwner(container, wanted) {
343
359
  let best;
344
360
  let bestDepth = -1;
361
+ const memo = new Map();
345
362
  for (const el of elementsUnder(container)) {
346
363
  if (isIgnored(el))
347
364
  continue;
348
- if (!fuzzyVisibleText(el.textContent ?? '', wanted))
365
+ if (!fuzzyVisibleText(visibleTextOf(el, memo), wanted))
349
366
  continue;
350
367
  let depth = 0;
351
368
  for (let parent = el.parentElement; parent !== null; parent = parent.parentElement)
@@ -392,15 +409,16 @@ function buildEmptyHint(query) {
392
409
  const container = resolveContainer(query.scope).container ?? document.body;
393
410
  const all = container.querySelectorAll(`[${TESTID_ATTR}]`);
394
411
  const present = [];
412
+ const seen = new Set();
395
413
  for (const el of Array.from(all)) {
396
414
  if (isIgnored(el))
397
415
  continue;
398
416
  const id = el.getAttribute(TESTID_ATTR);
399
- 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)
400
421
  present.push(id);
401
- if (present.length >= MAX_PRESENT_TESTIDS)
402
- break;
403
- }
404
422
  }
405
423
  const registered = declaredTestids();
406
424
  const knownEmptyState = present.some((id) => registered.includes(id));
@@ -408,6 +426,7 @@ function buildEmptyHint(query) {
408
426
  const hint = {
409
427
  route,
410
428
  presentTestids: present,
429
+ ...(seen.size > present.length ? { presentTestidsTotal: seen.size } : {}),
411
430
  presentRegions: buildPresentRegions(query),
412
431
  knownEmptyState,
413
432
  };
@@ -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;
@@ -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
  }
@@ -35,15 +35,23 @@ export function installDeparture(emit) {
35
35
  return;
36
36
  const href = anchor.getAttribute('href') ?? '';
37
37
  const here = document.location.href;
38
- if (!isDeparture(href, anchor.hasAttribute('download'), here))
38
+ const download = anchor.hasAttribute('download');
39
+ if (!isDeparture(href, download, here))
39
40
  return;
40
- seq += 1;
41
- emit(EventType.NET_PENDING, {
42
- id: `nav-${String(seq)}`,
43
- method: 'GET',
44
- url: new URL(href, here).toString(),
45
- initiator: NetInitiator.NAVIGATION,
46
- ...(anchor.hasAttribute('download') ? { download: true } : {}),
41
+ const url = new URL(href, here).toString();
42
+ queueMicrotask(() => {
43
+ observeSafely(() => {
44
+ if (event.defaultPrevented)
45
+ return;
46
+ seq += 1;
47
+ emit(EventType.NET_PENDING, {
48
+ id: `nav-${String(seq)}`,
49
+ method: 'GET',
50
+ url,
51
+ initiator: NetInitiator.NAVIGATION,
52
+ ...(download ? { download: true } : {}),
53
+ });
54
+ });
47
55
  });
48
56
  });
49
57
  };
@@ -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;
@@ -7,6 +7,7 @@ const PREVIEW_CHARS = 2000;
7
7
  export function installDownload(emit, opts = {}) {
8
8
  if (typeof URL.createObjectURL !== 'function')
9
9
  return () => undefined;
10
+ const ac = new AbortController();
10
11
  const objectUrl = URL.createObjectURL.bind(URL);
11
12
  const known = new Map();
12
13
  const originalCreate = captureMethod(URL, 'createObjectURL');
@@ -58,7 +59,7 @@ export function installDownload(emit, opts = {}) {
58
59
  report(anchor.getAttribute('href') ?? '', anchor.getAttribute('download') ?? undefined);
59
60
  });
60
61
  };
61
- document.addEventListener('click', onClick, true);
62
+ document.addEventListener('click', onClick, { capture: true, signal: ac.signal });
62
63
  const originalClick = captureMethod(HTMLAnchorElement.prototype, 'click');
63
64
  const patchedClick = function reticleObservedAnchorClick() {
64
65
  const pending = observeValue(() => this.hasAttribute('download')
@@ -75,7 +76,7 @@ export function installDownload(emit, opts = {}) {
75
76
  };
76
77
  HTMLAnchorElement.prototype.click = patchedClick;
77
78
  return () => {
78
- document.removeEventListener('click', onClick, true);
79
+ ac.abort();
79
80
  if (URL.createObjectURL === patchedCreate)
80
81
  URL.createObjectURL = originalCreate;
81
82
  if (HTMLAnchorElement.prototype.click === patchedClick) {
@@ -0,0 +1,18 @@
1
+ export declare const REPEAT_BURST = 3;
2
+ export declare const MAX_TRACKED_FINGERPRINTS = 256;
3
+ export interface RepeatDecision {
4
+ readonly emit: boolean;
5
+ readonly repeats?: number;
6
+ }
7
+ export interface ErrorIdentity {
8
+ readonly message: string;
9
+ readonly source?: string;
10
+ readonly line?: number;
11
+ readonly kind?: string;
12
+ readonly stack?: string;
13
+ }
14
+ export declare function fingerprintError(identity: ErrorIdentity): string;
15
+ export interface RepeatLimiter {
16
+ admit(identity: ErrorIdentity): RepeatDecision;
17
+ }
18
+ export declare function createRepeatLimiter(burst?: number, maxTracked?: number): RepeatLimiter;
@@ -0,0 +1,41 @@
1
+ import { fnv1a } from '@reticlehq/core';
2
+ export const REPEAT_BURST = 3;
3
+ export const MAX_TRACKED_FINGERPRINTS = 256;
4
+ const PASS = { emit: true };
5
+ const DROP = { emit: false };
6
+ function isCheckpoint(count) {
7
+ if (count < 10)
8
+ return false;
9
+ let n = count;
10
+ while (0 === n % 10)
11
+ n /= 10;
12
+ return 1 === n;
13
+ }
14
+ export function fingerprintError(identity) {
15
+ return fnv1a([
16
+ identity.message,
17
+ identity.source ?? '',
18
+ undefined === identity.line ? '' : String(identity.line),
19
+ identity.kind ?? '',
20
+ identity.stack ?? '',
21
+ ].join('\x00'));
22
+ }
23
+ export function createRepeatLimiter(burst = REPEAT_BURST, maxTracked = MAX_TRACKED_FINGERPRINTS) {
24
+ const counts = new Map();
25
+ return {
26
+ admit(identity) {
27
+ const key = fingerprintError(identity);
28
+ const seen = (counts.get(key) ?? 0) + 1;
29
+ counts.delete(key);
30
+ if (counts.size >= maxTracked) {
31
+ const oldest = counts.keys().next();
32
+ if (!(oldest.done ?? false))
33
+ counts.delete(oldest.value);
34
+ }
35
+ counts.set(key, seen);
36
+ if (seen <= burst)
37
+ return PASS;
38
+ return isCheckpoint(seen) ? { emit: true, repeats: seen } : DROP;
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,2 @@
1
+ import type { Emit, Teardown } from './types.js';
2
+ export declare function installField(emit: Emit): Teardown;