@reticlehq/browser 2.11.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-dom.js +41 -3
- package/dist/actions/actions.js +43 -11
- package/dist/commands/commands.js +6 -2
- package/dist/connect-options.d.ts +18 -1
- package/dist/dom/a11y.js +92 -9
- package/dist/dom/dom-ignore.d.ts +9 -0
- package/dist/dom/dom-ignore.js +32 -3
- package/dist/dom/query.js +168 -97
- package/dist/dom/refs.d.ts +11 -0
- package/dist/dom/refs.js +30 -1
- package/dist/dom/snapshot.d.ts +37 -0
- package/dist/dom/snapshot.js +141 -37
- package/dist/edit-epoch.d.ts +32 -0
- package/dist/edit-epoch.js +108 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -1
- package/dist/observers/animation.js +6 -8
- package/dist/observers/context-open.d.ts +12 -0
- package/dist/observers/context-open.js +25 -0
- package/dist/observers/focus.js +5 -6
- package/dist/observers/health.js +6 -9
- package/dist/observers/install-all.js +2 -0
- 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/observers/storage.js +1 -1
- package/dist/presenter/presenter-brand.d.ts +4 -18
- package/dist/presenter/presenter-brand.js +6 -18
- package/dist/presenter/presenter-config.d.ts +11 -8
- package/dist/presenter/presenter-config.js +9 -3
- package/dist/presenter/presenter-controls.d.ts +0 -3
- package/dist/presenter/presenter-controls.js +0 -3
- 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-settings.js +10 -2
- package/dist/presenter/presenter-shell-styles.js +20 -1
- package/dist/presenter/presenter-shell.d.ts +0 -2
- package/dist/presenter/presenter-shell.js +1 -6
- 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.d.ts +12 -0
- package/dist/reticle.js +40 -1
- package/dist/transport/unreachable-message.d.ts +7 -0
- package/dist/transport/unreachable-message.js +11 -1
- package/package.json +4 -7
- package/dist/dom/blind-spots.d.ts +0 -13
- package/dist/dom/blind-spots.js +0 -29
- package/dist/observers/subresources.d.ts +0 -10
- package/dist/observers/subresources.js +0 -63
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',
|
|
@@ -55,6 +55,16 @@ function directText(el) {
|
|
|
55
55
|
const collapsed = out.replace(/\s+/g, ' ').trim();
|
|
56
56
|
return collapsed.length > TEXT_MAX ? `${collapsed.slice(0, TEXT_MAX)}…` : collapsed;
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Cap on how many cut branches one snapshot names.
|
|
60
|
+
*
|
|
61
|
+
* A hostile or merely huge DOM can cut thousands of siblings at once (a non-virtualized grid does it
|
|
62
|
+
* routinely), and a frontier that large is neither affordable to send nor useful to re-read one at a
|
|
63
|
+
* time. Fifty is well above what a real page's cut produces — the frontier is the branches at the
|
|
64
|
+
* node cap, not the nodes themselves — and small enough that the list stays a hint rather than a
|
|
65
|
+
* second snapshot. Past it the read declares `unreadOverflow` instead of quietly sending fewer.
|
|
66
|
+
*/
|
|
67
|
+
export const MAX_UNREAD_BRANCHES = 50;
|
|
58
68
|
/** Cheap skips that need no computed style (tags, overlays, aria-hidden, [hidden]). */
|
|
59
69
|
function skipEarly(el) {
|
|
60
70
|
if (SKIP_TAGS.has(el.tagName.toLowerCase()))
|
|
@@ -77,8 +87,12 @@ function stateSuffix(el) {
|
|
|
77
87
|
function formatLine(el, depth, role, name, layout) {
|
|
78
88
|
const indent = ' '.repeat(depth);
|
|
79
89
|
const value = getValue(el);
|
|
80
|
-
|
|
81
|
-
|
|
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)})` : '';
|
|
82
96
|
const valuePart = value !== undefined && value.length > 0 ? ` [value="${value}"]` : '';
|
|
83
97
|
const layoutPart = layout.length > 0 ? ` [${layout}]` : '';
|
|
84
98
|
return `${indent}- ${role}${namePart}${refPart}${valuePart}${layoutPart}${stateSuffix(el)}`;
|
|
@@ -105,6 +119,16 @@ function layoutSignature(style) {
|
|
|
105
119
|
}
|
|
106
120
|
return '';
|
|
107
121
|
}
|
|
122
|
+
/** Record a cut branch on the frontier, declaring an overflow rather than shortening it silently. */
|
|
123
|
+
function recordUnread(branches, ctx) {
|
|
124
|
+
for (const branch of branches) {
|
|
125
|
+
if (ctx.unread.length >= MAX_UNREAD_BRANCHES) {
|
|
126
|
+
ctx.unreadOverflow = true;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
ctx.unread.push(refs.refFor(branch));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
108
132
|
/**
|
|
109
133
|
* The children to walk under `parent`, piercing boundaries a plain `.children` misses: an OPEN shadow
|
|
110
134
|
* root renders as part of the element (web components / design systems), and a SAME-ORIGIN iframe's
|
|
@@ -134,52 +158,118 @@ function pierceChildren(parent) {
|
|
|
134
158
|
}
|
|
135
159
|
return out;
|
|
136
160
|
}
|
|
161
|
+
/** Emit one element (if it earns a line) and descend into it. Split out of `walk` so the completion
|
|
162
|
+
* re-read can start AT a branch root — the node the truncated walk stopped just before emitting. */
|
|
163
|
+
function visit(child, depth, ctx, inLive) {
|
|
164
|
+
if (skipEarly(child))
|
|
165
|
+
return;
|
|
166
|
+
// Resolve computed style ONCE per node (the dominant snapshot cost) and thread it into both the
|
|
167
|
+
// display-none skip and the layout signature — was two forced style resolutions per node.
|
|
168
|
+
const view = child.ownerDocument.defaultView;
|
|
169
|
+
const style = view !== null ? view.getComputedStyle(child) : null;
|
|
170
|
+
if (style !== null && 'none' === style.display)
|
|
171
|
+
return;
|
|
172
|
+
const role = getRole(child);
|
|
173
|
+
const name = getAccessibleName(child);
|
|
174
|
+
const interactive = INTERACTIVE.has(role);
|
|
175
|
+
// Announcements are exempt from leanness, and so is everything inside one: a live region whose
|
|
176
|
+
// message sits in a child element would otherwise be included as a contentless `- generic`.
|
|
177
|
+
const announce = inLive || announces(child, role);
|
|
178
|
+
const lean = ctx.mode === SnapshotMode.INTERACTIVE && !announce;
|
|
179
|
+
// A generic, unnamed container's own text content — only consulted outside INTERACTIVE mode,
|
|
180
|
+
// so the actionable-only view stays lean while FULL/meaningful views see content regressions.
|
|
181
|
+
const text = !lean && 'generic' === role && 0 === name.length ? directText(child) : '';
|
|
182
|
+
// Layout signature for grid/flex containers — makes CLS/layout regressions visible.
|
|
183
|
+
const layout = lean ? '' : layoutSignature(style);
|
|
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;
|
|
229
|
+
if (include) {
|
|
230
|
+
ctx.nodes += 1;
|
|
231
|
+
ctx.lines.push(text.length > 0 && 0 === name.length && 0 === layout.length
|
|
232
|
+
? formatTextLine(depth, text)
|
|
233
|
+
: formatLine(child, depth, role, name, layout));
|
|
234
|
+
walk(child, depth + 1, ctx, announce);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
walk(child, depth, ctx, announce);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
137
240
|
function walk(parent, depth, ctx, inLive = false) {
|
|
138
241
|
if (depth > ctx.maxDepth)
|
|
139
242
|
return;
|
|
140
|
-
|
|
243
|
+
const children = pierceChildren(parent);
|
|
244
|
+
for (let index = 0; index < children.length; index += 1) {
|
|
141
245
|
if (ctx.nodes >= ctx.maxNodes) {
|
|
142
246
|
ctx.truncated = true;
|
|
247
|
+
// Everything from here on is unread, not absent. Naming the frontier is what makes the cut
|
|
248
|
+
// recoverable — see `unread` on SnapshotResult.
|
|
249
|
+
recordUnread(children.slice(index), ctx);
|
|
143
250
|
return;
|
|
144
251
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// display-none skip and the layout signature — was two forced style resolutions per node.
|
|
149
|
-
const view = child.ownerDocument.defaultView;
|
|
150
|
-
const style = view !== null ? view.getComputedStyle(child) : null;
|
|
151
|
-
if (style !== null && 'none' === style.display)
|
|
152
|
-
continue;
|
|
153
|
-
const role = getRole(child);
|
|
154
|
-
const name = getAccessibleName(child);
|
|
155
|
-
const interactive = INTERACTIVE.has(role);
|
|
156
|
-
// Announcements are exempt from leanness, and so is everything inside one: a live region whose
|
|
157
|
-
// message sits in a child element would otherwise be included as a contentless `- generic`.
|
|
158
|
-
const announce = inLive || announces(child, role);
|
|
159
|
-
const lean = ctx.mode === SnapshotMode.INTERACTIVE && !announce;
|
|
160
|
-
// A generic, unnamed container's own text content — only consulted outside INTERACTIVE mode,
|
|
161
|
-
// so the actionable-only view stays lean while FULL/meaningful views see content regressions.
|
|
162
|
-
const text = !lean && 'generic' === role && 0 === name.length ? directText(child) : '';
|
|
163
|
-
// Layout signature for grid/flex containers — makes CLS/layout regressions visible.
|
|
164
|
-
const layout = lean ? '' : layoutSignature(style);
|
|
165
|
-
const meaningful = interactive || role !== 'generic' || name.length > 0 || text.length > 0 || layout.length > 0;
|
|
166
|
-
const include = lean ? interactive : meaningful;
|
|
167
|
-
if (include) {
|
|
168
|
-
ctx.nodes += 1;
|
|
169
|
-
ctx.lines.push(text.length > 0 && 0 === name.length && 0 === layout.length
|
|
170
|
-
? formatTextLine(depth, text)
|
|
171
|
-
: formatLine(child, depth, role, name, layout));
|
|
172
|
-
walk(child, depth + 1, ctx, announce);
|
|
173
|
-
}
|
|
174
|
-
else {
|
|
175
|
-
walk(child, depth, ctx, announce);
|
|
176
|
-
}
|
|
252
|
+
const child = children[index];
|
|
253
|
+
if (child !== undefined)
|
|
254
|
+
visit(child, depth, ctx, inLive);
|
|
177
255
|
}
|
|
178
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
|
+
*/
|
|
179
267
|
function collectDialogs(root) {
|
|
180
268
|
const nodes = root.querySelectorAll('[role="dialog"], dialog[open], [aria-modal="true"]');
|
|
181
269
|
const names = [];
|
|
182
270
|
for (const node of nodes) {
|
|
271
|
+
if (isReticleOverlay(node))
|
|
272
|
+
continue;
|
|
183
273
|
if (isVisible(node))
|
|
184
274
|
names.push(getAccessibleName(node) || '(unnamed dialog)');
|
|
185
275
|
}
|
|
@@ -209,6 +299,11 @@ function overlayHidingPage(root) {
|
|
|
209
299
|
const dialogs = document.body.querySelectorAll('[role="dialog"], dialog[open], [aria-modal="true"]');
|
|
210
300
|
let modal;
|
|
211
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;
|
|
212
307
|
if (isVisible(d)) {
|
|
213
308
|
modal = d;
|
|
214
309
|
break;
|
|
@@ -255,16 +350,25 @@ export function buildSnapshot(options = {}) {
|
|
|
255
350
|
const ctx = {
|
|
256
351
|
lines: [],
|
|
257
352
|
nodes: 0,
|
|
353
|
+
leanSkipped: 0,
|
|
258
354
|
truncated: false,
|
|
259
355
|
mode,
|
|
260
356
|
maxNodes: options.maxNodes ?? 400,
|
|
261
357
|
maxDepth: options.maxDepth ?? 20,
|
|
358
|
+
unread: [],
|
|
359
|
+
unreadOverflow: false,
|
|
262
360
|
};
|
|
263
|
-
|
|
361
|
+
if (true === options.includeRoot)
|
|
362
|
+
visit(root, 0, ctx, false);
|
|
363
|
+
else
|
|
364
|
+
walk(root, 0, ctx);
|
|
264
365
|
return {
|
|
265
366
|
tree: ctx.lines.join('\n'),
|
|
266
367
|
status,
|
|
267
368
|
nodes: ctx.nodes,
|
|
268
369
|
truncated: ctx.truncated,
|
|
370
|
+
...(ctx.leanSkipped > 0 ? { leanSkipped: ctx.leanSkipped } : {}),
|
|
371
|
+
...(ctx.unread.length > 0 ? { unread: ctx.unread } : {}),
|
|
372
|
+
...(ctx.unreadOverflow ? { unreadOverflow: true } : {}),
|
|
269
373
|
};
|
|
270
374
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type RefRegistry } from './dom/refs.js';
|
|
2
|
+
export declare class EditEpoch {
|
|
3
|
+
#private;
|
|
4
|
+
/** The registry is injected for the same reason the clock is: the interesting property is the
|
|
5
|
+
* RELATION between when a ref was minted and when an edit landed, and a global has no seam. */
|
|
6
|
+
constructor(registry: RefRegistry);
|
|
7
|
+
/**
|
|
8
|
+
* How many hot updates this document has applied.
|
|
9
|
+
*
|
|
10
|
+
* `NO_EDITS_OBSERVED` means exactly that and never "none happened": most pages Reticle instruments
|
|
11
|
+
* have no channel that could report one, so absence here is UNKNOWN.
|
|
12
|
+
*/
|
|
13
|
+
get current(): number;
|
|
14
|
+
/** Subscribe to a hot-update channel if one was handed in. Anything else is silently ignored —
|
|
15
|
+
* no channel is the normal case, not an error. */
|
|
16
|
+
observe(hot: unknown): void;
|
|
17
|
+
/** Record that an update was applied, naming the files it changed (which may be none). */
|
|
18
|
+
applied(files: readonly string[]): void;
|
|
19
|
+
/**
|
|
20
|
+
* The refusal for a ref that no longer resolves — saying WHY when the page can tell us.
|
|
21
|
+
*
|
|
22
|
+
* The generic wording is untouched and stays at the front: it is already tuned, and the server's
|
|
23
|
+
* recovery table matches on it. When the ref predates the last hot update the diagnosis is
|
|
24
|
+
* appended, because "you edited TripCard.tsx and it re-rendered" is a next step and "that ref is
|
|
25
|
+
* stale" is a dead end. A ref minted AFTER the last update gets the generic message unchanged —
|
|
26
|
+
* blaming an edit for an ordinary post-click stale ref would be a confident wrong answer, which is
|
|
27
|
+
* worse than the vague right one.
|
|
28
|
+
*/
|
|
29
|
+
staleRefMessage(ref: string): string;
|
|
30
|
+
}
|
|
31
|
+
/** The epoch of the page, paired with the process-wide ref registry it has to reason against. */
|
|
32
|
+
export declare const editEpoch: EditEpoch;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { NO_EDITS_OBSERVED } from '@reticlehq/core';
|
|
2
|
+
import { echoRef, refs } from './dom/refs.js';
|
|
3
|
+
/**
|
|
4
|
+
* What the page can tell us about its own source being edited underneath it.
|
|
5
|
+
*
|
|
6
|
+
* The agent driving Reticle edits source and re-verifies in a loop. When an edit lands, the dev
|
|
7
|
+
* server hot-updates the module and the framework re-renders: DOM nodes are REPLACED, every ref the
|
|
8
|
+
* agent holds points at a detached node — and there was no navigation, so the document is the same
|
|
9
|
+
* one and `documentId` never moves. The agent got a generic "that ref is stale" and could not tell
|
|
10
|
+
* its own edit landing from a bug in the app it was verifying.
|
|
11
|
+
*
|
|
12
|
+
* DELIBERATELY NOT A BUILD INTEGRATION. Nothing here watches files, reads the user's repo or knows
|
|
13
|
+
* what a module graph is. The page is told by its own dev server that it hot-updated; this records
|
|
14
|
+
* that it happened and what it named, and that is the whole feature.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Vite's "the update has been applied" event, on a hot context.
|
|
18
|
+
*
|
|
19
|
+
* `vite:afterUpdate` rather than `vite:beforeUpdate`: the point of the epoch is that the DOM has
|
|
20
|
+
* been replaced, and before the update it has not been yet.
|
|
21
|
+
*
|
|
22
|
+
* The SDK does NOT depend on Vite — it ships to Next, Electron, Tauri and plain pages, and it cannot
|
|
23
|
+
* read `import.meta.hot` of its own module in any of them (a dependency is pre-bundled by Vite's
|
|
24
|
+
* optimizer and gets no hot context at all). The channel is handed IN by whoever has one, and this
|
|
25
|
+
* name is the only Vite-shaped thing in the SDK.
|
|
26
|
+
*/
|
|
27
|
+
const HOT_UPDATE_APPLIED = 'vite:afterUpdate';
|
|
28
|
+
/** How many changed files a refusal names before it stops being a diagnosis and starts being a list. */
|
|
29
|
+
const MAX_NAMED_FILES = 3;
|
|
30
|
+
/** The exact substring the server's recovery table keys off. Changing it silently unclassifies. */
|
|
31
|
+
const STALE_REF_REFUSAL = 'no longer resolves to an element';
|
|
32
|
+
/** The clause that turns a dead end into a diagnosis, and the one the server's table matches on. */
|
|
33
|
+
const CODE_CHANGED = 'the code changed underneath it';
|
|
34
|
+
/** Anything object-shaped, as a bag of unknowns. The payload comes from the page, so nothing about
|
|
35
|
+
* its shape is guaranteed and every field has to be checked before it is read. */
|
|
36
|
+
function fields(value) {
|
|
37
|
+
return null !== value && 'object' === typeof value
|
|
38
|
+
? value
|
|
39
|
+
: undefined;
|
|
40
|
+
}
|
|
41
|
+
function isHotLike(value) {
|
|
42
|
+
return 'function' === typeof fields(value)?.['on'];
|
|
43
|
+
}
|
|
44
|
+
/** The file paths a hot-update payload named, narrowed from `unknown`. */
|
|
45
|
+
function updatedPaths(payload) {
|
|
46
|
+
const updates = fields(payload)?.['updates'];
|
|
47
|
+
if (!Array.isArray(updates))
|
|
48
|
+
return [];
|
|
49
|
+
const paths = [];
|
|
50
|
+
for (const update of updates) {
|
|
51
|
+
const path = fields(update)?.['path'];
|
|
52
|
+
if ('string' === typeof path && path.length > 0 && !paths.includes(path))
|
|
53
|
+
paths.push(path);
|
|
54
|
+
}
|
|
55
|
+
return paths;
|
|
56
|
+
}
|
|
57
|
+
export class EditEpoch {
|
|
58
|
+
#epoch = NO_EDITS_OBSERVED;
|
|
59
|
+
#files = [];
|
|
60
|
+
#refs;
|
|
61
|
+
/** The registry is injected for the same reason the clock is: the interesting property is the
|
|
62
|
+
* RELATION between when a ref was minted and when an edit landed, and a global has no seam. */
|
|
63
|
+
constructor(registry) {
|
|
64
|
+
this.#refs = registry;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* How many hot updates this document has applied.
|
|
68
|
+
*
|
|
69
|
+
* `NO_EDITS_OBSERVED` means exactly that and never "none happened": most pages Reticle instruments
|
|
70
|
+
* have no channel that could report one, so absence here is UNKNOWN.
|
|
71
|
+
*/
|
|
72
|
+
get current() {
|
|
73
|
+
return this.#epoch;
|
|
74
|
+
}
|
|
75
|
+
/** Subscribe to a hot-update channel if one was handed in. Anything else is silently ignored —
|
|
76
|
+
* no channel is the normal case, not an error. */
|
|
77
|
+
observe(hot) {
|
|
78
|
+
if (!isHotLike(hot))
|
|
79
|
+
return;
|
|
80
|
+
hot.on(HOT_UPDATE_APPLIED, (payload) => this.applied(updatedPaths(payload)));
|
|
81
|
+
}
|
|
82
|
+
/** Record that an update was applied, naming the files it changed (which may be none). */
|
|
83
|
+
applied(files) {
|
|
84
|
+
this.#epoch += 1;
|
|
85
|
+
this.#files = files.slice(0, MAX_NAMED_FILES);
|
|
86
|
+
this.#refs.markEdited();
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The refusal for a ref that no longer resolves — saying WHY when the page can tell us.
|
|
90
|
+
*
|
|
91
|
+
* The generic wording is untouched and stays at the front: it is already tuned, and the server's
|
|
92
|
+
* recovery table matches on it. When the ref predates the last hot update the diagnosis is
|
|
93
|
+
* appended, because "you edited TripCard.tsx and it re-rendered" is a next step and "that ref is
|
|
94
|
+
* stale" is a dead end. A ref minted AFTER the last update gets the generic message unchanged —
|
|
95
|
+
* blaming an edit for an ordinary post-click stale ref would be a confident wrong answer, which is
|
|
96
|
+
* worse than the vague right one.
|
|
97
|
+
*/
|
|
98
|
+
staleRefMessage(ref) {
|
|
99
|
+
const generic = `ref '${echoRef(ref)}' ${STALE_REF_REFUSAL}`;
|
|
100
|
+
if (!this.#refs.mintedBeforeLastEdit(ref))
|
|
101
|
+
return generic;
|
|
102
|
+
const named = 0 === this.#files.length ? 'the page hot-updated' : `${this.#files.join(', ')} hot-updated`;
|
|
103
|
+
return (`${generic} — ${named} after this ref was taken, so ${CODE_CHANGED} and the framework ` +
|
|
104
|
+
'replaced the node. Query again for a fresh ref: this is your own edit landing, not the app failing.');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** The epoch of the page, paired with the process-wide ref registry it has to reason against. */
|
|
108
|
+
export const editEpoch = new EditEpoch(refs);
|
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
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Emit, Teardown } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The page asked for ANOTHER browsing context — `window.open` — so the consequence of whatever was
|
|
4
|
+
* just clicked may live where this document's SDK cannot follow. An OAuth sign-in is the archetype:
|
|
5
|
+
* the POST succeeds, the popup carries the whole flow, and the original tab legitimately never
|
|
6
|
+
* changes (#508). Without this event the contradiction layer reads that shape as response-ignored,
|
|
7
|
+
* accusing the client of ignoring a response it handed to a window nobody can see.
|
|
8
|
+
*
|
|
9
|
+
* The wrap preserves the call exactly: same arguments, same return value, and a null/undefined
|
|
10
|
+
* first argument (the "open a blank tab" form) still emits, since a blank tab can navigate anywhere.
|
|
11
|
+
*/
|
|
12
|
+
export declare function installContextOpen(emit: Emit): Teardown;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { EventType } from '@reticlehq/core';
|
|
2
|
+
import { captureMethod } from '../patching/capture-method.js';
|
|
3
|
+
/**
|
|
4
|
+
* The page asked for ANOTHER browsing context — `window.open` — so the consequence of whatever was
|
|
5
|
+
* just clicked may live where this document's SDK cannot follow. An OAuth sign-in is the archetype:
|
|
6
|
+
* the POST succeeds, the popup carries the whole flow, and the original tab legitimately never
|
|
7
|
+
* changes (#508). Without this event the contradiction layer reads that shape as response-ignored,
|
|
8
|
+
* accusing the client of ignoring a response it handed to a window nobody can see.
|
|
9
|
+
*
|
|
10
|
+
* The wrap preserves the call exactly: same arguments, same return value, and a null/undefined
|
|
11
|
+
* first argument (the "open a blank tab" form) still emits, since a blank tab can navigate anywhere.
|
|
12
|
+
*/
|
|
13
|
+
export function installContextOpen(emit) {
|
|
14
|
+
const originalOpen = captureMethod(window, 'open');
|
|
15
|
+
if (originalOpen === undefined)
|
|
16
|
+
return () => { };
|
|
17
|
+
const openPatch = function (href, target, features) {
|
|
18
|
+
emit(EventType.CONTEXT_OPENED, { ...(href === undefined ? {} : { href }) });
|
|
19
|
+
return originalOpen.call(this, href, target, features);
|
|
20
|
+
};
|
|
21
|
+
window.open = openPatch;
|
|
22
|
+
return () => {
|
|
23
|
+
window.open = originalOpen;
|
|
24
|
+
};
|
|
25
|
+
}
|
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
|
}
|
|
@@ -17,6 +17,7 @@ import { installStoreState } from './state.js';
|
|
|
17
17
|
import { installFocus } from './focus.js';
|
|
18
18
|
import { installBlindSpots } from './blind-spots.js';
|
|
19
19
|
import { installDownload } from './download.js';
|
|
20
|
+
import { installContextOpen } from './context-open.js';
|
|
20
21
|
import { installNetwork } from './network.js';
|
|
21
22
|
import { installIpc, ipcNetOverrides, isReticleOwnIpc } from './ipc.js';
|
|
22
23
|
import { installPerf } from './perf.js';
|
|
@@ -62,5 +63,6 @@ export function installAllObservers(emit, options) {
|
|
|
62
63
|
guard(emit, SdkSite.NETWORK_OBSERVER, () => installDownload(emit, { capturePreview: options.captureBodies })),
|
|
63
64
|
guard(emit, SdkSite.DOM_OBSERVER, () => installBlindSpots(emit)), // cross-origin iframes the SDK can't see → BLIND_SPOT (coverage: partial)
|
|
64
65
|
guard(emit, SdkSite.HEALTH_OBSERVER, () => installHealth(emit)), // page visibility/focus health + heartbeat
|
|
66
|
+
guard(emit, SdkSite.DOM_OBSERVER, () => installContextOpen(emit)), // window.open → CONTEXT_OPENED (the consequence may live in another context)
|
|
65
67
|
];
|
|
66
68
|
}
|
|
@@ -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.
|