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