@reticlehq/browser 2.8.0 → 2.10.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 (79) hide show
  1. package/NOTICE +7 -0
  2. package/dist/actions/actions-dom.js +23 -15
  3. package/dist/actions/actions.d.ts +17 -0
  4. package/dist/actions/actions.js +132 -29
  5. package/dist/actions/synthetic-input.d.ts +9 -0
  6. package/dist/actions/synthetic-input.js +40 -0
  7. package/dist/commands/commands.js +11 -1
  8. package/dist/connect-options.d.ts +9 -9
  9. package/dist/dom/a11y.d.ts +9 -0
  10. package/dist/dom/a11y.js +19 -0
  11. package/dist/dom/dom-ignore.d.ts +19 -6
  12. package/dist/dom/dom-ignore.js +20 -13
  13. package/dist/dom/query.d.ts +2 -2
  14. package/dist/dom/query.js +85 -45
  15. package/dist/dom/snapshot.d.ts +5 -0
  16. package/dist/dom/snapshot.js +34 -0
  17. package/dist/dom/source.d.ts +14 -0
  18. package/dist/dom/source.js +16 -0
  19. package/dist/observers/install-all.js +4 -1
  20. package/dist/observers/ipc.d.ts +16 -0
  21. package/dist/observers/ipc.js +19 -1
  22. package/dist/observers/network.d.ts +8 -0
  23. package/dist/observers/network.js +24 -3
  24. package/dist/observers/state.js +21 -2
  25. package/dist/presenter/presenter-brand.d.ts +24 -7
  26. package/dist/presenter/presenter-brand.js +23 -6
  27. package/dist/presenter/presenter-config.d.ts +102 -9
  28. package/dist/presenter/presenter-config.js +94 -8
  29. package/dist/presenter/presenter-controls.d.ts +10 -10
  30. package/dist/presenter/presenter-controls.js +175 -85
  31. package/dist/presenter/presenter-dock-layout.d.ts +6 -0
  32. package/dist/presenter/presenter-dock-layout.js +170 -0
  33. package/dist/presenter/presenter-drag.d.ts +48 -0
  34. package/dist/presenter/presenter-drag.js +198 -0
  35. package/dist/presenter/presenter-effects.d.ts +1 -1
  36. package/dist/presenter/presenter-effects.js +1 -1
  37. package/dist/presenter/presenter-glow.d.ts +5 -5
  38. package/dist/presenter/presenter-glow.js +7 -7
  39. package/dist/presenter/presenter-heroicons-data.d.ts +41 -0
  40. package/dist/presenter/presenter-heroicons-data.js +38 -0
  41. package/dist/presenter/presenter-hud-chrome.d.ts +17 -0
  42. package/dist/presenter/presenter-hud-chrome.js +41 -0
  43. package/dist/presenter/presenter-icons.d.ts +44 -0
  44. package/dist/presenter/presenter-icons.js +100 -0
  45. package/dist/presenter/presenter-log.d.ts +27 -5
  46. package/dist/presenter/presenter-log.js +114 -28
  47. package/dist/presenter/presenter-report-copy.d.ts +83 -0
  48. package/dist/presenter/presenter-report-copy.js +127 -0
  49. package/dist/presenter/presenter-report-styles.d.ts +7 -0
  50. package/dist/presenter/presenter-report-styles.js +102 -0
  51. package/dist/presenter/presenter-report.d.ts +23 -0
  52. package/dist/presenter/presenter-report.js +217 -0
  53. package/dist/presenter/presenter-run-state.d.ts +1 -1
  54. package/dist/presenter/presenter-settings-styles.d.ts +2 -0
  55. package/dist/presenter/presenter-settings-styles.js +153 -0
  56. package/dist/presenter/presenter-settings.d.ts +82 -0
  57. package/dist/presenter/presenter-settings.js +494 -0
  58. package/dist/presenter/presenter-shell-styles.d.ts +1 -0
  59. package/dist/presenter/presenter-shell-styles.js +375 -0
  60. package/dist/presenter/presenter-shell.d.ts +48 -0
  61. package/dist/presenter/presenter-shell.js +408 -0
  62. package/dist/presenter/presenter-styles.d.ts +9 -4
  63. package/dist/presenter/presenter-styles.js +61 -125
  64. package/dist/presenter/presenter-tally.d.ts +8 -11
  65. package/dist/presenter/presenter-tally.js +48 -23
  66. package/dist/presenter/presenter-test-helpers.d.ts +1 -1
  67. package/dist/presenter/presenter-test-helpers.js +1 -1
  68. package/dist/presenter/presenter-verbs.js +2 -2
  69. package/dist/presenter/presenter-workspace.d.ts +15 -0
  70. package/dist/presenter/presenter-workspace.js +180 -0
  71. package/dist/presenter/presenter.d.ts +15 -10
  72. package/dist/presenter/presenter.js +194 -46
  73. package/dist/reticle.d.ts +2 -2
  74. package/dist/reticle.js +23 -19
  75. package/dist/review/annotator-styles.d.ts +6 -0
  76. package/dist/review/annotator-styles.js +81 -0
  77. package/dist/review/annotator.d.ts +29 -17
  78. package/dist/review/annotator.js +477 -146
  79. package/package.json +7 -4
package/dist/dom/query.js CHANGED
@@ -1,8 +1,8 @@
1
- import { queryAllByRole, queryAllByText, queryAllByLabelText, queryAllByPlaceholderText, queryAllByTestId, queryAllByAltText, } from '@testing-library/dom';
1
+ import { queryAllByRole, queryAllByText, queryAllByLabelText, queryAllByPlaceholderText, queryAllByTestId, queryAllByAltText, getDefaultNormalizer, } from '@testing-library/dom';
2
2
  import { DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
3
3
  import { isFrame, isHtmlElement } from './realm.js';
4
4
  import { capturedRootOf } from './shadow-registry.js';
5
- import { getAccessibleName, getRole, describe, getStates, isVisible } from './a11y.js';
5
+ import { getAccessibleName, getRole, describe, getStates, isInViewport, isVisible, } from './a11y.js';
6
6
  import { isIgnored } from './dom-ignore.js';
7
7
  import { isSensitiveKey } from '../security/serialization.js';
8
8
  import { getCapabilities } from '../registry/capabilities.js';
@@ -17,7 +17,7 @@ const MAX_COMPONENT_CANDIDATES = 2000;
17
17
  const COMPONENT_CANDIDATE_SELECTOR = `[${SOURCE_ATTR}], [${TESTID_ATTR}], button, a, input, select, textarea, [role]`;
18
18
  /**
19
19
  * Resolve a scope to its container. A container of `null` means a scope was GIVEN but resolved to
20
- * nothing (unmounted, or a selector that matches no element) — the caller must NOT fall back to the
20
+ * nothing (unmounted, or a selector that matches no element) - the caller must NOT fall back to the
21
21
  * whole page, or a scoped query silently widens into a phantom match. `scope === undefined` (no scope)
22
22
  * legitimately searches the body.
23
23
  */
@@ -33,12 +33,12 @@ function resolveContainer(scope) {
33
33
  return { container: found, scopeMissing: false };
34
34
  }
35
35
  catch {
36
- // invalid selector — treat as a missing scope, never a whole-page search
36
+ // invalid selector - treat as a missing scope, never a whole-page search
37
37
  }
38
38
  return { container: null, scopeMissing: true };
39
39
  }
40
40
  /**
41
- * Resolve an element by its SOURCE location — the precise, granular auto-anchor. The babel plugin
41
+ * Resolve an element by its SOURCE location - the precise, granular auto-anchor. The babel plugin
42
42
  * stamps `data-reticle-source="file:line:column"` on host elements, so a line-level starts-with match
43
43
  * pins the exact JSX element with a single fast attribute selector (no fiber walk). Column is
44
44
  * ignored so a small column drift doesn't unbind the anchor.
@@ -55,7 +55,7 @@ function findBySource(container, source) {
55
55
  /**
56
56
  * Resolve by component display name when no source stamp is available: scan a bounded set of
57
57
  * likely-actionable elements and keep those whose NEAREST enclosing component (via the registered
58
- * framework adapter) matches. Coarser than source (one component renders many hosts) — used as a
58
+ * framework adapter) matches. Coarser than source (one component renders many hosts) - used as a
59
59
  * fallback / for frameworks without a source plugin.
60
60
  */
61
61
  function findByComponentName(container, component) {
@@ -84,7 +84,7 @@ function findByComponent(container, query) {
84
84
  return [];
85
85
  }
86
86
  /**
87
- * Role + name, matched the way Reticle REPORTS them — not only the way Testing Library computes them.
87
+ * Role + name, matched the way Reticle REPORTS them - not only the way Testing Library computes them.
88
88
  *
89
89
  * Two implementations were disagreeing, on both axes, for the same element:
90
90
  *
@@ -94,7 +94,7 @@ function findByComponent(container, query) {
94
94
  *
95
95
  * `reticle_query`, `reticle_snapshot` and every act result report Reticle's values. The matcher used
96
96
  * TL's. So Reticle printed `textbox "Search User"` and then could not find it by that exact
97
- * role and name — the same query call reporting an identity it cannot match.
97
+ * role and name - the same query call reporting an identity it cannot match.
98
98
  *
99
99
  * It surfaced through the flow recorder, which anchors a step to the reported role+name: `flow_save`
100
100
  * graded the flow `asserted` with `degraded: 0`, a clean bill of health, and the step drifted on the
@@ -106,7 +106,7 @@ function findByComponent(container, query) {
106
106
  * today keeps resolving exactly as before and this can never take a match away. Fixed on the
107
107
  * matching side rather than by changing what is reported: dropping `placeholder` would leave an
108
108
  * input whose only label is its placeholder nameless in every snapshot, and renaming the role would
109
- * churn every stored anchor — both worse trades than a fallback that runs on the empty path.
109
+ * churn every stored anchor - both worse trades than a fallback that runs on the empty path.
110
110
  */
111
111
  function queryByRoleAndName(container, role, name) {
112
112
  if (name === undefined)
@@ -118,6 +118,35 @@ function queryByRoleAndName(container, role, name) {
118
118
  // Reticle's own role AND name: the pair actually printed to the caller.
119
119
  return [...container.querySelectorAll('*')].filter((el) => getRole(el) === role && getAccessibleName(el).trim() === wanted);
120
120
  }
121
+ /**
122
+ * The same text, written two legal ways, did not match itself.
123
+ *
124
+ * Unicode lets `café` be one code point (`é`) or two (`e` + a combining acute). They render
125
+ * identically, they are canonically equivalent, and JavaScript string comparison says they differ —
126
+ * so a query typed one way against a DOM holding the other found nothing. That is a false RED: the
127
+ * agent asserts text that is visibly on screen, is told it is absent, and reports a working app as
128
+ * broken.
129
+ *
130
+ * It cannot happen in English, which is why it survived here. It is ordinary in French, Vietnamese
131
+ * and Korean, and the decomposed form is what macOS filesystems, several IMEs and some databases
132
+ * hand back.
133
+ *
134
+ * Normalising both sides can only ADD matches: normalisation is idempotent and
135
+ * canonical-equivalence-preserving, so two strings equal before are equal after and nothing that
136
+ * matched can stop matching. That property is why this is safe to apply to the matching core.
137
+ *
138
+ * Applied only to queries over USER-VISIBLE text. A testid is an attribute the developer typed on
139
+ * both sides and a component name is an identifier; normalising those would change what an exact
140
+ * match means, for no case anyone has.
141
+ */
142
+ const toNfc = (value) => value.normalize('NFC');
143
+ /**
144
+ * Testing Library's own normaliser — whitespace trimming and collapsing — with NFC on top.
145
+ *
146
+ * Composed rather than replaced: writing a normaliser from scratch here would silently drop their
147
+ * whitespace handling, which every existing match depends on.
148
+ */
149
+ const TEXT_NORMALIZER = { normalizer: (text) => toNfc(getDefaultNormalizer()(text)) };
121
150
  /** Run the appropriate Testing-Library query against ONE root (light DOM or a shadow root). */
122
151
  function findIn(container, query) {
123
152
  const by = query.by;
@@ -128,62 +157,68 @@ function findIn(container, query) {
128
157
  case QueryBy.ROLE:
129
158
  return queryByRoleAndName(container, value, query.name);
130
159
  case QueryBy.TEXT:
131
- return queryAllByText(container, value, { exact: false });
160
+ return queryAllByText(container, toNfc(value), { exact: false, ...TEXT_NORMALIZER });
132
161
  case QueryBy.LABEL:
133
- return queryAllByLabelText(container, value, { exact: false });
162
+ return queryAllByLabelText(container, toNfc(value), { exact: false, ...TEXT_NORMALIZER });
134
163
  case QueryBy.PLACEHOLDER:
135
- return queryAllByPlaceholderText(container, value, { exact: false });
164
+ return queryAllByPlaceholderText(container, toNfc(value), {
165
+ exact: false,
166
+ ...TEXT_NORMALIZER,
167
+ });
136
168
  case QueryBy.TESTID:
137
169
  return queryAllByTestId(container, value, { exact: true });
138
170
  case QueryBy.ALT:
139
- return queryAllByAltText(container, value, { exact: false });
171
+ return queryAllByAltText(container, toNfc(value), { exact: false, ...TEXT_NORMALIZER });
140
172
  case QueryBy.COMPONENT:
141
173
  // value is the component name;.source (if present) still takes precedence inside.
142
174
  return findByComponent(container, { ...query, component: query.component ?? value });
143
175
  default:
144
176
  // THROW, never `return []`. An unsupported strategy answering "no matches" is
145
- // indistinguishable from the element genuinely being absent, so `by:'css'` — the first thing
146
- // anyone arriving from Playwright reaches for — reported a page with a <body> as empty. The
177
+ // indistinguishable from the element genuinely being absent, so `by:'css'` - the first thing
178
+ // anyone arriving from Playwright reaches for - reported a page with a <body> as empty. The
147
179
  // server now rejects unknown strategies at the schema; this is the same guarantee for every
148
180
  // other path in (replay, reticle_run, an internal caller), because a false negative invented
149
181
  // by the tool is the exact failure this product exists to prevent.
150
- throw new Error(`unsupported query strategy '${String(by)}' — use one of: ${Object.values(QueryBy).join(', ')}`);
182
+ throw new Error(`unsupported query strategy '${String(by)}' - use one of: ${Object.values(QueryBy).join(', ')}`);
151
183
  }
152
184
  }
153
- // Auto-anchor (component / source) — checked before the role/text fields so a query carrying
185
+ // Auto-anchor (component / source) - checked before the role/text fields so a query carrying
154
186
  // both a component anchor and an incidental role resolves by the more durable anchor.
155
187
  if (query.component !== undefined || query.source !== undefined) {
156
188
  return findByComponent(container, query);
157
189
  }
158
190
  // Structured form (role+name, or any single field). Same round-trip guarantee as the by+value
159
- // spelling above — the two forms must not disagree about what is findable.
191
+ // spelling above - the two forms must not disagree about what is findable.
160
192
  if (query.role !== undefined) {
161
193
  return queryByRoleAndName(container, query.role, query.name);
162
194
  }
163
195
  if (query.text !== undefined)
164
- return queryAllByText(container, query.text, { exact: false });
196
+ return queryAllByText(container, toNfc(query.text), { exact: false, ...TEXT_NORMALIZER });
165
197
  if (query.label !== undefined) {
166
- return queryAllByLabelText(container, query.label, { exact: false });
198
+ return queryAllByLabelText(container, toNfc(query.label), { exact: false, ...TEXT_NORMALIZER });
167
199
  }
168
200
  if (query.placeholder !== undefined) {
169
- return queryAllByPlaceholderText(container, query.placeholder, { exact: false });
201
+ return queryAllByPlaceholderText(container, toNfc(query.placeholder), {
202
+ exact: false,
203
+ ...TEXT_NORMALIZER,
204
+ });
170
205
  }
171
206
  if (query.testid !== undefined)
172
207
  return queryAllByTestId(container, query.testid, { exact: true });
173
208
  if (query.alt !== undefined)
174
- return queryAllByAltText(container, query.alt, { exact: false });
209
+ return queryAllByAltText(container, toNfc(query.alt), { exact: false, ...TEXT_NORMALIZER });
175
210
  return [];
176
211
  }
177
212
  /**
178
213
  * Every open shadow root at or beneath `root`, in document order.
179
214
  *
180
- * A closed root is deliberately unreachable — `element.shadowRoot` is null by design, and the SDK
215
+ * A closed root is deliberately unreachable - `element.shadowRoot` is null by design, and the SDK
181
216
  * reports that as a blind spot rather than pretending to see through it.
182
217
  */
183
218
  /**
184
219
  * A same-origin `<iframe>`'s body, or null when the document is unreachable.
185
220
  *
186
- * Cross-origin access THROWS in some engines and yields null in others, so both are handled — and
221
+ * Cross-origin access THROWS in some engines and yields null in others, so both are handled - and
187
222
  * both mean the same thing here: not ours to read. That case is not a silent skip, it is reported
188
223
  * separately as a declared blind spot.
189
224
  */
@@ -202,7 +237,7 @@ const FRAME_DEPTH_MAX = 3;
202
237
  * open shadow roots, and the documents of SAME-ORIGIN iframes.
203
238
  *
204
239
  * The iframe half was measured missing: a same-origin frame whose text the page could read straight
205
- * off `contentDocument` returned zero matches from `query`, and coverage said nothing about it — the
240
+ * off `contentDocument` returned zero matches from `query`, and coverage said nothing about it - the
206
241
  * worst combination, an absent-element answer for content that is right there. Cross-origin frames
207
242
  * are a genuinely different case and stay unread; they are declared instead.
208
243
  */
@@ -224,7 +259,7 @@ function embeddedRootsUnder(root) {
224
259
  continue;
225
260
  const body = readableFrameBody(el);
226
261
  if (null === body)
227
- continue; // cross-origin — declared, not searched
262
+ continue; // cross-origin - declared, not searched
228
263
  found.push(body);
229
264
  walk(body, depth + 1);
230
265
  }
@@ -234,13 +269,13 @@ function embeddedRootsUnder(root) {
234
269
  }
235
270
  /**
236
271
  * NOTE ON COST. The walk above is a `querySelectorAll('*')` over the container, and `reticle_query` is
237
- * a hot-path tool, so every query on every app pays it — including the overwhelming majority that hold
272
+ * a hot-path tool, so every query on every app pays it - including the overwhelming majority that hold
238
273
  * no web components.
239
274
  *
240
275
  * A cache invalidated by a MutationObserver was written and then REMOVED, because it is not sound:
241
276
  * `attachShadow` on an element that is already in the document mutates only the shadow tree, which an
242
277
  * observer watching documentElement's subtree never sees. The cached "no shadow roots here" would
243
- * persist and the content would be silently missed — a false negative indistinguishable from a genuinely
278
+ * persist and the content would be silently missed - a false negative indistinguishable from a genuinely
244
279
  * absent element, which is the exact bug the piercing was added to fix.
245
280
  *
246
281
  * Correctness wins until there is a measurement saying the walk actually matters. The allocation is
@@ -250,18 +285,18 @@ function embeddedRootsUnder(root) {
250
285
  * Run the query against the light DOM AND every open shadow root beneath the scope.
251
286
  *
252
287
  * Testing Library only walks the light DOM, so a control inside a web component returned zero matches
253
- * on a completely healthy page — a miss indistinguishable from a genuinely absent element. The
288
+ * on a completely healthy page - a miss indistinguishable from a genuinely absent element. The
254
289
  * snapshot has always pierced open roots; this makes `query` agree with it.
255
290
  */
256
291
  function findCandidates(query) {
257
292
  const { container, scopeMissing } = resolveContainer(query.scope);
258
- // A given-but-missing scope searches NOTHING — never the whole page. The empty result plus the
293
+ // A given-but-missing scope searches NOTHING - never the whole page. The empty result plus the
259
294
  // scopeMissing flag is what keeps "gone scope" distinct from "absent element".
260
295
  if (null === container)
261
296
  return { candidates: [], scopeMissing: true };
262
297
  // `self: true` asks for the container ITSELF, which every other path excludes by construction.
263
298
  // A layout element with no role, name, testid or own text is unreachable by any semantic locator,
264
- // and it is routinely the element carrying the handler — "click the empty space in this row" is a
299
+ // and it is routinely the element carrying the handler - "click the empty space in this row" is a
265
300
  // real user action that was simply not expressible. Requires a scope: without one there is no root
266
301
  // to return, and answering `document.body` would be a wrong answer wearing the shape of one.
267
302
  if (true === query.self) {
@@ -274,11 +309,11 @@ function findCandidates(query) {
274
309
  const collect = (els) => {
275
310
  for (const el of els) {
276
311
  if (seen.has(el))
277
- continue; // reachable from both host and root — count once
312
+ continue; // reachable from both host and root - count once
278
313
  // Reticle's OWN UI is not part of the app under test. `snapshot` has always excluded it; query
279
- // did not, so the presenter panel and the annotator answered `by: role` like app controls —
314
+ // did not, so the presenter panel and the annotator answered `by: role` like app controls -
280
315
  // measured on a real merchant dashboard, 7 of the 40 buttons an agent could see were ours
281
- // ("Pause", "End", "Minimise the panel", "Export", "Flag a bug for the agent"). Two of those
316
+ // ("Pause", "End", "Chat", "Export"). Two of those
282
317
  // share a NAME with a real control on that page, so an agent resolving "Export" could drive the
283
318
  // observer instead of the app and then reason about the result. Filtered here, at the one
284
319
  // collection point, so every entry path (by role/text/testid, shadow roots, scoped) is covered.
@@ -295,14 +330,14 @@ function findCandidates(query) {
295
330
  }
296
331
  /** Longest attribute value returned; one enormous href must not blow the response budget. */
297
332
  export const ATTR_VALUE_MAX = 512;
298
- /** Most attributes projected per element — a guard against a caller asking for everything. */
333
+ /** Most attributes projected per element - a guard against a caller asking for everything. */
299
334
  const ATTR_KEYS_MAX = 12;
300
335
  /**
301
336
  * Read the requested attributes off an element.
302
337
  *
303
338
  * Absent attributes are OMITTED rather than returned empty, so "not present" and "present but blank"
304
339
  * stay distinguishable. Credential-bearing names are redacted with the same rule the network and
305
- * storage paths use — a projection API must not become an exfiltration path.
340
+ * storage paths use - a projection API must not become an exfiltration path.
306
341
  */
307
342
  function projectAttrs(el, keys) {
308
343
  const out = {};
@@ -315,6 +350,11 @@ function projectAttrs(el, keys) {
315
350
  return Object.keys(out).length > 0 ? out : undefined;
316
351
  }
317
352
  function inState(el, state, memo) {
353
+ // inViewport is computed on demand rather than listed in getStates, so it stays assertable without
354
+ // adding a state to every element in every snapshot (it would bloat the wire and churn every
355
+ // describe). The predicate path is the only consumer that needs it. (#398)
356
+ if (ElementState.IN_VIEWPORT === state)
357
+ return isInViewport(el, memo);
318
358
  return getStates(el, isVisible(el, memo)).includes(state);
319
359
  }
320
360
  /**
@@ -322,7 +362,7 @@ function inState(el, state, memo) {
322
362
  *
323
363
  * describe() is the expensive part of a query: it resolves the accessible name and forces a style
324
364
  * computation for every ancestor to decide visibility. Describing every match on a page with
325
- * thousands of them freezes the main thread for seconds — and then the wire discards all but the
365
+ * thousands of them freezes the main thread for seconds - and then the wire discards all but the
326
366
  * first `MAX_COLLECTION_ITEMS` anyway, so the work past that point was never observable by anyone.
327
367
  * Matching one to the other means the cost of a broad query is bounded by the transport rather than
328
368
  * by the size of the page.
@@ -337,15 +377,15 @@ const MAX_DESCRIBED = TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS;
337
377
  export function matchQuery(query, state, limit = MAX_DESCRIBED) {
338
378
  // NO blanket try/catch here. It used to turn ANY exception during candidate-finding into
339
379
  // `elements = []`, which is the same lie as the `default` arm above: a query that could not run
340
- // reported that the element is not on the page. The one failure it was plausibly guarding —
341
- // a scope selector that matches nothing — is already handled explicitly and distinctly, as
380
+ // reported that the element is not on the page. The one failure it was plausibly guarding -
381
+ // a scope selector that matches nothing - is already handled explicitly and distinctly, as
342
382
  // `scopeMissing`, so what remained was a net that could only convert real faults into false
343
383
  // negatives.
344
384
  const found = findCandidates(query);
345
385
  const elements = found.candidates;
346
386
  const scopeMissing = found.scopeMissing;
347
387
  // One visibility cache for the whole (synchronous) query pass. isVisible is an O(depth) forced-style
348
- // walk; the state filter runs it over EVERY candidate (the count must be exact) — on a match-heavy
388
+ // walk; the state filter runs it over EVERY candidate (the count must be exact) - on a match-heavy
349
389
  // page (e.g. a 3k-row grid) that is tens of thousands of getComputedStyle calls on the host's main
350
390
  // thread. The memo makes each element's ancestors resolve once, then short-circuit for every sibling.
351
391
  const visMemo = new Map();
@@ -371,7 +411,7 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED) {
371
411
  ...(0 === filtered.length ? { hint: buildEmptyHint(query) } : {}),
372
412
  };
373
413
  }
374
- /** Resolve `aria-labelledby` (one or more element IDs) to the referenced elements' text — a bare ID is
414
+ /** Resolve `aria-labelledby` (one or more element IDs) to the referenced elements' text - a bare ID is
375
415
  * not a human-readable name. Undefined when unset or nothing resolves. */
376
416
  function resolveLabelledBy(el) {
377
417
  const ids = el.getAttribute('aria-labelledby');
@@ -384,7 +424,7 @@ function resolveLabelledBy(el) {
384
424
  .join(' ');
385
425
  return text.length > 0 ? text : undefined;
386
426
  }
387
- /** Structural clusters of the page — the successor to the raw testid list in zero-match hints. */
427
+ /** Structural clusters of the page - the successor to the raw testid list in zero-match hints. */
388
428
  function buildPresentRegions(query) {
389
429
  // For the diagnostic hint we WANT the page's orientation even when the scope is gone, so fall back
390
430
  // to the body here (the match path above already reported scopeMissing, so this can't mask it).
@@ -418,7 +458,7 @@ function buildPresentRegions(query) {
418
458
  }
419
459
  for (const el of containers) {
420
460
  const name = el.getAttribute('aria-label') ??
421
- resolveLabelledBy(el) ?? // aria-labelledby is an element ID — resolve it to the referenced TEXT
461
+ resolveLabelledBy(el) ?? // aria-labelledby is an element ID - resolve it to the referenced TEXT
422
462
  el.getAttribute('data-testid') ??
423
463
  undefined;
424
464
  const children = el.querySelectorAll('[role]');
@@ -474,8 +514,8 @@ function buildEmptyHint(query) {
474
514
  * Resolve a query to descriptors for the `query` MCP tool.
475
515
  *
476
516
  * `count` is carried through deliberately: the server reports match totals from it rather than from
477
- * the array, because the array is capped in transit. Dropping it here — which this function used to
478
- * do — silently put the server back to counting survivors and calling that the answer.
517
+ * the array, because the array is capped in transit. Dropping it here - which this function used to
518
+ * do - silently put the server back to counting survivors and calling that the answer.
479
519
  */
480
520
  export function runQuery(query, limit) {
481
521
  const result = matchQuery(query, undefined, limit);
@@ -4,6 +4,11 @@ interface SnapshotStatus {
4
4
  title: string;
5
5
  /** Open dialogs, OMITTED when there are none — absence means "no dialog is up". */
6
6
  visibleDialogs?: string[];
7
+ /**
8
+ * Present ONLY when a whole-page snapshot came back near-empty because an open overlay
9
+ * aria-hidden the rest of the page (#397). Absence means the page is not hidden behind an overlay.
10
+ */
11
+ overlayHidingPage?: string;
7
12
  }
8
13
  export interface SnapshotResult {
9
14
  tree: string;
@@ -194,12 +194,46 @@ function collectDialogs(root) {
194
194
  * case, so the field was almost pure repetition; a reader learns nothing from its presence and
195
195
  * everything from it.
196
196
  */
197
+ /**
198
+ * A focus-trap modal (Radix and friends) marks every sibling of the portal root aria-hidden. The
199
+ * snapshot walk correctly skips aria-hidden subtrees, so a whole-page snapshot can return only the
200
+ * overlay's own nodes and nothing else -- indistinguishable from "this page is empty". If the
201
+ * overlay's cleanup is stuck (common on a throttled tab), every later action then dispatches with no
202
+ * effect because the overlay genuinely captures pointer events. Say so instead of returning a
203
+ * near-empty tree with no explanation. (#397)
204
+ */
205
+ function overlayHidingPage(root) {
206
+ // Only meaningful for a whole-page snapshot; a scoped snapshot is the subtree the caller chose.
207
+ if (root !== document.body)
208
+ return undefined;
209
+ const dialogs = document.body.querySelectorAll('[role="dialog"], dialog[open], [aria-modal="true"]');
210
+ let modal;
211
+ for (const d of dialogs) {
212
+ if (isVisible(d)) {
213
+ modal = d;
214
+ break;
215
+ }
216
+ }
217
+ if (modal === undefined)
218
+ return undefined;
219
+ const modalEl = modal;
220
+ const outside = Array.from(document.body.children).filter((c) => !c.contains(modalEl));
221
+ if (0 === outside.length)
222
+ return undefined;
223
+ if (!outside.every((c) => 'true' === c.getAttribute('aria-hidden')))
224
+ return undefined;
225
+ return ('the rest of the page is aria-hidden behind an open overlay (a focus-trap modal), so this ' +
226
+ 'snapshot shows only the overlay. If it will not close its cleanup may be stuck on a throttled ' +
227
+ 'tab: dismiss the overlay, or snapshot with { scope } inside it.');
228
+ }
197
229
  function buildStatus(root) {
198
230
  const visibleDialogs = collectDialogs(root);
231
+ const overlay = overlayHidingPage(root);
199
232
  return {
200
233
  route: `${location.pathname}${location.search}${location.hash}`,
201
234
  title: document.title,
202
235
  ...(visibleDialogs.length > 0 ? { visibleDialogs } : {}),
236
+ ...(overlay !== undefined ? { overlayHidingPage: overlay } : {}),
203
237
  };
204
238
  }
205
239
  /** Build the semantic accessibility snapshot of the page or a subtree. */
@@ -29,6 +29,20 @@ export declare function parseSourceAttr(value: string | null): SourceLocation |
29
29
  export declare function sourceFromDom(el: Element): SourceLocation | undefined;
30
30
  /** Best source for the element: the framework adapter's, else the nearest babel-stamped attribute. */
31
31
  export declare function sourceFor(el: Element, adapterSource: SourceLocation | undefined): SourceLocation | undefined;
32
+ /**
33
+ * Does ANY element in this document carry a source stamp?
34
+ *
35
+ * The difference between "this element has no stamp" and "the stamping loader is not running at
36
+ * all" — two answers that look identical from a single missing `source` field, and that call for
37
+ * completely different next actions. The first is ordinary: not every element sits under a stamped
38
+ * host. The second means "fix at file:line", the single most-cited reason to reach for Reticle over
39
+ * a generic DOM tool, is silently unavailable for the whole session — and it has been discovered by
40
+ * reading the adapter's source rather than from anything Reticle said.
41
+ *
42
+ * One `querySelector`, which stops at the first hit, so this is cheap enough to answer on a
43
+ * single-element path like inspect. It is only consulted when a source is already missing.
44
+ */
45
+ export declare function documentHasSourceStamps(doc: Document): boolean;
32
46
  /**
33
47
  * `file:line` — the form an agent can act on directly.
34
48
  *
@@ -46,6 +46,22 @@ export function sourceFor(el, adapterSource) {
46
46
  return { file: adapterSource.file, line: adapterSource.line };
47
47
  return sourceFromDom(el);
48
48
  }
49
+ /**
50
+ * Does ANY element in this document carry a source stamp?
51
+ *
52
+ * The difference between "this element has no stamp" and "the stamping loader is not running at
53
+ * all" — two answers that look identical from a single missing `source` field, and that call for
54
+ * completely different next actions. The first is ordinary: not every element sits under a stamped
55
+ * host. The second means "fix at file:line", the single most-cited reason to reach for Reticle over
56
+ * a generic DOM tool, is silently unavailable for the whole session — and it has been discovered by
57
+ * reading the adapter's source rather than from anything Reticle said.
58
+ *
59
+ * One `querySelector`, which stops at the first hit, so this is cheap enough to answer on a
60
+ * single-element path like inspect. It is only consulted when a source is already missing.
61
+ */
62
+ export function documentHasSourceStamps(doc) {
63
+ return doc.querySelector(`[${SOURCE_ATTR}]`) !== null;
64
+ }
49
65
  /**
50
66
  * `file:line` — the form an agent can act on directly.
51
67
  *
@@ -18,7 +18,7 @@ import { installFocus } from './focus.js';
18
18
  import { installBlindSpots } from './blind-spots.js';
19
19
  import { installDownload } from './download.js';
20
20
  import { installNetwork } from './network.js';
21
- import { installIpc, ipcNetOverrides } from './ipc.js';
21
+ import { installIpc, ipcNetOverrides, isReticleOwnIpc } from './ipc.js';
22
22
  import { installPerf } from './perf.js';
23
23
  import { installRoute } from './route.js';
24
24
  import { installConsole } from './console.js';
@@ -43,6 +43,9 @@ export function installAllObservers(emit, options) {
43
43
  guard(emit, SdkSite.NETWORK_OBSERVER, () => installNetwork(emit, {
44
44
  captureBodies: options.captureBodies,
45
45
  reinterpret: ipcNetOverrides,
46
+ // The SDK's own Tauri screenshot is a fetch like any other — skip it, or the observer
47
+ // reports its own captures as the app's writes.
48
+ ignore: isReticleOwnIpc,
46
49
  })),
47
50
  // Desktop backends are reached over IPC, not HTTP — inert on a plain web page.
48
51
  guard(emit, SdkSite.IPC_OBSERVER, () => installIpc(emit, { captureBodies: options.captureBodies })),
@@ -5,6 +5,22 @@ import type { Emit, Teardown } from './types.js';
5
5
  * already holds, so IPC knowledge stays in this file.
6
6
  */
7
7
  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
+ export declare function isReticleOwnIpc(url: string): boolean;
8
24
  /**
9
25
  * Subscribe to the Electron preload shim, if this app installed it. Inert on Tauri (whose calls the
10
26
  * network observer handles) and on a plain web page.
@@ -1,4 +1,4 @@
1
- import { BlindSpotKind, EventType, IPC_URL_SCHEME, IpcStatus, NetInitiator, RETICLE_IPC_GLOBAL, } from '@reticlehq/core';
1
+ import { BlindSpotKind, EventType, IPC_URL_SCHEME, IpcStatus, NetInitiator, RETICLE_IPC_GLOBAL, RETICLE_TAURI_CAPTURE_COMMAND, } from '@reticlehq/core';
2
2
  import { observeSafely } from './types.js';
3
3
  /**
4
4
  * How Tauri v2 reports a command's verdict. The transport answers HTTP 200 whether the Rust command
@@ -48,6 +48,24 @@ export function ipcNetOverrides(url, header) {
48
48
  statusText: ok ? IPC_STATUS_TEXT.OK : IPC_STATUS_TEXT.ERROR,
49
49
  };
50
50
  }
51
+ /**
52
+ * Is this fetch Reticle's OWN desktop call rather than the app's?
53
+ *
54
+ * Tauri has no preload stage, so `reticle_screenshot` reaches the shell the only way it can: an
55
+ * `invoke` — and an invoke IS a fetch to the `ipc://` protocol, straight through the patch the
56
+ * network observer installed. So the observer recorded its own screenshots as app IPC calls, and
57
+ * because `IPC` counts as a mutating method, three concurrent captures inside one action window came
58
+ * back to the agent as `duplicate-request`: "the same write fired 3 times", against an app that had
59
+ * never made the call. Accusing a correct app of a double submit is the most damaging thing a
60
+ * verification tool can do, and here the tool was the one submitting.
61
+ *
62
+ * Electron never had this problem — its preload captures through the UNPATCHED invoke for exactly
63
+ * this reason — and the bridge's own WebSocket is skipped on the same principle (`isBridgeSocket`).
64
+ * The capture command is the SDK's whole Tauri-side surface, so matching it is the whole rule.
65
+ */
66
+ export function isReticleOwnIpc(url) {
67
+ return TAURI_IPC_URL.exec(url)?.[1] === RETICLE_TAURI_CAPTURE_COMMAND;
68
+ }
51
69
  /**
52
70
  * Is this page an Electron renderer? Read off the user agent, which Electron stamps and which is
53
71
  * available whether or not the preload ran — the whole point is to recognise a renderer that has
@@ -16,6 +16,14 @@ interface NetworkOptions {
16
16
  captureBodies?: boolean;
17
17
  /** Optional hook that reinterprets a completed request — see NetResponseReinterpreter. */
18
18
  reinterpret?: NetResponseReinterpreter;
19
+ /**
20
+ * Requests Reticle itself makes, which must never reach the app's evidence.
21
+ *
22
+ * Same seam and same reason as `reinterpret`: this observer knows nothing about desktop IPC, and
23
+ * the SDK's own Tauri screenshot travels as an ordinary fetch through this very patch. Passed in
24
+ * rather than imported, so the knowledge stays in the IPC observer.
25
+ */
26
+ ignore?: (url: string) => boolean;
19
27
  }
20
28
  /** Pure: the first real app-code frame in a stack string, capped. Exported for unit testing. */
21
29
  export declare function firstAppFrame(stack: string | undefined): string | undefined;
@@ -61,6 +61,22 @@ function projectRequestBody(body, captureBodies) {
61
61
  const { body: out, truncated } = projectBody(text, contentType);
62
62
  return truncated ? { requestBody: out, requestBodyTruncated: true } : { requestBody: out };
63
63
  }
64
+ /**
65
+ * Did this response succeed? A REDIRECT DID.
66
+ *
67
+ * The one rule both transports must share. `Response.ok` is true only for 200-299, so reading it
68
+ * straight through stamped every 3xx as a failed request, while the XHR path computed the same field
69
+ * itself and called the identical status a success. Downstream, `ok` is authoritative when present —
70
+ * so a POST-redirect-GET login, the first flow anyone verifies in an app with auth, produced a
71
+ * `ui-advanced-request-failed` contradiction citing its own success path, and the agent had to
72
+ * overrule the verdict by hand. A verdict that gets manually overruled is not deciding anything.
73
+ *
74
+ * 3xx is normal successful navigation. An opaque (no-cors) response reports status 0 and is correctly
75
+ * excluded here, exactly as `Response.ok` excluded it.
76
+ */
77
+ function statusIsOk(status) {
78
+ return status >= 200 && status < 400;
79
+ }
64
80
  /**
65
81
  * Response metadata that needs no body capture: HTTP status text, content-type, and byte size (from
66
82
  * content-length when the server sent it). Lets an agent tell an HTML error page served as 200 from
@@ -219,10 +235,15 @@ export function installNetwork(emit, opts = {}) {
219
235
  let seq = 0;
220
236
  const nextId = () => `n${++seq}`;
221
237
  window.fetch = async (input, init) => {
238
+ const rawUrl = urlOf(input);
239
+ // Reticle's own call: pass it straight through, observed by nobody. Emitting even the PENDING
240
+ // half would be wrong twice over — it lands in the agent's network evidence, and `settle` would
241
+ // wait on the SDK's own screenshot as if the app had a request in flight.
242
+ if (true === opts.ignore?.(rawUrl))
243
+ return callFetch(input, init);
222
244
  const id = nextId();
223
245
  const start = performance.now();
224
246
  const method = methodOf(input, init);
225
- const rawUrl = urlOf(input);
226
247
  const url = redactUrl(rawUrl);
227
248
  const initiatorStack = initiatorFrame();
228
249
  const initiatorFields = initiatorStack === undefined ? {} : { initiatorStack };
@@ -241,7 +262,7 @@ export function installNetwork(emit, opts = {}) {
241
262
  method,
242
263
  url,
243
264
  status: res.status,
244
- ok: res.ok,
265
+ ok: statusIsOk(res.status),
245
266
  durationMs: Math.round(headersAt - start),
246
267
  initiator: 'fetch',
247
268
  ...initiatorFields,
@@ -384,7 +405,7 @@ export function installNetwork(emit, opts = {}) {
384
405
  method: cur.method,
385
406
  url: cur.url,
386
407
  status: this.status,
387
- ok: this.status >= 200 && this.status < 400,
408
+ ok: statusIsOk(this.status),
388
409
  durationMs: Math.round(performance.now() - cur.start),
389
410
  initiator: 'xhr',
390
411
  ...(cur.initiatorStack === undefined ? {} : { initiatorStack: cur.initiatorStack }),
@@ -1,4 +1,4 @@
1
- import { EventType, REDACTED_VALUE } from '@reticlehq/core';
1
+ import { BlindSpotKind, EventType, REDACTED_VALUE } from '@reticlehq/core';
2
2
  import { subscribableStores, onStoreRegistered, } from '../registry/stores.js';
3
3
  import { isSensitiveKey, sanitizeForTransport } from '../security/serialization.js';
4
4
  function isRecord(value) {
@@ -68,9 +68,28 @@ export function installStoreState(emit) {
68
68
  // Stores already registered...
69
69
  for (const entry of subscribableStores())
70
70
  watch(entry);
71
+ /**
72
+ * Declare an unwatched state channel — and clear it the moment one is watched.
73
+ *
74
+ * With nothing subscribed, every act reports `stateDiffs: []`, which reads as "the app changed no
75
+ * state" when the truth is "nobody was looking". Emitted as a BLIND_SPOT so it travels the path
76
+ * that already exists for everything the layer cannot see: bounding, never impeaching, so no
77
+ * verdict turns UNKNOWN over it.
78
+ */
79
+ if (0 === active.size) {
80
+ emit(EventType.BLIND_SPOT, { kind: BlindSpotKind.UNWATCHED_STATE, count: 1 });
81
+ }
71
82
  //...and any registered LATER. The SDK installs observers during connect, but apps call
72
83
  // registerStore after that, so without this the common case subscribes to nothing.
73
- const offRegistered = onStoreRegistered(watch);
84
+ const offRegistered = onStoreRegistered((entry) => {
85
+ const wasDark = 0 === active.size;
86
+ watch(entry);
87
+ // The channel just lit up. A blind spot that is never withdrawn is a permanent lie, and this one
88
+ // is withdrawn in the ordinary case: apps register their stores AFTER connect() installs us.
89
+ if (wasDark && active.size > 0) {
90
+ emit(EventType.BLIND_SPOT, { kind: BlindSpotKind.UNWATCHED_STATE, count: 0 });
91
+ }
92
+ });
74
93
  return () => {
75
94
  offRegistered();
76
95
  for (const unsubscribe of active.values()) {