@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.
- package/dist/actions/actions.js +6 -6
- package/dist/connect-options.d.ts +18 -1
- package/dist/dom/dom-ignore.d.ts +9 -0
- package/dist/dom/dom-ignore.js +32 -3
- package/dist/dom/query.js +55 -3
- package/dist/dom/snapshot.d.ts +8 -0
- package/dist/dom/snapshot.js +71 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -1
- package/dist/observers/animation.js +6 -8
- package/dist/observers/focus.js +5 -6
- package/dist/observers/health.js +6 -9
- package/dist/observers/network-redact.d.ts +13 -0
- package/dist/observers/network-redact.js +12 -1
- package/dist/observers/network.d.ts +2 -2
- package/dist/observers/network.js +24 -17
- package/dist/observers/scroll.js +4 -2
- package/dist/presenter/presenter-config.d.ts +11 -2
- package/dist/presenter/presenter-config.js +9 -0
- package/dist/presenter/presenter-hud-chrome.d.ts +1 -1
- package/dist/presenter/presenter-report-copy.d.ts +30 -1
- package/dist/presenter/presenter-report-copy.js +31 -2
- package/dist/presenter/presenter-report-styles.js +24 -0
- package/dist/presenter/presenter-report.d.ts +2 -2
- package/dist/presenter/presenter-report.js +95 -3
- package/dist/presenter/presenter-shell-styles.js +20 -1
- package/dist/presenter/presenter-shell.js +0 -1
- package/dist/presenter/presenter.d.ts +13 -0
- package/dist/presenter/presenter.js +37 -2
- package/dist/registry/auto-testids.d.ts +21 -0
- package/dist/registry/auto-testids.js +48 -0
- package/dist/registry/capabilities.d.ts +28 -1
- package/dist/registry/capabilities.js +59 -7
- package/dist/registry/store-adapters.d.ts +1 -1
- package/dist/registry/store-adapters.js +6 -1
- package/dist/registry/stores.d.ts +4 -0
- package/dist/registry/stores.js +35 -0
- package/dist/reticle.js +8 -0
- package/dist/transport/unreachable-message.d.ts +7 -0
- package/dist/transport/unreachable-message.js +9 -0
- package/package.json +4 -4
package/dist/actions/actions.js
CHANGED
|
@@ -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
|
|
329
|
-
|
|
330
|
-
|
|
328
|
+
const sourceDangerous = requiresDangerousConfirmation(dangerousActionContext(el), getRole(el));
|
|
329
|
+
const targetDangerous = isHtmlElement(dragTarget) &&
|
|
330
|
+
requiresDangerousConfirmation(dangerousActionContext(dragTarget), getRole(dragTarget));
|
|
331
331
|
if (canTrigger &&
|
|
332
|
-
|
|
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
|
-
/**
|
|
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
|
*
|
package/dist/dom/dom-ignore.d.ts
CHANGED
|
@@ -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. */
|
package/dist/dom/dom-ignore.js
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/dist/dom/snapshot.d.ts
CHANGED
|
@@ -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
|
package/dist/dom/snapshot.js
CHANGED
|
@@ -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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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,
|
|
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
|
}
|
package/dist/observers/focus.js
CHANGED
|
@@ -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
|
}
|
package/dist/observers/health.js
CHANGED
|
@@ -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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
442
|
+
const urlFields = netUrlFields(String(u));
|
|
436
443
|
emit(EventType.NET_STREAM, {
|
|
437
444
|
transport: StreamTransport.SSE,
|
|
438
445
|
direction: StreamDirection.OPEN,
|
|
439
|
-
|
|
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
|
-
|
|
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
|
|
469
|
+
const urlFields = netUrlFields(String(u));
|
|
463
470
|
emit(EventType.NET_STREAM, {
|
|
464
471
|
transport: StreamTransport.WS,
|
|
465
472
|
direction: StreamDirection.OPEN,
|
|
466
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
568
|
+
...netUrlFields(rawUrl),
|
|
562
569
|
durationMs: Math.round(entry.duration),
|
|
563
570
|
initiator: type,
|
|
564
571
|
...(entry.transferSize > 0 ? { transferSize: entry.transferSize } : {}),
|