@reticlehq/browser 2.13.0 → 2.14.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 (196) hide show
  1. package/dist/actions/actions-dom.d.ts +0 -20
  2. package/dist/actions/actions-dom.js +0 -66
  3. package/dist/actions/actions-press.d.ts +10 -0
  4. package/dist/actions/actions-press.js +77 -0
  5. package/dist/actions/actions.d.ts +0 -89
  6. package/dist/actions/actions.js +24 -358
  7. package/dist/actions/appeared-text.d.ts +0 -20
  8. package/dist/actions/appeared-text.js +0 -36
  9. package/dist/actions/danger-context.d.ts +3 -0
  10. package/dist/actions/danger-context.js +39 -0
  11. package/dist/actions/scroll.d.ts +0 -9
  12. package/dist/actions/scroll.js +0 -7
  13. package/dist/actions/synthetic-input.d.ts +0 -7
  14. package/dist/actions/synthetic-input.js +0 -27
  15. package/dist/actions/value-input.d.ts +0 -30
  16. package/dist/actions/value-input.js +0 -33
  17. package/dist/commands/commands.d.ts +0 -2
  18. package/dist/commands/commands.js +0 -67
  19. package/dist/connect-options.d.ts +2 -96
  20. package/dist/dom/a11y.d.ts +0 -29
  21. package/dist/dom/a11y.js +1 -88
  22. package/dist/dom/auto-anchor.d.ts +0 -40
  23. package/dist/dom/auto-anchor.js +0 -33
  24. package/dist/dom/chart.d.ts +0 -61
  25. package/dist/dom/chart.js +0 -60
  26. package/dist/dom/desktop-capture.d.ts +0 -30
  27. package/dist/dom/desktop-capture.js +0 -72
  28. package/dist/dom/dom-ignore.d.ts +0 -30
  29. package/dist/dom/dom-ignore.js +0 -47
  30. package/dist/dom/occlusion.d.ts +0 -7
  31. package/dist/dom/occlusion.js +0 -7
  32. package/dist/dom/query.d.ts +0 -14
  33. package/dist/dom/query.js +48 -214
  34. package/dist/dom/realm.d.ts +0 -20
  35. package/dist/dom/realm.js +0 -28
  36. package/dist/dom/refs.d.ts +0 -76
  37. package/dist/dom/refs.js +2 -133
  38. package/dist/dom/shadow-registry.d.ts +0 -33
  39. package/dist/dom/shadow-registry.js +2 -72
  40. package/dist/dom/snapshot.d.ts +2 -43
  41. package/dist/dom/snapshot.js +26 -151
  42. package/dist/dom/source.d.ts +0 -44
  43. package/dist/dom/source.js +0 -45
  44. package/dist/dom/theme.d.ts +0 -37
  45. package/dist/dom/theme.js +2 -52
  46. package/dist/dom/virtualized.d.ts +0 -17
  47. package/dist/dom/virtualized.js +0 -27
  48. package/dist/edit-epoch.d.ts +0 -22
  49. package/dist/edit-epoch.js +0 -52
  50. package/dist/index.js +1 -23
  51. package/dist/observers/animation.d.ts +0 -1
  52. package/dist/observers/animation.js +0 -3
  53. package/dist/observers/blind-spots.d.ts +0 -26
  54. package/dist/observers/blind-spots.js +2 -61
  55. package/dist/observers/console.d.ts +1 -2
  56. package/dist/observers/console.js +41 -30
  57. package/dist/observers/context-open.d.ts +1 -11
  58. package/dist/observers/context-open.js +10 -14
  59. package/dist/observers/departure.d.ts +3 -0
  60. package/dist/observers/departure.js +52 -0
  61. package/dist/observers/dialogs.d.ts +2 -0
  62. package/dist/observers/dialogs.js +39 -0
  63. package/dist/observers/dom.js +0 -44
  64. package/dist/observers/download.d.ts +1 -2
  65. package/dist/observers/download.js +38 -55
  66. package/dist/observers/focus.d.ts +0 -6
  67. package/dist/observers/focus.js +0 -7
  68. package/dist/observers/frames.d.ts +0 -9
  69. package/dist/observers/frames.js +1 -28
  70. package/dist/observers/health.d.ts +0 -5
  71. package/dist/observers/health.js +1 -33
  72. package/dist/observers/install-all.d.ts +2 -0
  73. package/dist/observers/install-all.js +26 -27
  74. package/dist/observers/ipc.d.ts +0 -25
  75. package/dist/observers/ipc.js +0 -65
  76. package/dist/observers/navigation.d.ts +2 -0
  77. package/dist/observers/navigation.js +33 -0
  78. package/dist/observers/network-body.d.ts +5 -28
  79. package/dist/observers/network-body.js +11 -108
  80. package/dist/observers/network-redact.d.ts +1 -20
  81. package/dist/observers/network-redact.js +1 -80
  82. package/dist/observers/network-stream.d.ts +0 -21
  83. package/dist/observers/network-stream.js +1 -44
  84. package/dist/observers/network.d.ts +2 -26
  85. package/dist/observers/network.js +177 -294
  86. package/dist/observers/perf.d.ts +0 -10
  87. package/dist/observers/perf.js +0 -16
  88. package/dist/observers/route.d.ts +1 -2
  89. package/dist/observers/route.js +13 -19
  90. package/dist/observers/scroll.d.ts +0 -1
  91. package/dist/observers/scroll.js +0 -9
  92. package/dist/observers/sdk-failure.d.ts +1 -9
  93. package/dist/observers/sdk-failure.js +1 -30
  94. package/dist/observers/state.d.ts +1 -11
  95. package/dist/observers/state.js +13 -41
  96. package/dist/observers/storage.d.ts +0 -14
  97. package/dist/observers/storage.js +10 -34
  98. package/dist/observers/types.d.ts +0 -15
  99. package/dist/observers/types.js +0 -14
  100. package/dist/patching/capture-method.d.ts +0 -24
  101. package/dist/patching/capture-method.js +0 -26
  102. package/dist/presenter/overlay.d.ts +0 -1
  103. package/dist/presenter/overlay.js +0 -1
  104. package/dist/presenter/presenter-brand.d.ts +0 -19
  105. package/dist/presenter/presenter-brand.js +0 -21
  106. package/dist/presenter/presenter-config.d.ts +1 -105
  107. package/dist/presenter/presenter-config.js +2 -91
  108. package/dist/presenter/presenter-controls.d.ts +0 -29
  109. package/dist/presenter/presenter-controls.js +3 -131
  110. package/dist/presenter/presenter-dock-layout.d.ts +0 -3
  111. package/dist/presenter/presenter-dock-layout.js +0 -4
  112. package/dist/presenter/presenter-drag.d.ts +2 -24
  113. package/dist/presenter/presenter-drag.js +0 -26
  114. package/dist/presenter/presenter-effects.d.ts +0 -9
  115. package/dist/presenter/presenter-effects.js +0 -9
  116. package/dist/presenter/presenter-glow.d.ts +0 -21
  117. package/dist/presenter/presenter-glow.js +1 -27
  118. package/dist/presenter/presenter-heroicons-data.d.ts +0 -8
  119. package/dist/presenter/presenter-heroicons-data.js +0 -8
  120. package/dist/presenter/presenter-hud-chrome.d.ts +0 -10
  121. package/dist/presenter/presenter-hud-chrome.js +0 -10
  122. package/dist/presenter/presenter-icons.d.ts +0 -6
  123. package/dist/presenter/presenter-icons.js +0 -6
  124. package/dist/presenter/presenter-log.d.ts +0 -38
  125. package/dist/presenter/presenter-log.js +1 -45
  126. package/dist/presenter/presenter-report-copy.d.ts +5 -66
  127. package/dist/presenter/presenter-report-copy.js +5 -66
  128. package/dist/presenter/presenter-report-styles.d.ts +0 -6
  129. package/dist/presenter/presenter-report-styles.js +6 -6
  130. package/dist/presenter/presenter-report.d.ts +4 -7
  131. package/dist/presenter/presenter-report.js +18 -76
  132. package/dist/presenter/presenter-run-state.d.ts +0 -7
  133. package/dist/presenter/presenter-run-state.js +0 -5
  134. package/dist/presenter/presenter-settings-styles.d.ts +0 -1
  135. package/dist/presenter/presenter-settings-styles.js +0 -1
  136. package/dist/presenter/presenter-settings.d.ts +1 -23
  137. package/dist/presenter/presenter-settings.js +1 -31
  138. package/dist/presenter/presenter-shell-styles.js +2 -5
  139. package/dist/presenter/presenter-shell.d.ts +2 -18
  140. package/dist/presenter/presenter-shell.js +1 -73
  141. package/dist/presenter/presenter-styles.d.ts +0 -9
  142. package/dist/presenter/presenter-styles.js +0 -9
  143. package/dist/presenter/presenter-tally.d.ts +0 -5
  144. package/dist/presenter/presenter-tally.js +0 -4
  145. package/dist/presenter/presenter-test-helpers.d.ts +0 -2
  146. package/dist/presenter/presenter-test-helpers.js +0 -2
  147. package/dist/presenter/presenter-verbs.js +0 -7
  148. package/dist/presenter/presenter-workspace.d.ts +0 -4
  149. package/dist/presenter/presenter-workspace.js +1 -8
  150. package/dist/presenter/presenter.d.ts +0 -60
  151. package/dist/presenter/presenter.js +4 -118
  152. package/dist/recorder/recorder-styles.d.ts +0 -6
  153. package/dist/recorder/recorder-styles.js +0 -6
  154. package/dist/recorder/recorder.d.ts +0 -34
  155. package/dist/recorder/recorder.js +0 -49
  156. package/dist/registry/adapters.d.ts +0 -10
  157. package/dist/registry/adapters.js +0 -10
  158. package/dist/registry/auto-testids.d.ts +0 -19
  159. package/dist/registry/auto-testids.js +1 -21
  160. package/dist/registry/capabilities.d.ts +0 -31
  161. package/dist/registry/capabilities.js +1 -46
  162. package/dist/registry/commit-and-signal.d.ts +0 -6
  163. package/dist/registry/commit-and-signal.js +1 -7
  164. package/dist/registry/domains.d.ts +0 -8
  165. package/dist/registry/domains.js +0 -7
  166. package/dist/registry/emitter.d.ts +0 -14
  167. package/dist/registry/emitter.js +0 -10
  168. package/dist/registry/store-adapters.d.ts +0 -186
  169. package/dist/registry/store-adapters.js +0 -163
  170. package/dist/registry/stores.d.ts +0 -33
  171. package/dist/registry/stores.js +0 -59
  172. package/dist/reticle-presenter-helpers.d.ts +0 -13
  173. package/dist/reticle-presenter-helpers.js +0 -14
  174. package/dist/reticle.d.ts +1 -52
  175. package/dist/reticle.js +20 -145
  176. package/dist/review/annotator-styles.js +0 -7
  177. package/dist/review/annotator.d.ts +2 -13
  178. package/dist/review/annotator.js +0 -40
  179. package/dist/review/mark-anchor.d.ts +0 -11
  180. package/dist/review/mark-anchor.js +0 -8
  181. package/dist/security/serialization.d.ts +0 -20
  182. package/dist/security/serialization.js +1 -66
  183. package/dist/session-continuity.d.ts +0 -16
  184. package/dist/session-continuity.js +1 -20
  185. package/dist/timers/clock.d.ts +1 -14
  186. package/dist/timers/clock.js +59 -60
  187. package/dist/timers/native-console.js +0 -4
  188. package/dist/timers/native-timers.d.ts +0 -25
  189. package/dist/timers/native-timers.js +3 -33
  190. package/dist/transport/transport.d.ts +0 -51
  191. package/dist/transport/transport.js +3 -129
  192. package/dist/transport/unreachable-message.d.ts +0 -17
  193. package/dist/transport/unreachable-message.js +0 -17
  194. package/dist/util/captured-method.d.ts +0 -35
  195. package/dist/util/captured-method.js +0 -35
  196. package/package.json +2 -2
@@ -0,0 +1,39 @@
1
+ import { EventType } from '@reticlehq/core';
2
+ import { observeSafely } from './types.js';
3
+ import { isSyntheticInput } from '../actions/synthetic-input.js';
4
+ const DECLINED = { alert: undefined, confirm: false, prompt: null };
5
+ const KINDS = ['alert', 'confirm', 'prompt'];
6
+ export function installDialogs(emit) {
7
+ const slots = window;
8
+ const originals = new Map();
9
+ const patched = new Map();
10
+ for (const kind of KINDS) {
11
+ const original = slots[kind];
12
+ if ('function' !== typeof original)
13
+ continue;
14
+ originals.set(kind, original);
15
+ const callOriginal = original.bind(window);
16
+ const wrapper = (...args) => {
17
+ if (!isSyntheticInput())
18
+ return callOriginal(...args);
19
+ const answered = DECLINED[kind];
20
+ const message = args[0];
21
+ observeSafely(() => {
22
+ emit(EventType.DIALOG_OPENED, {
23
+ kind,
24
+ ...('string' === typeof message ? { message } : {}),
25
+ ...(answered === undefined ? {} : { answered }),
26
+ });
27
+ });
28
+ return answered;
29
+ };
30
+ patched.set(kind, wrapper);
31
+ slots[kind] = wrapper;
32
+ }
33
+ return () => {
34
+ for (const [kind, original] of originals) {
35
+ if (slots[kind] === patched.get(kind))
36
+ slots[kind] = original;
37
+ }
38
+ };
39
+ }
@@ -1,12 +1,6 @@
1
1
  import { EventType, TruncationChannel } from '@reticlehq/core';
2
2
  import { getAccessibleName, getRole, isVisible } from '../dom/a11y.js';
3
3
  import { refs } from '../dom/refs.js';
4
- /**
5
- * A STABLE identity for the region a mutation happened in. Ambient learning cannot key on the element
6
- * ref for a churning list: every appended row is a NEW element (fresh ref) and a removed one has no ref
7
- * at all, so per-ref counts never accumulate and the region is never recognized as ambient. The
8
- * container persists across ticks, so its testid (or ref) is the identity that does accumulate.
9
- */
10
4
  function regionKeyOf(target) {
11
5
  const el = isElement(target) ? target : null;
12
6
  if (null === el)
@@ -28,13 +22,11 @@ const WATCHED_ATTRS = [
28
22
  'aria-selected',
29
23
  'aria-checked',
30
24
  'data-state',
31
- // Widened: visual + resource + form-value attributes. Values are capped (they can be long).
32
25
  'style',
33
26
  'src',
34
27
  'href',
35
28
  'value',
36
29
  ];
37
- /** Attribute/text values are capped per event — style/src can be huge and would bloat the ledger. */
38
30
  const MAX_ATTR_VALUE_LEN = 120;
39
31
  function capValue(value) {
40
32
  if (null === value)
@@ -43,24 +35,14 @@ function capValue(value) {
43
35
  }
44
36
  const DIALOG_ROLES = new Set(['dialog', 'alertdialog']);
45
37
  const LIVE_ROLES = new Set(['alert', 'status']);
46
- /** Max meaningful added/removed nodes reported per mutation batch (backpressure). */
47
38
  const MAX_PER_BATCH = 40;
48
39
  function isMeaningful(role, name) {
49
40
  return role !== 'generic' || name.length > 0;
50
41
  }
51
- /** Observe DOM mutations and emit semantic (not raw) events. */
52
- /**
53
- * The trimmed text carried by a node list, or undefined when it carries none.
54
- *
55
- * Whitespace-only nodes are ignored deliberately: a re-render that reshuffles indentation would
56
- * otherwise emit a text change on every layout node, which is noise the timeline cannot afford.
57
- */
58
42
  const TEXT_NODE = 3;
59
43
  function textOf(nodes) {
60
44
  let out = '';
61
45
  for (const node of nodes) {
62
- // The numeric nodeType, not `Node.TEXT_NODE`: the global `Node` constructor is not guaranteed to
63
- // exist wherever this runs, and referencing it threw a ReferenceError rather than degrading.
64
46
  if (node.nodeType !== TEXT_NODE)
65
47
  continue;
66
48
  out += node.nodeValue ?? '';
@@ -69,17 +51,11 @@ function textOf(nodes) {
69
51
  return trimmed.length > 0 ? trimmed : undefined;
70
52
  }
71
53
  export function installDom(emit) {
72
- // Installed here rather than in the bootstrap list because this observer is the registry's reason
73
- // for existing, and the ordering matters: a root attached between the two would be missed by both.
74
- // It is also the only chance to capture a CLOSED root, which attachShadow returns exactly once.
75
54
  const stopRegistry = installShadowRegistry();
76
55
  const observer = new MutationObserver((records) => {
77
56
  let added = 0;
78
57
  let removed = 0;
79
58
  let changed = 0;
80
- // Count element nodes dropped purely because a per-batch cap was already reached. The cap is on
81
- // MEANINGFUL events, so it is only hit after a real flood — the count is a raw over-estimate
82
- // (it can include not-yet-inspected noise) but `dropped > 0` honestly means "this batch was capped".
83
59
  let dropped = 0;
84
60
  for (const record of records) {
85
61
  if ('attributes' === record.type) {
@@ -90,7 +66,6 @@ export function installDom(emit) {
90
66
  continue;
91
67
  }
92
68
  changed += 1;
93
- // Old value (attributeOldValue) + capped new value — a diff, not just a reading.
94
69
  const value = capValue(target.getAttribute(record.attributeName));
95
70
  const old = capValue(record.oldValue);
96
71
  emit(EventType.DOM_ATTR, {
@@ -102,8 +77,6 @@ export function installDom(emit) {
102
77
  continue;
103
78
  }
104
79
  if ('characterData' === record.type) {
105
- // In-place text change inside an existing subtree (wizard steps, inline edits) —
106
- // childList-only would miss this.
107
80
  const parent = record.target.parentElement;
108
81
  if (parent !== null && !isReticleOverlay(parent)) {
109
82
  if (changed >= MAX_PER_BATCH) {
@@ -117,14 +90,6 @@ export function installDom(emit) {
117
90
  }
118
91
  continue;
119
92
  }
120
- // A TEXT node arriving or leaving is a text change, and it is the ordinary one. `characterData`
121
- // fires only when an EXISTING text node's `data` is edited in place; `el.textContent = x`,
122
- // `innerText`, and React replacing a child all REPLACE the node instead — a childList mutation
123
- // carrying a Text node, which the Element-only loops below skip. Measured on a live page:
124
- // `firstChild.data = x` emitted `dom.text`, while `textContent`, `innerText` and
125
- // `appendChild(createTextNode())` emitted NOTHING. So the most common visible change an app
126
- // makes — updating text — produced no event at all, `reticle_observe` reported "nothing
127
- // happened" over a screen that had visibly changed, and `settled` went quiet immediately.
128
93
  if (textOf(record.addedNodes) !== undefined || textOf(record.removedNodes) !== undefined) {
129
94
  const parent = isElement(record.target) ? record.target : null;
130
95
  if (parent !== null && !isReticleOverlay(parent)) {
@@ -132,8 +97,6 @@ export function installDom(emit) {
132
97
  dropped += 1;
133
98
  else {
134
99
  changed += 1;
135
- // The element's text AFTER the mutation, so a cleared node reports '' rather than the
136
- // text that just left — the reader wants what is on screen now.
137
100
  const text = (parent.textContent ?? '').trim().slice(0, 80);
138
101
  const old = capValue(textOf(record.removedNodes) ?? null);
139
102
  emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }) }, refs.refFor(parent));
@@ -177,12 +140,9 @@ export function installDom(emit) {
177
140
  if (!isMeaningful(role, name))
178
141
  continue;
179
142
  removed += 1;
180
- // A removed node has no ref (it is gone), so the CONTAINER is the only stable identity — and it
181
- // is what ambient learning needs to recognize a churning region.
182
143
  emit(EventType.DOM_REMOVED, { role, name, region: regionKeyOf(record.target) });
183
144
  }
184
145
  }
185
- // Never silent: a capped batch tells the ledger its DOM counts understate reality.
186
146
  if (dropped > 0)
187
147
  emit(EventType.TRUNCATED, { channel: TruncationChannel.DOM, dropped });
188
148
  });
@@ -196,13 +156,9 @@ export function installDom(emit) {
196
156
  characterDataOldValue: true,
197
157
  };
198
158
  observer.observe(document.documentElement, options);
199
- // A MutationObserver does NOT cross a shadow boundary, so every root needs its own observation.
200
- // Without this, a click that changed text inside a web component produced an empty observe window —
201
- // the same shape as an app that ignored the click entirely.
202
159
  for (const root of capturedRoots())
203
160
  observer.observe(root, options);
204
161
  const unsubscribe = onShadowRoot((root) => observer.observe(root, options));
205
- // Same-origin frame documents are separate node trees for the same reason, with the same symptom.
206
162
  const stopFrames = observeSameOriginFrames((body) => observer.observe(body, options));
207
163
  return () => {
208
164
  stopFrames();
@@ -1,6 +1,5 @@
1
- import type { Emit, Teardown } from './types.js';
1
+ import { type Emit, type Teardown } from './types.js';
2
2
  interface DownloadOptions {
3
- /** Include a content preview. Gated for the same reason request bodies are: exports hold PII. */
4
3
  capturePreview?: boolean;
5
4
  }
6
5
  export declare function installDownload(emit: Emit, opts?: DownloadOptions): Teardown;
@@ -1,58 +1,37 @@
1
1
  import { EventType } from '@reticlehq/core';
2
2
  import { captureMethod } from '../patching/capture-method.js';
3
3
  import { projectBody } from './network-body.js';
4
- /**
5
- * Files the app PRODUCES — the one artifact class an outside-the-browser tool cannot inspect.
6
- *
7
- * A generated export never crosses the network: the app builds a Blob, hands it to
8
- * `URL.createObjectURL`, and clicks an anchor. There is no request to intercept, and Playwright MCP
9
- * has no tool that returns a downloaded file at all. Reticle runs inside the page, where the Blob is
10
- * constructed, so the export is observable before it reaches the disk.
11
- *
12
- * Measured on a real merchant dashboard: one Export click produced a CSV wrong three ways at once —
13
- * 25 data rows in a file named `transactions-128-rows.csv`, an unquoted `3 Aug, 10:00 pm` that
14
- * shifts every column after it, and an `amount_inr` header over rows denominated in USD — while the
15
- * click itself reported a clean success and every other channel agreed. The benchmark's ground truth
16
- * records those three as missed by BOTH toolchains, for the same reason: nobody opened the file.
17
- */
18
- /** Text-like types worth counting lines in and previewing. Anything else reports type + size only. */
4
+ import { observeSafely, observeValue } from './types.js';
19
5
  const TEXTUAL = /text\/|json|csv|xml|ndjson|javascript/i;
20
- /** Lines are reported for text files; the preview is capped and redacted by projectBody. */
21
6
  const PREVIEW_CHARS = 2000;
22
7
  export function installDownload(emit, opts = {}) {
23
- // jsdom does not implement `createObjectURL`, and plenty of users run this SDK under jsdom in their
24
- // own test suite. Reaching for `.bind` on an absent function threw during `connect()` — an
25
- // observability layer taking the host app down with it, which is the one failure mode it may never
26
- // have. No object URLs means no generated files to observe, so this simply stands down.
27
8
  if (typeof URL.createObjectURL !== 'function')
28
9
  return () => undefined;
29
10
  const objectUrl = URL.createObjectURL.bind(URL);
30
11
  const known = new Map();
31
- /**
32
- * `createObjectURL` is where the file becomes real. The filename is NOT here — it lives on the
33
- * anchor that gets clicked — so the blob is remembered against its URL and reported when either
34
- * the anchor fires or, failing that, on a microtask so a download that is never clicked (a
35
- * generated preview, an <img> src) is still visible.
36
- */
37
12
  const originalCreate = captureMethod(URL, 'createObjectURL');
38
- URL.createObjectURL = function reticleObservedObjectUrl(obj) {
13
+ const patchedCreate = function reticleObservedObjectUrl(obj) {
39
14
  const url = objectUrl(obj);
40
- if (typeof Blob !== 'undefined' && obj instanceof Blob) {
41
- // Started here, awaited at report time — never blocking the app's own download path.
42
- const text = TEXTUAL.test(obj.type)
43
- ? obj.text().catch(() => undefined)
44
- : Promise.resolve(undefined);
45
- known.set(url, { type: obj.type, size: obj.size, text });
46
- }
15
+ observeSafely(() => {
16
+ if (typeof Blob !== 'undefined' && obj instanceof Blob) {
17
+ const text = TEXTUAL.test(obj.type)
18
+ ? obj.text().catch(() => undefined)
19
+ : Promise.resolve(undefined);
20
+ known.set(url, { type: obj.type, size: obj.size, text });
21
+ }
22
+ });
47
23
  return url;
48
24
  };
25
+ URL.createObjectURL = patchedCreate;
49
26
  const report = (url, filename) => {
50
27
  const record = known.get(url);
51
28
  if (record === undefined)
52
29
  return;
53
30
  known.delete(url);
54
31
  void record.text.then((text) => {
55
- emitDownload(record, filename, text);
32
+ observeSafely(() => {
33
+ emitDownload(record, filename, text);
34
+ });
56
35
  });
57
36
  };
58
37
  const emitDownload = (record, filename, text) => {
@@ -60,8 +39,6 @@ export function installDownload(emit, opts = {}) {
60
39
  if (filename !== undefined && filename.length > 0)
61
40
  data['filename'] = filename;
62
41
  if (text !== undefined) {
63
- // The LINE COUNT is reported even without preview capture: it is a number, not content, and it
64
- // is the whole tell for "25 rows in a file the toast calls 128".
65
42
  data['lines'] = text.split('\n').filter((l) => l.length > 0).length;
66
43
  if (true === opts.capturePreview) {
67
44
  const { body, truncated } = projectBody(text.slice(0, PREVIEW_CHARS), record.type);
@@ -72,32 +49,38 @@ export function installDownload(emit, opts = {}) {
72
49
  }
73
50
  emit(EventType.DOWNLOAD, data);
74
51
  };
75
- /**
76
- * An anchor click carries the NAME. Captured at the document level so it sees the click whether the
77
- * app dispatches it programmatically or a human does, and whether or not the anchor is in the DOM.
78
- */
79
52
  const onClick = (event) => {
80
- const target = event.target;
81
- const anchor = target instanceof Element ? target.closest('a[download]') : null;
82
- if (null === anchor)
83
- return;
84
- report(anchor.getAttribute('href') ?? '', anchor.getAttribute('download') ?? undefined);
53
+ observeSafely(() => {
54
+ const target = event.target;
55
+ const anchor = target instanceof Element ? target.closest('a[download]') : null;
56
+ if (null === anchor)
57
+ return;
58
+ report(anchor.getAttribute('href') ?? '', anchor.getAttribute('download') ?? undefined);
59
+ });
85
60
  };
86
61
  document.addEventListener('click', onClick, true);
87
- // A programmatic `a.click()` on an anchor never inserted into the document dispatches no bubbling
88
- // event this listener can see, so the anchor path is patched too.
89
62
  const originalClick = captureMethod(HTMLAnchorElement.prototype, 'click');
90
- HTMLAnchorElement.prototype.click = function reticleObservedAnchorClick() {
91
- if (this.hasAttribute('download')) {
92
- report(this.getAttribute('href') ?? '', this.getAttribute('download') ?? undefined);
93
- }
63
+ const patchedClick = function reticleObservedAnchorClick() {
64
+ const pending = observeValue(() => this.hasAttribute('download')
65
+ ? {
66
+ href: this.getAttribute('href') ?? '',
67
+ name: this.getAttribute('download') ?? undefined,
68
+ }
69
+ : undefined);
94
70
  originalClick.call(this);
71
+ observeSafely(() => {
72
+ if (pending !== undefined)
73
+ report(pending.href, pending.name);
74
+ });
95
75
  };
76
+ HTMLAnchorElement.prototype.click = patchedClick;
96
77
  return () => {
97
- // A REAL restore, so teardown leaves the page exactly as it was found.
98
78
  document.removeEventListener('click', onClick, true);
99
- URL.createObjectURL = originalCreate;
100
- HTMLAnchorElement.prototype.click = originalClick;
79
+ if (URL.createObjectURL === patchedCreate)
80
+ URL.createObjectURL = originalCreate;
81
+ if (HTMLAnchorElement.prototype.click === patchedClick) {
82
+ HTMLAnchorElement.prototype.click = originalClick;
83
+ }
101
84
  known.clear();
102
85
  };
103
86
  }
@@ -1,9 +1,3 @@
1
1
  import type { Emit, Teardown } from './types.js';
2
- /** A human-legible label for a focused element, or undefined for body/none. Pure; exported for testing. */
3
2
  export declare function focusLabel(el: EventTarget | null): string | undefined;
4
- /**
5
- * Track element focus movement. Focus dropping to <body> after an act — a modal that closed and stranded
6
- * focus, a button that vanished — is a real accessibility/UX regression no screenshot names. Emits
7
- * FOCUS_CHANGE {to, from, toBody}. Reversible.
8
- */
9
3
  export declare function installFocus(emit: Emit): Teardown;
@@ -1,6 +1,5 @@
1
1
  import { EventType } from '@reticlehq/core';
2
2
  import { getAccessibleName, getRole } from '../dom/a11y.js';
3
- /** A human-legible label for a focused element, or undefined for body/none. Pure; exported for testing. */
4
3
  export function focusLabel(el) {
5
4
  if (!(el instanceof Element) || el === document.body)
6
5
  return undefined;
@@ -11,11 +10,6 @@ export function focusLabel(el) {
11
10
  function toBody(el) {
12
11
  return null === el || el === document.body;
13
12
  }
14
- /**
15
- * Track element focus movement. Focus dropping to <body> after an act — a modal that closed and stranded
16
- * focus, a button that vanished — is a real accessibility/UX regression no screenshot names. Emits
17
- * FOCUS_CHANGE {to, from, toBody}. Reversible.
18
- */
19
13
  export function installFocus(emit) {
20
14
  const ac = new AbortController();
21
15
  const { signal } = ac;
@@ -31,7 +25,6 @@ export function installFocus(emit) {
31
25
  });
32
26
  };
33
27
  const onFocusOut = (event) => {
34
- // Focus left with nothing gaining it → it dropped to the body (the regression case).
35
28
  if (event.relatedTarget !== null)
36
29
  return;
37
30
  const from = last;
@@ -1,12 +1,3 @@
1
1
  import type { Teardown } from './types.js';
2
- /** Every same-origin frame body at or beneath `root`, in document order. */
3
2
  export declare function sameOriginFrameBodies(root: ParentNode): HTMLElement[];
4
- /**
5
- * Call `attach` for every same-origin frame body now and after each frame load.
6
- *
7
- * A frame that navigates gets a BRAND NEW document, so the old observation is dead and re-attaching on
8
- * `load` is not optional — without it, observation silently stops the first time an embedded panel
9
- * routes. Attaching twice to the same node is harmless (MutationObserver de-duplicates), so no
10
- * bookkeeping is kept.
11
- */
12
3
  export declare function observeSameOriginFrames(attach: (body: HTMLElement) => void): Teardown;
@@ -1,30 +1,12 @@
1
- /**
2
- * Same-origin `<iframe>` documents, wired into the top document's observation.
3
- *
4
- * A `MutationObserver` is scoped to the node it is given, and a frame's document is a DIFFERENT node
5
- * tree — so a click inside an embedded panel changed its text and `observe` reported an empty window.
6
- * Measured on a console with an embedded SLA panel: the click landed, the panel updated, and the
7
- * observe window held zero DOM events. That is the false-green shape exactly — "nothing happened" is
8
- * what an agent concludes, and it is wrong.
9
- *
10
- * A MutationObserver CAN observe a node in another same-origin document, so the fix is to hand it the
11
- * frame bodies. Cross-origin frames stay unreachable and keep their existing blind-spot declaration.
12
- *
13
- * Network is NOT covered here. A frame's `fetch` lives in the frame's own realm and the top realm's
14
- * patch never sees it, so requests it makes are invisible — declared as a blind spot rather than
15
- * partially instrumented, because a half-seen request channel is worse than an admitted one.
16
- */
17
- /** Frames-inside-frames, bounded. Real embeds nest once, occasionally twice. */
18
1
  const DEPTH_MAX = 3;
19
2
  function bodyOf(frame) {
20
3
  try {
21
4
  return frame.contentDocument?.body ?? null;
22
5
  }
23
6
  catch {
24
- return null; // cross-origin — not ours to read
7
+ return null;
25
8
  }
26
9
  }
27
- /** Every same-origin frame body at or beneath `root`, in document order. */
28
10
  export function sameOriginFrameBodies(root) {
29
11
  const found = [];
30
12
  const walk = (node, depth) => {
@@ -41,21 +23,12 @@ export function sameOriginFrameBodies(root) {
41
23
  walk(root, 0);
42
24
  return found;
43
25
  }
44
- /**
45
- * Call `attach` for every same-origin frame body now and after each frame load.
46
- *
47
- * A frame that navigates gets a BRAND NEW document, so the old observation is dead and re-attaching on
48
- * `load` is not optional — without it, observation silently stops the first time an embedded panel
49
- * routes. Attaching twice to the same node is harmless (MutationObserver de-duplicates), so no
50
- * bookkeeping is kept.
51
- */
52
26
  export function observeSameOriginFrames(attach) {
53
27
  const attachAll = () => {
54
28
  for (const body of sameOriginFrameBodies(document))
55
29
  attach(body);
56
30
  };
57
31
  attachAll();
58
- // `load` does not bubble, but it DOES reach a capturing listener on the document.
59
32
  const onLoad = (event) => {
60
33
  if (event.target instanceof HTMLIFrameElement)
61
34
  attachAll();
@@ -1,7 +1,2 @@
1
1
  import type { Emit, Teardown } from './types.js';
2
- /**
3
- * Report page visibility/focus immediately on change + a lightweight native heartbeat.
4
- * Lets the bridge know whether the tab is foregrounded so the agent never drives a throttled
5
- * tab blind. Uses a native (pre-bound) timer so a frozen app clock (reticle_clock) never stalls it.
6
- */
7
2
  export declare function installHealth(emit: Emit): Teardown;
@@ -1,11 +1,5 @@
1
1
  import { BrowserBrand, EventType, HealthReason, RETICLE_IPC_GLOBAL, SESSION_HEALTH, } from '@reticlehq/core';
2
2
  import { nativeSetInterval } from '../timers/native-timers.js';
3
- /**
4
- * Which shell this page runs in. Reported with health because it is what makes a timeout
5
- * diagnosable: an occluded WKWebView is SUSPENDED by macOS, so the session stays connected while
6
- * every command times out — and a URL cannot tell a Tauri dev server from a plain localhost app, so
7
- * the runtime has to come from the page itself.
8
- */
9
3
  function detectRuntime() {
10
4
  const w = window;
11
5
  if (w['__TAURI_INTERNALS__'] !== undefined || w['__TAURI__'] !== undefined)
@@ -14,11 +8,6 @@ function detectRuntime() {
14
8
  return 'electron';
15
9
  return 'web';
16
10
  }
17
- /**
18
- * The rendering engine, coarse on purpose. Three buckets — never the UA string itself, which is
19
- * high-cardinality and semi-identifying. Order matters: every Chromium UA also contains "Safari", and
20
- * Gecko is the only engine that reports a real `Gecko/` build token.
21
- */
22
11
  function detectEngine() {
23
12
  const ua = navigator.userAgent;
24
13
  if (/Chrome|Chromium|Edg\//.test(ua))
@@ -27,11 +16,6 @@ function detectEngine() {
27
16
  return 'gecko';
28
17
  return 'webkit';
29
18
  }
30
- /**
31
- * Brand names as the Chromium family and the UA string spell them, mapped to our closed vocabulary.
32
- * Matched on the WHOLE lowercased name, never a substring: brand strings are attacker-influenced
33
- * free text, and a substring rule is how "Search Browser" becomes `arc`.
34
- */
35
19
  const BRAND_BY_NAME = {
36
20
  'google chrome': BrowserBrand.CHROME,
37
21
  chrome: BrowserBrand.CHROME,
@@ -44,19 +28,9 @@ const BRAND_BY_NAME = {
44
28
  firefox: BrowserBrand.FIREFOX,
45
29
  safari: BrowserBrand.SAFARI,
46
30
  };
47
- /** Chromium's own filler entries: the greased `Not_A Brand` (spelt differently every release) and the generic engine name. */
48
31
  function isFiller(name) {
49
32
  return 'chromium' === name || (name.includes('not') && name.includes('brand'));
50
33
  }
51
- /**
52
- * The brand, from `navigator.userAgentData.brands` where it exists and the UA string where it does
53
- * not (Firefox and Safari expose no userAgentData at all). Every fork claims `Google Chrome`
54
- * alongside its own entry, so a non-Chrome match always wins; a Chromium that names nothing but
55
- * filler is `other`, because calling it Chrome would be a guess.
56
- *
57
- * Never throws — a desktop webview may expose a `userAgentData` of any shape, and health reporting
58
- * must survive it.
59
- */
60
34
  function detectBrand() {
61
35
  try {
62
36
  const nav = navigator;
@@ -79,7 +53,6 @@ function detectBrand() {
79
53
  }
80
54
  }
81
55
  catch {
82
- /* a health report must never depend on an exotic userAgentData */
83
56
  }
84
57
  const ua = navigator.userAgent;
85
58
  if (/Edg\//.test(ua))
@@ -103,11 +76,6 @@ function snapshotHealth() {
103
76
  brand: detectBrand(),
104
77
  };
105
78
  }
106
- /**
107
- * Report page visibility/focus immediately on change + a lightweight native heartbeat.
108
- * Lets the bridge know whether the tab is foregrounded so the agent never drives a throttled
109
- * tab blind. Uses a native (pre-bound) timer so a frozen app clock (reticle_clock) never stalls it.
110
- */
111
79
  export function installHealth(emit) {
112
80
  const ac = new AbortController();
113
81
  const { signal } = ac;
@@ -117,7 +85,7 @@ export function installHealth(emit) {
117
85
  document.addEventListener('visibilitychange', () => report(HealthReason.VISIBILITY), { signal });
118
86
  window.addEventListener('focus', () => report(HealthReason.FOCUS), { signal });
119
87
  window.addEventListener('blur', () => report(HealthReason.BLUR), { signal });
120
- report(HealthReason.INITIAL); // baseline so the server knows state before the first change
88
+ report(HealthReason.INITIAL);
121
89
  const stopHeartbeat = nativeSetInterval(() => report(HealthReason.HEARTBEAT), SESSION_HEALTH.HEARTBEAT_MS);
122
90
  return () => {
123
91
  stopHeartbeat();
@@ -1,6 +1,8 @@
1
1
  import type { Emit, Teardown } from './types.js';
2
2
  interface InstallOptions {
3
3
  captureBodies: boolean;
4
+ captureErrorBodies?: boolean;
4
5
  }
6
+ export declare function runTeardowns(emit: Emit, teardowns: readonly Teardown[]): void;
5
7
  export declare function installAllObservers(emit: Emit, options: InstallOptions): Teardown[];
6
8
  export {};
@@ -1,16 +1,3 @@
1
- /**
2
- * Install every page observer, guarded.
3
- *
4
- * Extracted from `reticle.ts` because it is one job with one rule, and because that file is the SDK's
5
- * public surface — the more of this that lived there, the harder it was to see what `connect()`
6
- * actually does.
7
- *
8
- * THE RULE: one observer failing must never stop the others. This array used to be built bare, so a
9
- * single `install*` that threw took the whole `connect()` down with it — one bad patch on one exotic
10
- * page and the app got no instrumentation at all, silently, because the SDK\'s own catch blocks
11
- * ensured nobody ever found out. Each install is now wrapped: the healthy observers still run, the
12
- * failure is reported over the bridge, and the caller gets a teardown list of the same length.
13
- */
14
1
  import { installDom } from './dom.js';
15
2
  import { installStorage } from './storage.js';
16
3
  import { installStoreState } from './state.js';
@@ -22,47 +9,59 @@ import { installNetwork } from './network.js';
22
9
  import { installIpc, ipcNetOverrides, isReticleOwnIpc } from './ipc.js';
23
10
  import { installPerf } from './perf.js';
24
11
  import { installRoute } from './route.js';
12
+ import { installNavigation } from './navigation.js';
13
+ import { installDeparture } from './departure.js';
25
14
  import { installConsole } from './console.js';
15
+ import { installDialogs } from './dialogs.js';
26
16
  import { installAnimation } from './animation.js';
27
17
  import { installScroll } from './scroll.js';
28
18
  import { installHealth } from './health.js';
29
19
  import { reportSdkFailure, SdkSite } from './sdk-failure.js';
30
- /** Run one install, reporting rather than throwing if it cannot start. */
31
20
  function guard(emit, site, install) {
32
21
  try {
33
22
  return install();
34
23
  }
35
24
  catch (error) {
36
25
  reportSdkFailure(emit, site, error);
37
- return () => { }; // nothing was installed, so there is nothing to tear down
26
+ return () => { };
27
+ }
28
+ }
29
+ export function runTeardowns(emit, teardowns) {
30
+ for (const teardown of teardowns) {
31
+ try {
32
+ teardown();
33
+ }
34
+ catch (error) {
35
+ reportSdkFailure(emit, SdkSite.TEARDOWN, error);
36
+ }
38
37
  }
39
38
  }
40
39
  export function installAllObservers(emit, options) {
41
40
  return [
42
- // Composition happens HERE, not inside the network observer: the network observer knows
43
- // nothing about desktop IPC, and the IPC observer knows nothing about fetch plumbing.
44
41
  guard(emit, SdkSite.NETWORK_OBSERVER, () => installNetwork(emit, {
45
42
  captureBodies: options.captureBodies,
43
+ ...(options.captureErrorBodies === undefined
44
+ ? {}
45
+ : { captureErrorBodies: options.captureErrorBodies }),
46
46
  reinterpret: ipcNetOverrides,
47
- // The SDK's own Tauri screenshot is a fetch like any other — skip it, or the observer
48
- // reports its own captures as the app's writes.
49
47
  ignore: isReticleOwnIpc,
50
48
  })),
51
- // Desktop backends are reached over IPC, not HTTP — inert on a plain web page.
52
49
  guard(emit, SdkSite.IPC_OBSERVER, () => installIpc(emit, { captureBodies: options.captureBodies })),
53
50
  guard(emit, SdkSite.ANIMATION_OBSERVER, () => installPerf(emit)),
54
51
  guard(emit, SdkSite.ROUTER_OBSERVER, () => installRoute(emit)),
52
+ guard(emit, SdkSite.NETWORK_OBSERVER, () => installNavigation(emit)),
53
+ guard(emit, SdkSite.NETWORK_OBSERVER, () => installDeparture(emit)),
55
54
  guard(emit, SdkSite.CONSOLE_OBSERVER, () => installConsole(emit)),
55
+ guard(emit, SdkSite.CONSOLE_OBSERVER, () => installDialogs(emit)),
56
56
  guard(emit, SdkSite.ANIMATION_OBSERVER, () => installAnimation(emit)),
57
57
  guard(emit, SdkSite.DOM_OBSERVER, () => installScroll(emit)),
58
58
  guard(emit, SdkSite.DOM_OBSERVER, () => installDom(emit)),
59
- guard(emit, SdkSite.STORE_ADAPTER, () => installStorage(emit)), // storage WRITES → STORAGE_CHANGE diffs (pull remains the fallback)
60
- guard(emit, SdkSite.STORE_ADAPTER, () => installStoreState(emit)), // subscribed-store mutations → STATE_CHANGE path diffs
61
- guard(emit, SdkSite.DOM_OBSERVER, () => installFocus(emit)), // element focus movement → FOCUS_CHANGE (focus-to-body = a regression)
62
- // Files the app PRODUCES — never cross the network, so no outside-the-page tool can see them.
59
+ guard(emit, SdkSite.STORE_ADAPTER, () => installStorage(emit)),
60
+ guard(emit, SdkSite.STORE_ADAPTER, () => installStoreState(emit)),
61
+ guard(emit, SdkSite.DOM_OBSERVER, () => installFocus(emit)),
63
62
  guard(emit, SdkSite.NETWORK_OBSERVER, () => installDownload(emit, { capturePreview: options.captureBodies })),
64
- guard(emit, SdkSite.DOM_OBSERVER, () => installBlindSpots(emit)), // cross-origin iframes the SDK can't see → BLIND_SPOT (coverage: partial)
65
- guard(emit, SdkSite.HEALTH_OBSERVER, () => installHealth(emit)), // page visibility/focus health + heartbeat
66
- guard(emit, SdkSite.DOM_OBSERVER, () => installContextOpen(emit)), // window.open → CONTEXT_OPENED (the consequence may live in another context)
63
+ guard(emit, SdkSite.DOM_OBSERVER, () => installBlindSpots(emit)),
64
+ guard(emit, SdkSite.HEALTH_OBSERVER, () => installHealth(emit)),
65
+ guard(emit, SdkSite.DOM_OBSERVER, () => installContextOpen(emit)),
67
66
  ];
68
67
  }
@@ -1,32 +1,7 @@
1
1
  import type { Emit, Teardown } from './types.js';
2
- /**
3
- * Fields to override on a NET_REQUEST that turned out to be a Tauri IPC call, or null when the
4
- * request was ordinary HTTP. Pure — the network observer calls this with the response header it
5
- * already holds, so IPC knowledge stays in this file.
6
- */
7
2
  export declare function ipcNetOverrides(url: string, header: (name: string) => string | null): Record<string, unknown> | undefined;
8
- /**
9
- * Is this fetch Reticle's OWN desktop call rather than the app's?
10
- *
11
- * Tauri has no preload stage, so `reticle_screenshot` reaches the shell the only way it can: an
12
- * `invoke` — and an invoke IS a fetch to the `ipc://` protocol, straight through the patch the
13
- * network observer installed. So the observer recorded its own screenshots as app IPC calls, and
14
- * because `IPC` counts as a mutating method, three concurrent captures inside one action window came
15
- * back to the agent as `duplicate-request`: "the same write fired 3 times", against an app that had
16
- * never made the call. Accusing a correct app of a double submit is the most damaging thing a
17
- * verification tool can do, and here the tool was the one submitting.
18
- *
19
- * Electron never had this problem — its preload captures through the UNPATCHED invoke for exactly
20
- * this reason — and the bridge's own WebSocket is skipped on the same principle (`isBridgeSocket`).
21
- * The capture command is the SDK's whole Tauri-side surface, so matching it is the whole rule.
22
- */
23
3
  export declare function isReticleOwnIpc(url: string): boolean;
24
- /**
25
- * Subscribe to the Electron preload shim, if this app installed it. Inert on Tauri (whose calls the
26
- * network observer handles) and on a plain web page.
27
- */
28
4
  interface IpcOptions {
29
- /** Forward the IPC payloads the preload recorded. Off by default, like the HTTP body capture. */
30
5
  captureBodies?: boolean;
31
6
  }
32
7
  export declare function installIpc(emit: Emit, options?: IpcOptions): Teardown;