@reticlehq/browser 2.12.0 → 2.13.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 (41) hide show
  1. package/dist/actions/actions.js +6 -6
  2. package/dist/connect-options.d.ts +18 -1
  3. package/dist/dom/dom-ignore.d.ts +9 -0
  4. package/dist/dom/dom-ignore.js +32 -3
  5. package/dist/dom/query.js +55 -3
  6. package/dist/dom/snapshot.d.ts +8 -0
  7. package/dist/dom/snapshot.js +71 -5
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +5 -1
  10. package/dist/observers/animation.js +6 -8
  11. package/dist/observers/focus.js +5 -6
  12. package/dist/observers/health.js +6 -9
  13. package/dist/observers/network-redact.d.ts +13 -0
  14. package/dist/observers/network-redact.js +12 -1
  15. package/dist/observers/network.d.ts +2 -2
  16. package/dist/observers/network.js +24 -17
  17. package/dist/observers/scroll.js +4 -2
  18. package/dist/presenter/presenter-config.d.ts +11 -2
  19. package/dist/presenter/presenter-config.js +9 -0
  20. package/dist/presenter/presenter-hud-chrome.d.ts +1 -1
  21. package/dist/presenter/presenter-report-copy.d.ts +30 -1
  22. package/dist/presenter/presenter-report-copy.js +31 -2
  23. package/dist/presenter/presenter-report-styles.js +24 -0
  24. package/dist/presenter/presenter-report.d.ts +2 -2
  25. package/dist/presenter/presenter-report.js +95 -3
  26. package/dist/presenter/presenter-shell-styles.js +20 -1
  27. package/dist/presenter/presenter-shell.js +0 -1
  28. package/dist/presenter/presenter.d.ts +13 -0
  29. package/dist/presenter/presenter.js +37 -2
  30. package/dist/registry/auto-testids.d.ts +21 -0
  31. package/dist/registry/auto-testids.js +48 -0
  32. package/dist/registry/capabilities.d.ts +28 -1
  33. package/dist/registry/capabilities.js +59 -7
  34. package/dist/registry/store-adapters.d.ts +1 -1
  35. package/dist/registry/store-adapters.js +6 -1
  36. package/dist/registry/stores.d.ts +4 -0
  37. package/dist/registry/stores.js +35 -0
  38. package/dist/reticle.js +8 -0
  39. package/dist/transport/unreachable-message.d.ts +7 -0
  40. package/dist/transport/unreachable-message.js +9 -0
  41. package/package.json +4 -4
@@ -142,8 +142,8 @@ function dangerousActionContext(el) {
142
142
  form?.getAttribute('action') ?? '',
143
143
  ].join(' ');
144
144
  }
145
- function requiresDangerousConfirmation(text) {
146
- return isDangerousActionText(text);
145
+ function requiresDangerousConfirmation(text, role) {
146
+ return isDangerousActionText(text, role);
147
147
  }
148
148
  /**
149
149
  * Which key a `press` is asking for.
@@ -325,11 +325,11 @@ function assertActionAllowed(el, action, args) {
325
325
  // Same resolver as the dispatch below, so the destructive-action guard cannot classify a drag
326
326
  // by a target the dispatch will not use.
327
327
  const dragTarget = action === ActionType.DRAG ? refs.resolve(dragTargetRef(args)) : null;
328
- const context = isHtmlElement(dragTarget)
329
- ? `${dangerousActionContext(el)} ${dangerousActionContext(dragTarget)}`
330
- : dangerousActionContext(el);
328
+ const sourceDangerous = requiresDangerousConfirmation(dangerousActionContext(el), getRole(el));
329
+ const targetDangerous = isHtmlElement(dragTarget) &&
330
+ requiresDangerousConfirmation(dangerousActionContext(dragTarget), getRole(dragTarget));
331
331
  if (canTrigger &&
332
- requiresDangerousConfirmation(context) &&
332
+ (sourceDangerous || targetDangerous) &&
333
333
  args[DANGEROUS_ACTION_CONFIRM_ARG] !== true) {
334
334
  throw new Error(`potentially destructive action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`);
335
335
  }
@@ -17,7 +17,11 @@ export interface ReticleConnectOptions {
17
17
  * agent scope to the right app even when its dev server boots on an unexpected port. Optional.
18
18
  */
19
19
  projectId?: string;
20
- /** Browser/bridge pairing token. Required when either endpoint is non-localhost. */
20
+ /**
21
+ * Browser/bridge pairing token. Required whenever the daemon has one, which is the ordinary case:
22
+ * it auto-provisions `~/.reticle/pairing-token` on first start and then refuses a hello without it,
23
+ * including on localhost. Non-localhost additionally needs `allowNonLocalhost`.
24
+ */
21
25
  token?: string;
22
26
  /** Explicitly allow Reticle on a non-localhost page or bridge. Requires token. */
23
27
  allowNonLocalhost?: boolean;
@@ -35,6 +39,19 @@ export interface ReticleConnectOptions {
35
39
  * sensitive keys redacted, per-body capped). Off by default - bodies cost tokens and can carry PII.
36
40
  */
37
41
  captureNetworkBodies?: boolean;
42
+ /**
43
+ * Make Reticle's OWN presenter visible to snapshots and queries. CONTRIBUTORS ONLY.
44
+ *
45
+ * The presenter is hidden from every tool by design, for a good reason: an agent that can drive
46
+ * Reticle's own interface can fabricate its own impact report, and Reticle chrome in a snapshot is
47
+ * noise in every other app on earth.
48
+ *
49
+ * The cost of that rule is that a HUD change is the only kind of change Reticle cannot be used to
50
+ * check — the panel rendering it is invisible to everything that could look at it. This hatch
51
+ * exists for exactly that case: the app under test IS Reticle. It reports itself in the app's
52
+ * capabilities, so a verdict drawn with it open can never be mistaken for an ordinary one.
53
+ */
54
+ exposePresenter?: boolean;
38
55
  /**
39
56
  * The project root, so source paths report repo-relative instead of absolute.
40
57
  *
@@ -4,6 +4,15 @@
4
4
  * presenter, so omitting its selector here leaked annotation chrome into every snapshot.
5
5
  */
6
6
  export declare const RETICLE_OVERLAY: string;
7
+ /**
8
+ * Make Reticle's own presenter visible to snapshots and queries. Contributors only.
9
+ *
10
+ * Deliberately a function rather than a field on the ignore set, so the one place that decides
11
+ * "is this ours" also decides "may it be seen", and no caller can half-apply it.
12
+ */
13
+ export declare function setPresenterVisible(visible: boolean): void;
14
+ /** Whether the hatch is open — reported in the app's capabilities so a verdict is never mistaken. */
15
+ export declare function isPresenterVisible(): boolean;
7
16
  /** Let the host app add selectors to exclude from snapshots (e.g. its own dev widgets). */
8
17
  export declare function setIgnoreSelectors(selectors: string[]): void;
9
18
  /** True if the element is part of Reticle's own presenter overlay. */
@@ -7,6 +7,34 @@ export const RETICLE_OVERLAY = '[data-reticle-overlay],[data-reticle-cursor],[da
7
7
  /** Known third-party dev overlays to keep out of snapshots (Agentation, Next dev UI). */
8
8
  const DEV_OVERLAYS = '[data-agentation],#__next-build-watcher,nextjs-portal,[data-nextjs-dialog],[data-nextjs-toast]';
9
9
  let extraIgnore = '';
10
+ /**
11
+ * Whether Reticle's OWN UI is visible to snapshots and queries.
12
+ *
13
+ * Off, always, unless an app opts in — and the opt-in exists for exactly one situation: the app
14
+ * under test IS Reticle. A HUD change is otherwise the only kind of change Reticle cannot be used to
15
+ * check, because the panel that renders it is invisible to every tool that could look at it.
16
+ *
17
+ * It stays off by default because the reason for hiding it is sound: an agent that can drive
18
+ * Reticle's own interface can fabricate its own impact report, and a snapshot full of Reticle chrome
19
+ * is noise in every other app on earth. This is a hatch for contributors, not a setting.
20
+ *
21
+ * Third-party dev overlays stay ignored either way — they are somebody else's furniture and were
22
+ * never the thing being verified.
23
+ */
24
+ let presenterVisible = false;
25
+ /**
26
+ * Make Reticle's own presenter visible to snapshots and queries. Contributors only.
27
+ *
28
+ * Deliberately a function rather than a field on the ignore set, so the one place that decides
29
+ * "is this ours" also decides "may it be seen", and no caller can half-apply it.
30
+ */
31
+ export function setPresenterVisible(visible) {
32
+ presenterVisible = visible;
33
+ }
34
+ /** Whether the hatch is open — reported in the app's capabilities so a verdict is never mistaken. */
35
+ export function isPresenterVisible() {
36
+ return presenterVisible;
37
+ }
10
38
  /** Let the host app add selectors to exclude from snapshots (e.g. its own dev widgets). */
11
39
  export function setIgnoreSelectors(selectors) {
12
40
  extraIgnore = selectors.join(',');
@@ -35,8 +63,9 @@ export function isReticleUi(node) {
35
63
  }
36
64
  /** True if the element should be excluded from snapshots/queries (Reticle overlay or dev overlay). */
37
65
  export function isIgnored(el) {
38
- const sel = extraIgnore.length > 0
39
- ? `${RETICLE_OVERLAY},${DEV_OVERLAYS},${extraIgnore}`
40
- : `${RETICLE_OVERLAY},${DEV_OVERLAYS}`;
66
+ // With the hatch open, Reticle's own overlay drops out of the ignore set — but the third-party
67
+ // dev overlays do not. They are somebody else's furniture and were never the thing being verified.
68
+ const ours = presenterVisible ? '' : RETICLE_OVERLAY;
69
+ const sel = [ours, DEV_OVERLAYS, extraIgnore].filter((part) => part.length > 0).join(',');
41
70
  return el.closest(sel) !== null;
42
71
  }
package/dist/dom/query.js CHANGED
@@ -4,7 +4,7 @@ import { capturedRootOf } from './shadow-registry.js';
4
4
  import { getAccessibleName, getRole, describe, getStates, isInViewport, isVisible, } from './a11y.js';
5
5
  import { isIgnored } from './dom-ignore.js';
6
6
  import { isSensitiveKey } from '../security/serialization.js';
7
- import { getCapabilities } from '../registry/capabilities.js';
7
+ import { declaredTestids } from '../registry/capabilities.js';
8
8
  import { identifyComponent } from '../registry/adapters.js';
9
9
  import { refs } from './refs.js';
10
10
  const TESTID_ATTR = 'data-testid';
@@ -504,6 +504,47 @@ function buildPresentRegions(query) {
504
504
  }
505
505
  return regions;
506
506
  }
507
+ /**
508
+ * The tightest single element whose SUBTREE text carries `wanted`, when no element's OWN text does.
509
+ *
510
+ * `by: text` matches an element's direct text nodes (`directText`), so a label rendered across
511
+ * several child nodes — `v-html`, an inline `<span>` per word, a highlighted substring — exists on
512
+ * the page and matches nothing. The two channels disagree by construction: `act_and_wait` reports
513
+ * `appeared` by concatenating an added subtree, so the string an agent is most likely to copy into
514
+ * its next predicate is exactly the shape this query cannot find.
515
+ *
516
+ * That failure is indistinguishable from the element being absent, which is the expensive half: the
517
+ * agent reads "no element matched", concludes the app did not render, and reports a bug against
518
+ * correct code.
519
+ *
520
+ * Returns the DEEPEST container rather than the first, because every ancestor up to `<body>` also
521
+ * contains the string and naming `<body>` is not a locator. The deepest one is the tightest scope
522
+ * that still holds the whole string, which is the one worth pasting back.
523
+ */
524
+ function splitTextOwner(container, wanted) {
525
+ let best;
526
+ let bestDepth = -1;
527
+ for (const el of elementsUnder(container)) {
528
+ if (isIgnored(el))
529
+ continue; // never point an agent at Reticle's own UI
530
+ if (!fuzzyVisibleText(el.textContent ?? '', wanted))
531
+ continue;
532
+ let depth = 0;
533
+ for (let parent = el.parentElement; parent !== null; parent = parent.parentElement)
534
+ depth++;
535
+ if (depth > bestDepth) {
536
+ best = el;
537
+ bestDepth = depth;
538
+ }
539
+ }
540
+ return best;
541
+ }
542
+ /** The text this query searched for, in either spelling, or undefined when it searched by something else. */
543
+ function wantedTextOf(query) {
544
+ if (query.text !== undefined)
545
+ return query.text;
546
+ return QueryBy.TEXT === query.by ? query.value : undefined;
547
+ }
507
548
  /** Diagnostic hint for a zero-match query: what testids ARE present in the searched scope. */
508
549
  function buildEmptyHint(query) {
509
550
  const container = resolveContainer(query.scope).container ?? document.body;
@@ -519,15 +560,26 @@ function buildEmptyHint(query) {
519
560
  break;
520
561
  }
521
562
  }
522
- const registered = getCapabilities().testids;
563
+ // DECLARED, not observed: see declaredTestids. Every present testid is also an observed one, so
564
+ // the merged list would make this flag true for any page that has a testid at all.
565
+ const registered = declaredTestids();
523
566
  const knownEmptyState = present.some((id) => registered.includes(id));
524
567
  const route = `${location.pathname}${location.search}`;
525
- return {
568
+ const hint = {
526
569
  route,
527
570
  presentTestids: present,
528
571
  presentRegions: buildPresentRegions(query),
529
572
  knownEmptyState,
530
573
  };
574
+ // Only for a text search, and only when the string IS here — otherwise this is an ordinary miss
575
+ // and the extra clause would lengthen every failure to say nothing.
576
+ const wanted = wantedTextOf(query);
577
+ if (wanted !== undefined) {
578
+ const owner = splitTextOwner(container, wanted);
579
+ if (owner !== undefined)
580
+ hint.splitText = describe(owner);
581
+ }
582
+ return hint;
531
583
  }
532
584
  /**
533
585
  * Resolve a query to descriptors for the `query` MCP tool.
@@ -21,6 +21,14 @@ export interface SnapshotResult {
21
21
  * must not receive the entire page as if it were the modal.
22
22
  */
23
23
  scopeMissing?: boolean;
24
+ /**
25
+ * How many MEANINGFUL nodes the lean walk passed over, present only in a lean mode and only when
26
+ * non-zero. This is what stops an empty `interactive` tree reading as an empty page — the mode is
27
+ * a role filter, and a production app whose controls carry no roles yields nothing while being
28
+ * full of things to drive. It shipped untyped: the value was spread into the result and every
29
+ * consumer had to reach for it without a type, including the tool that builds the note from it.
30
+ */
31
+ leanSkipped?: number;
24
32
  /**
25
33
  * Refs of the subtree roots the walk never entered, present ONLY when `truncated`. This is the
26
34
  * cut's own frontier: re-snapshot each with `{ scope: ref, includeRoot: true }` and the union is
@@ -3,7 +3,7 @@ import { capturedRootOf } from './shadow-registry.js';
3
3
  import { isFrame } from './realm.js';
4
4
  import { getAccessibleName, getRole, getStates, getValue, isVisible } from './a11y.js';
5
5
  import { refs } from './refs.js';
6
- import { isIgnored } from './dom-ignore.js';
6
+ import { isIgnored, isReticleOverlay } from './dom-ignore.js';
7
7
  const INTERACTIVE = new Set([
8
8
  'button',
9
9
  'link',
@@ -87,8 +87,12 @@ function stateSuffix(el) {
87
87
  function formatLine(el, depth, role, name, layout) {
88
88
  const indent = ' '.repeat(depth);
89
89
  const value = getValue(el);
90
- const namePart = name.length > 0 ? ` "${name}"` : '';
91
- const refPart = INTERACTIVE.has(role) || name.length > 0 ? ` (ref=${refs.refFor(el)})` : '';
90
+ // A testid stands in for the accessible name when there is none. Including a role-less control and
91
+ // then printing it as a bare `- generic` is worse than leaving it out: it is visible and it cannot
92
+ // be addressed. This is the name its author gave it.
93
+ const label = name;
94
+ const namePart = label.length > 0 ? ` "${label}"` : '';
95
+ const refPart = INTERACTIVE.has(role) || label.length > 0 ? ` (ref=${refs.refFor(el)})` : '';
92
96
  const valuePart = value !== undefined && value.length > 0 ? ` [value="${value}"]` : '';
93
97
  const layoutPart = layout.length > 0 ? ` [${layout}]` : '';
94
98
  return `${indent}- ${role}${namePart}${refPart}${valuePart}${layoutPart}${stateSuffix(el)}`;
@@ -177,8 +181,51 @@ function visit(child, depth, ctx, inLive) {
177
181
  const text = !lean && 'generic' === role && 0 === name.length ? directText(child) : '';
178
182
  // Layout signature for grid/flex containers — makes CLS/layout regressions visible.
179
183
  const layout = lean ? '' : layoutSignature(style);
180
- const meaningful = interactive || role !== 'generic' || name.length > 0 || text.length > 0 || layout.length > 0;
181
- const include = lean ? interactive : meaningful;
184
+ // Actionable WITHOUT an interactive role, which is most of the real web.
185
+ //
186
+ // `interactive` is a role test, and a large share of production UI carries no roles at all.
187
+ // Measured on MarkText, a production Electron editor: `mode:"interactive"` returned an EMPTY tree
188
+ // where `mode:"full"` found 47 nodes, because its whole block picker is `<div>`s. An empty tree is
189
+ // indistinguishable from an empty page, and the tool description recommends this mode as the
190
+ // default — so the cheaper view told an agent there was nothing to drive.
191
+ //
192
+ // A `data-testid` ONLY. It is a handle its author put there to be driven, and it marks one element
193
+ // rather than a subtree.
194
+ //
195
+ // `cursor: pointer` was tried here as a second signal — it is how an app tells a HUMAN that a
196
+ // thing is clickable — and measured on MarkText it was a disaster: interactive went from 0 nodes
197
+ // and 72 tokens to 180 nodes and 870, against a FULL snapshot of 47 nodes and 424. The pointer
198
+ // cursor is inherited, so every wrapper div inside a clickable row matched, and the lean mode
199
+ // became twice the size of the complete one while adding nothing but nameless `- generic` lines.
200
+ // The mode's whole claim is that it is smaller; a signal that cannot tell a control from its
201
+ // ancestors cannot be used to decide what a control is.
202
+ // `data-testid` was tried here as a second signal, on the premise that it is a handle its author
203
+ // put there to be driven. Measured against our own instrumented bench app, the premise is false:
204
+ // the lean tree on its dashboard came to 16 nodes and 175 tokens, and EIGHT were display elements
205
+ // — kpi-deploys, kpi-success, kpi-p95, kpi-services, area-chart, activity-feed, brand. Half the
206
+ // "actionable" view was things nothing can act on, and the mode roughly doubled to carry them; the
207
+ // benchmark read it as a 4% verification-efficiency regression. A testid marks what a TEST cares
208
+ // about, which is a superset of what a driver can use.
209
+ //
210
+ // It did not even help the case it shipped for: MarkText's controls carry no testid. `leanSkipped`
211
+ // is what fixed that — an empty tree that says how many it passed over is no longer an empty page.
212
+ //
213
+ // `cursor: pointer` was tried too and was worse: interactive went from 0 nodes and 72 tokens to
214
+ // 180 and 870 against a FULL snapshot of 47/424, because the pointer cursor is inherited and every
215
+ // wrapper div inside a clickable row matched. A signal that cannot tell a control from its
216
+ // ancestors cannot decide what a control is.
217
+ const actionable = interactive;
218
+ // A testid still makes an element MEANINGFUL, so `full` keeps it and can name and address it. It
219
+ // just is not evidence that the element is ACTIONABLE, which is the only question lean mode asks.
220
+ const meaningful = actionable || role !== 'generic' || name.length > 0 || text.length > 0 || layout.length > 0;
221
+ const include = lean ? actionable : meaningful;
222
+ // Counted, not just skipped. An empty INTERACTIVE tree is indistinguishable from an empty page,
223
+ // and on a real app the difference is everything: MarkText's block picker is 20+ live controls
224
+ // that carry no role, no testid and no pointer cursor, so the lean view is empty while the app is
225
+ // full of things to drive. Knowing how many were passed over is what turns "" into "look again in
226
+ // full mode" — see the note the snapshot tool builds from this.
227
+ if (lean && !include && meaningful)
228
+ ctx.leanSkipped += 1;
182
229
  if (include) {
183
230
  ctx.nodes += 1;
184
231
  ctx.lines.push(text.length > 0 && 0 === name.length && 0 === layout.length
@@ -207,10 +254,22 @@ function walk(parent, depth, ctx, inLive = false) {
207
254
  visit(child, depth, ctx, inLive);
208
255
  }
209
256
  }
257
+ /**
258
+ * The APP's open dialogs. Reticle's own panels are excluded, always.
259
+ *
260
+ * The HUD's chat and report panels carry `role="dialog"` because that is the correct role for what
261
+ * they are — but they are OUR surface, not the application's. Once the presenter became visible to
262
+ * the tool surface (so that Reticle can be used to check its own HUD), every snapshot of every page
263
+ * started reporting `visibleDialogs: ["Reticle agent chat"]`, telling the agent a modal was up when
264
+ * the app had none. An agent that believes a dialog is open dismisses it before doing anything else,
265
+ * which is a wasted action at best and a dismissed REAL dialog at worst.
266
+ */
210
267
  function collectDialogs(root) {
211
268
  const nodes = root.querySelectorAll('[role="dialog"], dialog[open], [aria-modal="true"]');
212
269
  const names = [];
213
270
  for (const node of nodes) {
271
+ if (isReticleOverlay(node))
272
+ continue;
214
273
  if (isVisible(node))
215
274
  names.push(getAccessibleName(node) || '(unnamed dialog)');
216
275
  }
@@ -240,6 +299,11 @@ function overlayHidingPage(root) {
240
299
  const dialogs = document.body.querySelectorAll('[role="dialog"], dialog[open], [aria-modal="true"]');
241
300
  let modal;
242
301
  for (const d of dialogs) {
302
+ // Never OUR overlay. Reticle's HUD must not be able to explain the app's absence with itself:
303
+ // that turns "the page did not render" into "an overlay is covering it", which sends the reader
304
+ // to dismiss a panel that was never the problem.
305
+ if (isReticleOverlay(d))
306
+ continue;
243
307
  if (isVisible(d)) {
244
308
  modal = d;
245
309
  break;
@@ -286,6 +350,7 @@ export function buildSnapshot(options = {}) {
286
350
  const ctx = {
287
351
  lines: [],
288
352
  nodes: 0,
353
+ leanSkipped: 0,
289
354
  truncated: false,
290
355
  mode,
291
356
  maxNodes: options.maxNodes ?? 400,
@@ -302,6 +367,7 @@ export function buildSnapshot(options = {}) {
302
367
  status,
303
368
  nodes: ctx.nodes,
304
369
  truncated: ctx.truncated,
370
+ ...(ctx.leanSkipped > 0 ? { leanSkipped: ctx.leanSkipped } : {}),
305
371
  ...(ctx.unread.length > 0 ? { unread: ctx.unread } : {}),
306
372
  ...(ctx.unreadOverflow ? { unreadOverflow: true } : {}),
307
373
  };
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { SESSION_AUTO } from '@reticlehq/core';
6
6
  export { setIgnoreSelectors } from './dom/dom-ignore.js';
7
7
  export { registerAdapter, identifyComponent, readComponentState, elementHasHoverHandlers, adapterNames, } from './registry/adapters.js';
8
8
  export type { ReticleAdapter, ComponentInfo, ComponentSource } from './registry/adapters.js';
9
- export { registerStore, unregisterStore, storeNames, readStores, readStoresWithTruncation, } from './registry/stores.js';
9
+ export { registerStore, unregisterStore, storeNames, readStores, readStoresWithTruncation, sourceOwner, markAdapterSource, } from './registry/stores.js';
10
10
  export type { StoreGetter, StoreLike, StoreSubscribe } from './registry/stores.js';
11
11
  export { tanstackQueryStore, jotaiStore, xstateStore, valtioStore, mobxStore, recoilStore, svelteStore, piniaStore, pushStore, } from './registry/store-adapters.js';
12
12
  export type { QuerySnapshot, RecoilAtomSnapshot } from './registry/store-adapters.js';
package/dist/index.js CHANGED
@@ -14,7 +14,11 @@ export { setIgnoreSelectors } from './dom/dom-ignore.js';
14
14
  // Adapter API (used by @reticlehq/react and other framework adapters).
15
15
  export { registerAdapter, identifyComponent, readComponentState, elementHasHoverHandlers, adapterNames, } from './registry/adapters.js';
16
16
  // Store registry: pull live framework/store state on demand.
17
- export { registerStore, unregisterStore, storeNames, readStores, readStoresWithTruncation, } from './registry/stores.js';
17
+ export { registerStore, unregisterStore, storeNames, readStores, readStoresWithTruncation,
18
+ // Which name a store SOURCE is registered under. The React adapter discovers stores from the fiber
19
+ // tree and needs this to tell "the app already wired this exact store" from "this is another one" —
20
+ // a question neither the name nor the read value can answer.
21
+ sourceOwner, markAdapterSource, } from './registry/stores.js';
18
22
  // Adapters for state libraries that do not natively expose `{getState, subscribe}`. zustand and Redux
19
23
  // need none of these; TanStack Query is the one that matters most, because a stale-cache bug fires no
20
24
  // request and renders a plausible value, so the cache is the only witness.
@@ -3,6 +3,8 @@ import { refs } from '../dom/refs.js';
3
3
  import { isReticleOverlay } from '../dom/dom-ignore.js';
4
4
  /** Observe CSS animations + transitions and emit anim.start / anim.end. */
5
5
  export function installAnimation(emit) {
6
+ const ac = new AbortController();
7
+ const { signal } = ac;
6
8
  const onStart = (event) => {
7
9
  const target = event.target;
8
10
  // Skip Reticle's own HUD keyframes (reticle-pulse/reticle-shimmer/…) so observe/record never
@@ -23,12 +25,8 @@ export function installAnimation(emit) {
23
25
  emit(EventType.ANIM_END, { name: event.propertyName, kind: 'transition' }, refs.refFor(target));
24
26
  }
25
27
  };
26
- document.addEventListener('animationstart', onStart, true);
27
- document.addEventListener('animationend', onEnd, true);
28
- document.addEventListener('transitionend', onTransitionEnd, true);
29
- return () => {
30
- document.removeEventListener('animationstart', onStart, true);
31
- document.removeEventListener('animationend', onEnd, true);
32
- document.removeEventListener('transitionend', onTransitionEnd, true);
33
- };
28
+ document.addEventListener('animationstart', onStart, { capture: true, signal });
29
+ document.addEventListener('animationend', onEnd, { capture: true, signal });
30
+ document.addEventListener('transitionend', onTransitionEnd, { capture: true, signal });
31
+ return () => ac.abort();
34
32
  }
@@ -17,6 +17,8 @@ function toBody(el) {
17
17
  * FOCUS_CHANGE {to, from, toBody}. Reversible.
18
18
  */
19
19
  export function installFocus(emit) {
20
+ const ac = new AbortController();
21
+ const { signal } = ac;
20
22
  let last = null;
21
23
  const onFocusIn = (event) => {
22
24
  const to = event.target;
@@ -39,10 +41,7 @@ export function installFocus(emit) {
39
41
  toBody: true,
40
42
  });
41
43
  };
42
- document.addEventListener('focusin', onFocusIn);
43
- document.addEventListener('focusout', onFocusOut);
44
- return () => {
45
- document.removeEventListener('focusin', onFocusIn);
46
- document.removeEventListener('focusout', onFocusOut);
47
- };
44
+ document.addEventListener('focusin', onFocusIn, { signal });
45
+ document.addEventListener('focusout', onFocusOut, { signal });
46
+ return () => ac.abort();
48
47
  }
@@ -109,21 +109,18 @@ function snapshotHealth() {
109
109
  * tab blind. Uses a native (pre-bound) timer so a frozen app clock (reticle_clock) never stalls it.
110
110
  */
111
111
  export function installHealth(emit) {
112
+ const ac = new AbortController();
113
+ const { signal } = ac;
112
114
  const report = (reason) => {
113
115
  emit(EventType.PAGE_HEALTH, { ...snapshotHealth(), reason });
114
116
  };
115
- const onVisibility = () => report(HealthReason.VISIBILITY);
116
- const onFocus = () => report(HealthReason.FOCUS);
117
- const onBlur = () => report(HealthReason.BLUR);
118
- document.addEventListener('visibilitychange', onVisibility);
119
- window.addEventListener('focus', onFocus);
120
- window.addEventListener('blur', onBlur);
117
+ document.addEventListener('visibilitychange', () => report(HealthReason.VISIBILITY), { signal });
118
+ window.addEventListener('focus', () => report(HealthReason.FOCUS), { signal });
119
+ window.addEventListener('blur', () => report(HealthReason.BLUR), { signal });
121
120
  report(HealthReason.INITIAL); // baseline so the server knows state before the first change
122
121
  const stopHeartbeat = nativeSetInterval(() => report(HealthReason.HEARTBEAT), SESSION_HEALTH.HEARTBEAT_MS);
123
122
  return () => {
124
123
  stopHeartbeat();
125
- document.removeEventListener('visibilitychange', onVisibility);
126
- window.removeEventListener('focus', onFocus);
127
- window.removeEventListener('blur', onBlur);
124
+ ac.abort();
128
125
  };
129
126
  }
@@ -5,3 +5,16 @@
5
5
  * The hash is preserved and the URL is returned byte-for-byte when nothing matched.
6
6
  */
7
7
  export declare function redactUrl(raw: string): string;
8
+ /**
9
+ * Displayed URL plus, when redaction rewrote it, the raw request for graders.
10
+ *
11
+ * `url` is what the agent reads. `urlRaw` exists only so `urlContains` can still match a public
12
+ * path segment that the heuristic rewrote (`/auth/token/refresh-context`). Omitted when nothing
13
+ * changed, so an ordinary request pays nothing.
14
+ */
15
+ export declare function netUrlFields(raw: string): {
16
+ url: string;
17
+ } | {
18
+ url: string;
19
+ urlRaw: string;
20
+ };
@@ -1,4 +1,4 @@
1
- import { REDACTED_VALUE } from '@reticlehq/core';
1
+ import { REDACTED_VALUE, URL_RAW } from '@reticlehq/core';
2
2
  import { isSensitiveKey } from '../security/serialization.js';
3
3
  /** A path segment name that is typically followed by a single-use secret token in the NEXT segment. */
4
4
  const SENSITIVE_PATH_SEGMENT = /^(reset|verify|verification|confirm|activate|invite|magic|magiclink|token|key|oauth|unsubscribe|password)$/i;
@@ -67,3 +67,14 @@ export function redactUrl(raw) {
67
67
  const queryOut = -1 === queryStart ? '' : `?${newQuery}`;
68
68
  return `${segments.join('/')}${queryOut}${newHash}`;
69
69
  }
70
+ /**
71
+ * Displayed URL plus, when redaction rewrote it, the raw request for graders.
72
+ *
73
+ * `url` is what the agent reads. `urlRaw` exists only so `urlContains` can still match a public
74
+ * path segment that the heuristic rewrote (`/auth/token/refresh-context`). Omitted when nothing
75
+ * changed, so an ordinary request pays nothing.
76
+ */
77
+ export function netUrlFields(raw) {
78
+ const url = redactUrl(raw);
79
+ return url === raw ? { url } : { url, [URL_RAW]: raw };
80
+ }
@@ -1,6 +1,6 @@
1
1
  import type { Emit, Teardown } from './types.js';
2
- import { redactUrl } from './network-redact.js';
3
- export { redactUrl };
2
+ import { redactUrl, netUrlFields } from './network-redact.js';
3
+ export { redactUrl, netUrlFields };
4
4
  /** Config for the network observer. Body capture is OFF by default and dev-only opt-in. */
5
5
  /**
6
6
  * Reinterpret a completed request's fields from its response.
@@ -1,12 +1,12 @@
1
1
  import { BlindSpotKind, EventType, RETICLE_WS_PATH, StreamTransport, StreamDirection, } from '@reticlehq/core';
2
2
  import { captureMethod } from '../patching/capture-method.js';
3
3
  import { isCapturableType, projectBody, withBodyDeadline } from './network-body.js';
4
- import { redactUrl } from './network-redact.js';
4
+ import { redactUrl, netUrlFields } from './network-redact.js';
5
5
  import { watchStreamedBody } from './network-stream.js';
6
6
  import { requireCapturedMethod } from '../util/captured-method.js';
7
7
  // Redaction moved to its own cohesive module (network.ts is at its line cap); re-exported so callers
8
8
  // and the existing test suite keep importing it from here.
9
- export { redactUrl };
9
+ export { redactUrl, netUrlFields };
10
10
  /** The byte size of a binary frame (ArrayBuffer / Blob / typed-array view), or undefined if unknown. */
11
11
  function binaryFrameBytes(data) {
12
12
  if (data instanceof ArrayBuffer)
@@ -244,10 +244,17 @@ export function installNetwork(emit, opts = {}) {
244
244
  const id = nextId();
245
245
  const start = performance.now();
246
246
  const method = methodOf(input, init);
247
- const url = redactUrl(rawUrl);
247
+ const urlFields = netUrlFields(rawUrl);
248
+ const url = urlFields.url;
248
249
  const initiatorStack = initiatorFrame();
249
250
  const initiatorFields = initiatorStack === undefined ? {} : { initiatorStack };
250
- emit(EventType.NET_PENDING, { id, method, url, initiator: 'fetch', ...initiatorFields });
251
+ emit(EventType.NET_PENDING, {
252
+ id,
253
+ method,
254
+ ...urlFields,
255
+ initiator: 'fetch',
256
+ ...initiatorFields,
257
+ });
251
258
  try {
252
259
  const res = await callFetch(input, init);
253
260
  // The app's fetch resolves HERE — at headers — like a native fetch. durationMs is measured to
@@ -261,7 +268,7 @@ export function installNetwork(emit, opts = {}) {
261
268
  emit(EventType.NET_REQUEST, {
262
269
  id,
263
270
  method,
264
- url,
271
+ ...urlFields,
265
272
  status: res.status,
266
273
  ok: statusIsOk(res.status),
267
274
  durationMs: Math.round(headersAt - start),
@@ -376,7 +383,7 @@ export function installNetwork(emit, opts = {}) {
376
383
  emit(EventType.NET_PENDING, {
377
384
  id: m.id,
378
385
  method: m.method,
379
- url: m.url,
386
+ ...netUrlFields(m.rawUrl),
380
387
  initiator: 'xhr',
381
388
  ...initiatorFields,
382
389
  });
@@ -405,7 +412,7 @@ export function installNetwork(emit, opts = {}) {
405
412
  emit(EventType.NET_REQUEST, {
406
413
  id: cur.id,
407
414
  method: cur.method,
408
- url: cur.url,
415
+ ...netUrlFields(cur.rawUrl),
409
416
  status: this.status,
410
417
  ok: statusIsOk(this.status),
411
418
  durationMs: Math.round(performance.now() - cur.start),
@@ -432,17 +439,17 @@ export function installNetwork(emit, opts = {}) {
432
439
  window.EventSource = class extends origEventSource {
433
440
  constructor(u, init) {
434
441
  super(u, init);
435
- const url = redactUrl(String(u));
442
+ const urlFields = netUrlFields(String(u));
436
443
  emit(EventType.NET_STREAM, {
437
444
  transport: StreamTransport.SSE,
438
445
  direction: StreamDirection.OPEN,
439
- url,
446
+ ...urlFields,
440
447
  });
441
448
  this.addEventListener('message', (ev) => {
442
449
  emit(EventType.NET_STREAM, {
443
450
  transport: StreamTransport.SSE,
444
451
  direction: StreamDirection.IN,
445
- url,
452
+ ...urlFields,
446
453
  ...frameFields(ev.data, captureBodies),
447
454
  });
448
455
  });
@@ -459,17 +466,17 @@ export function installNetwork(emit, opts = {}) {
459
466
  this.#isBridge = isBridgeSocket(String(u));
460
467
  if (this.#isBridge)
461
468
  return;
462
- const url = redactUrl(String(u));
469
+ const urlFields = netUrlFields(String(u));
463
470
  emit(EventType.NET_STREAM, {
464
471
  transport: StreamTransport.WS,
465
472
  direction: StreamDirection.OPEN,
466
- url,
473
+ ...urlFields,
467
474
  });
468
475
  this.addEventListener('message', (ev) => {
469
476
  emit(EventType.NET_STREAM, {
470
477
  transport: StreamTransport.WS,
471
478
  direction: StreamDirection.IN,
472
- url,
479
+ ...urlFields,
473
480
  ...frameFields(ev.data, captureBodies),
474
481
  });
475
482
  });
@@ -486,7 +493,7 @@ export function installNetwork(emit, opts = {}) {
486
493
  emit(EventType.NET_STREAM, {
487
494
  transport: StreamTransport.WS,
488
495
  direction: StreamDirection.OUT,
489
- url: redactUrl(this.url),
496
+ ...netUrlFields(this.url),
490
497
  ...frameFields(data, captureBodies),
491
498
  });
492
499
  super.send(data);
@@ -504,7 +511,7 @@ export function installNetwork(emit, opts = {}) {
504
511
  if (navProto !== null && origBeacon !== undefined) {
505
512
  patchedBeacon = function (url, data) {
506
513
  const id = nextId();
507
- const redacted = redactUrl(String(url));
514
+ const urlFields = netUrlFields(String(url));
508
515
  const initiatorStack = initiatorFrame();
509
516
  const sent = origBeacon.call(this, url, data);
510
517
  // sendBeacon returns whether the payload was QUEUED, not an HTTP result — the response never
@@ -513,7 +520,7 @@ export function installNetwork(emit, opts = {}) {
513
520
  emit(EventType.NET_REQUEST, {
514
521
  id,
515
522
  method: 'POST',
516
- url: redacted,
523
+ ...urlFields,
517
524
  status: 0,
518
525
  ok: sent,
519
526
  queued: sent,
@@ -558,7 +565,7 @@ export function installNetwork(emit, opts = {}) {
558
565
  emit(EventType.NET_REQUEST, {
559
566
  id: nextId(),
560
567
  method: 'GET',
561
- url: redactUrl(rawUrl),
568
+ ...netUrlFields(rawUrl),
562
569
  durationMs: Math.round(entry.duration),
563
570
  initiator: type,
564
571
  ...(entry.transferSize > 0 ? { transferSize: entry.transferSize } : {}),