@reticlehq/browser 2.13.1 → 2.14.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.d.ts +0 -20
- package/dist/actions/actions-dom.js +0 -66
- package/dist/actions/actions-press.d.ts +10 -0
- package/dist/actions/actions-press.js +77 -0
- package/dist/actions/actions.d.ts +0 -89
- package/dist/actions/actions.js +24 -358
- package/dist/actions/appeared-text.d.ts +0 -20
- package/dist/actions/appeared-text.js +0 -36
- package/dist/actions/danger-context.d.ts +3 -0
- package/dist/actions/danger-context.js +39 -0
- package/dist/actions/scroll.d.ts +0 -9
- package/dist/actions/scroll.js +0 -7
- package/dist/actions/synthetic-input.d.ts +0 -7
- package/dist/actions/synthetic-input.js +0 -27
- package/dist/actions/value-input.d.ts +0 -30
- package/dist/actions/value-input.js +0 -33
- package/dist/commands/commands.d.ts +0 -2
- package/dist/commands/commands.js +0 -67
- package/dist/connect-options.d.ts +2 -96
- package/dist/dom/a11y.d.ts +0 -29
- package/dist/dom/a11y.js +1 -88
- package/dist/dom/auto-anchor.d.ts +0 -40
- package/dist/dom/auto-anchor.js +0 -33
- package/dist/dom/chart.d.ts +0 -61
- package/dist/dom/chart.js +0 -60
- package/dist/dom/desktop-capture.d.ts +0 -30
- package/dist/dom/desktop-capture.js +0 -72
- package/dist/dom/dom-ignore.d.ts +0 -30
- package/dist/dom/dom-ignore.js +0 -47
- package/dist/dom/occlusion.d.ts +0 -7
- package/dist/dom/occlusion.js +0 -7
- package/dist/dom/query.d.ts +0 -14
- package/dist/dom/query.js +48 -214
- package/dist/dom/realm.d.ts +0 -20
- package/dist/dom/realm.js +0 -28
- package/dist/dom/refs.d.ts +0 -76
- package/dist/dom/refs.js +2 -133
- package/dist/dom/shadow-registry.d.ts +0 -33
- package/dist/dom/shadow-registry.js +2 -72
- package/dist/dom/snapshot.d.ts +2 -43
- package/dist/dom/snapshot.js +26 -151
- package/dist/dom/source.d.ts +0 -44
- package/dist/dom/source.js +0 -45
- package/dist/dom/theme.d.ts +0 -37
- package/dist/dom/theme.js +2 -52
- package/dist/dom/virtualized.d.ts +0 -17
- package/dist/dom/virtualized.js +0 -27
- package/dist/edit-epoch.d.ts +0 -22
- package/dist/edit-epoch.js +0 -52
- package/dist/index.js +1 -23
- package/dist/observers/animation.d.ts +0 -1
- package/dist/observers/animation.js +0 -3
- package/dist/observers/blind-spots.d.ts +0 -26
- package/dist/observers/blind-spots.js +2 -61
- package/dist/observers/console.d.ts +1 -2
- package/dist/observers/console.js +41 -30
- package/dist/observers/context-open.d.ts +1 -11
- package/dist/observers/context-open.js +10 -14
- package/dist/observers/departure.d.ts +3 -0
- package/dist/observers/departure.js +52 -0
- package/dist/observers/dialogs.d.ts +2 -0
- package/dist/observers/dialogs.js +39 -0
- package/dist/observers/dom.js +0 -44
- package/dist/observers/download.d.ts +1 -2
- package/dist/observers/download.js +38 -55
- package/dist/observers/focus.d.ts +0 -6
- package/dist/observers/focus.js +0 -7
- package/dist/observers/frames.d.ts +0 -9
- package/dist/observers/frames.js +1 -28
- package/dist/observers/health.d.ts +0 -5
- package/dist/observers/health.js +1 -33
- package/dist/observers/install-all.d.ts +2 -0
- package/dist/observers/install-all.js +26 -27
- package/dist/observers/ipc.d.ts +0 -25
- package/dist/observers/ipc.js +0 -65
- package/dist/observers/navigation.d.ts +2 -0
- package/dist/observers/navigation.js +33 -0
- package/dist/observers/network-body.d.ts +5 -28
- package/dist/observers/network-body.js +11 -108
- package/dist/observers/network-redact.d.ts +1 -20
- package/dist/observers/network-redact.js +1 -80
- package/dist/observers/network-stream.d.ts +0 -21
- package/dist/observers/network-stream.js +1 -44
- package/dist/observers/network.d.ts +2 -26
- package/dist/observers/network.js +177 -294
- package/dist/observers/perf.d.ts +0 -10
- package/dist/observers/perf.js +0 -16
- package/dist/observers/route.d.ts +1 -2
- package/dist/observers/route.js +13 -19
- package/dist/observers/scroll.d.ts +0 -1
- package/dist/observers/scroll.js +0 -9
- package/dist/observers/sdk-failure.d.ts +1 -9
- package/dist/observers/sdk-failure.js +1 -30
- package/dist/observers/state.d.ts +1 -11
- package/dist/observers/state.js +13 -41
- package/dist/observers/storage.d.ts +0 -14
- package/dist/observers/storage.js +10 -34
- package/dist/observers/types.d.ts +0 -15
- package/dist/observers/types.js +0 -14
- package/dist/patching/capture-method.d.ts +0 -24
- package/dist/patching/capture-method.js +0 -26
- package/dist/presenter/overlay.d.ts +0 -1
- package/dist/presenter/overlay.js +0 -1
- package/dist/presenter/presenter-brand.d.ts +0 -19
- package/dist/presenter/presenter-brand.js +0 -21
- package/dist/presenter/presenter-config.d.ts +1 -105
- package/dist/presenter/presenter-config.js +2 -91
- package/dist/presenter/presenter-controls.d.ts +0 -29
- package/dist/presenter/presenter-controls.js +3 -131
- package/dist/presenter/presenter-dock-layout.d.ts +0 -3
- package/dist/presenter/presenter-dock-layout.js +0 -4
- package/dist/presenter/presenter-drag.d.ts +2 -24
- package/dist/presenter/presenter-drag.js +0 -26
- package/dist/presenter/presenter-effects.d.ts +0 -9
- package/dist/presenter/presenter-effects.js +0 -9
- package/dist/presenter/presenter-glow.d.ts +0 -21
- package/dist/presenter/presenter-glow.js +1 -27
- package/dist/presenter/presenter-heroicons-data.d.ts +0 -8
- package/dist/presenter/presenter-heroicons-data.js +0 -8
- package/dist/presenter/presenter-hud-chrome.d.ts +0 -10
- package/dist/presenter/presenter-hud-chrome.js +0 -10
- package/dist/presenter/presenter-icons.d.ts +0 -6
- package/dist/presenter/presenter-icons.js +0 -6
- package/dist/presenter/presenter-log.d.ts +0 -38
- package/dist/presenter/presenter-log.js +1 -45
- package/dist/presenter/presenter-report-copy.d.ts +5 -66
- package/dist/presenter/presenter-report-copy.js +5 -66
- package/dist/presenter/presenter-report-styles.d.ts +0 -6
- package/dist/presenter/presenter-report-styles.js +6 -6
- package/dist/presenter/presenter-report.d.ts +4 -7
- package/dist/presenter/presenter-report.js +18 -76
- package/dist/presenter/presenter-run-state.d.ts +0 -7
- package/dist/presenter/presenter-run-state.js +0 -5
- package/dist/presenter/presenter-settings-styles.d.ts +0 -1
- package/dist/presenter/presenter-settings-styles.js +0 -1
- package/dist/presenter/presenter-settings.d.ts +1 -23
- package/dist/presenter/presenter-settings.js +1 -31
- package/dist/presenter/presenter-shell-styles.js +2 -5
- package/dist/presenter/presenter-shell.d.ts +2 -18
- package/dist/presenter/presenter-shell.js +1 -73
- package/dist/presenter/presenter-styles.d.ts +0 -9
- package/dist/presenter/presenter-styles.js +0 -9
- package/dist/presenter/presenter-tally.d.ts +0 -5
- package/dist/presenter/presenter-tally.js +0 -4
- package/dist/presenter/presenter-test-helpers.d.ts +0 -2
- package/dist/presenter/presenter-test-helpers.js +0 -2
- package/dist/presenter/presenter-verbs.js +0 -7
- package/dist/presenter/presenter-workspace.d.ts +0 -4
- package/dist/presenter/presenter-workspace.js +1 -8
- package/dist/presenter/presenter.d.ts +0 -60
- package/dist/presenter/presenter.js +4 -118
- package/dist/recorder/recorder-styles.d.ts +0 -6
- package/dist/recorder/recorder-styles.js +0 -6
- package/dist/recorder/recorder.d.ts +0 -34
- package/dist/recorder/recorder.js +0 -49
- package/dist/registry/adapters.d.ts +0 -10
- package/dist/registry/adapters.js +0 -10
- package/dist/registry/auto-testids.d.ts +0 -19
- package/dist/registry/auto-testids.js +1 -21
- package/dist/registry/capabilities.d.ts +0 -31
- package/dist/registry/capabilities.js +1 -46
- package/dist/registry/commit-and-signal.d.ts +0 -6
- package/dist/registry/commit-and-signal.js +1 -7
- package/dist/registry/domains.d.ts +0 -8
- package/dist/registry/domains.js +0 -7
- package/dist/registry/emitter.d.ts +0 -14
- package/dist/registry/emitter.js +0 -10
- package/dist/registry/store-adapters.d.ts +0 -186
- package/dist/registry/store-adapters.js +0 -163
- package/dist/registry/stores.d.ts +0 -33
- package/dist/registry/stores.js +0 -59
- package/dist/reticle-presenter-helpers.d.ts +0 -13
- package/dist/reticle-presenter-helpers.js +0 -14
- package/dist/reticle.d.ts +1 -52
- package/dist/reticle.js +20 -145
- package/dist/review/annotator-styles.js +0 -7
- package/dist/review/annotator.d.ts +2 -13
- package/dist/review/annotator.js +0 -40
- package/dist/review/mark-anchor.d.ts +0 -11
- package/dist/review/mark-anchor.js +0 -8
- package/dist/security/serialization.d.ts +0 -20
- package/dist/security/serialization.js +1 -66
- package/dist/session-continuity.d.ts +0 -16
- package/dist/session-continuity.js +1 -20
- package/dist/timers/clock.d.ts +1 -14
- package/dist/timers/clock.js +59 -60
- package/dist/timers/native-console.js +0 -4
- package/dist/timers/native-timers.d.ts +0 -25
- package/dist/timers/native-timers.js +3 -33
- package/dist/transport/transport.d.ts +0 -51
- package/dist/transport/transport.js +3 -129
- package/dist/transport/unreachable-message.d.ts +0 -17
- package/dist/transport/unreachable-message.js +0 -17
- package/dist/util/captured-method.d.ts +0 -35
- package/dist/util/captured-method.js +0 -35
- package/package.json +2 -2
package/dist/actions/actions.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { ActionType, ActionWarning, DANGEROUS_ACTION_CONFIRM_ARG, ElementState,
|
|
1
|
+
import { ActionType, ActionWarning, DANGEROUS_ACTION_CONFIRM_ARG, ElementState, NATIVE_INPUT_ARG, SettleReason, } from '@reticlehq/core';
|
|
2
2
|
import { asSyntheticInput } from './synthetic-input.js';
|
|
3
3
|
import { echoRef, refs } from '../dom/refs.js';
|
|
4
|
+
import { dangerousActionContext, requiresDangerousConfirmation, submitControlFor, } from './danger-context.js';
|
|
4
5
|
import { assertEditable, assertNotRichText, setNativeValue } from './value-input.js';
|
|
5
6
|
import { getAccessibleName, getRole, isVisible, getStates } from '../dom/a11y.js';
|
|
6
7
|
import { elementHasHoverHandlers, identifyComponent } from '../registry/adapters.js';
|
|
7
8
|
import { isForm, isHtmlElement, isInput, isSelect, isTextArea } from '../dom/realm.js';
|
|
8
9
|
import { nativeSetTimeout, settle } from '../timers/native-timers.js';
|
|
9
10
|
import { AppearedText } from './appeared-text.js';
|
|
11
|
+
import { focusedOrDocument, isReflessDocumentPress, pressCode, pressKey, pressModifiers, } from './actions-press.js';
|
|
10
12
|
function asString(value, fallback = '') {
|
|
11
13
|
return 'string' === typeof value ? value : fallback;
|
|
12
14
|
}
|
|
@@ -18,18 +20,6 @@ function requireElement(ref) {
|
|
|
18
20
|
throw new Error(`ref '${echoRef(ref)}' is not an HTMLElement`);
|
|
19
21
|
return el;
|
|
20
22
|
}
|
|
21
|
-
/**
|
|
22
|
-
* Capture an element's anchor BEFORE the action runs. Critical: a navigating/destructive action can
|
|
23
|
-
* unmount the target (login submit, a close button), after which it has no readable attribute — so
|
|
24
|
-
* reading the anchor post-settle would silently degrade the recorded step. We read it up front while
|
|
25
|
-
* the element is still mounted.
|
|
26
|
-
*
|
|
27
|
-
* The component walk runs even when a testid is present. It used to be skipped, on the reasoning that
|
|
28
|
-
* a testid is the better anchor and the walk is wasted work — true for anchoring, but it also threw
|
|
29
|
-
* away the source pointer, which answers a different question (where is this defined?) and is the
|
|
30
|
-
* most useful thing we can give an agent that has to fix the thing we just broke. Acts are
|
|
31
|
-
* agent-initiated and rare, so the walk's cost is not on any hot path.
|
|
32
|
-
*/
|
|
33
23
|
function anchorOf(el) {
|
|
34
24
|
const testid = el.getAttribute('data-testid') ?? undefined;
|
|
35
25
|
const info = identifyComponent(el);
|
|
@@ -41,14 +31,6 @@ function anchorOf(el) {
|
|
|
41
31
|
out.component = component;
|
|
42
32
|
if (info?.source !== undefined)
|
|
43
33
|
out.source = info.source;
|
|
44
|
-
// Reported INDEPENDENTLY. They used to be all-or-nothing, so an element with a role and no
|
|
45
|
-
// accessible name — an icon button, a clickable div, a control labelled by an SVG — came back with
|
|
46
|
-
// no identity at all once identifyComponent (React-specific) also found nothing. Two features read
|
|
47
|
-
// this and both silently degrade without it: the flow recorder anchors a step by it, and coverage
|
|
48
|
-
// recognises a control it already drove across a re-render by it.
|
|
49
|
-
//
|
|
50
|
-
// A role alone is not a unique anchor, and the flow compiler still requires role AND name before it
|
|
51
|
-
// will anchor a step that way. But it is real information, and reporting it beats reporting nothing.
|
|
52
34
|
const role = getRole(el);
|
|
53
35
|
const name = getAccessibleName(el);
|
|
54
36
|
if (role.length > 0)
|
|
@@ -67,10 +49,6 @@ const result = (ref, action, effect, settled, settleReason, anchor, warning) =>
|
|
|
67
49
|
settleReason,
|
|
68
50
|
effect,
|
|
69
51
|
};
|
|
70
|
-
// Anchor and provenance are reported side by side rather than as alternatives: the testid says how
|
|
71
|
-
// to find this element next run, the component/source says where it is written. A step is anchored
|
|
72
|
-
// by the testid when it has one (see the flow compiler) and that is unchanged — what changed is
|
|
73
|
-
// that having a testid no longer erases the answer to "which file do I open?".
|
|
74
52
|
if (anchor.testid !== undefined)
|
|
75
53
|
base.testid = anchor.testid;
|
|
76
54
|
if (anchor.component !== undefined)
|
|
@@ -85,9 +63,6 @@ const result = (ref, action, effect, settled, settleReason, anchor, warning) =>
|
|
|
85
63
|
base.warning = warning;
|
|
86
64
|
return base;
|
|
87
65
|
};
|
|
88
|
-
// SELECT is fill-like for the purpose of `valueChanged`: selecting a value with NO matching <option>
|
|
89
|
-
// leaves el.value unchanged (or ''), and valueChanged:false is the only signal an agent gets that the
|
|
90
|
-
// select silently did nothing. Without it a no-op SELECT reported plain success — a false green.
|
|
91
66
|
const FILL_LIKE = new Set([
|
|
92
67
|
ActionType.FILL,
|
|
93
68
|
ActionType.TYPE,
|
|
@@ -95,211 +70,27 @@ const FILL_LIKE = new Set([
|
|
|
95
70
|
ActionType.SELECT,
|
|
96
71
|
]);
|
|
97
72
|
const isFillLike = (action) => FILL_LIKE.has(action);
|
|
98
|
-
/** Actions that resolve to a point and so benefit from off-viewport scroll + occlusion hit-test. */
|
|
99
73
|
const CLICK_LIKE = new Set([
|
|
100
74
|
ActionType.CLICK,
|
|
101
75
|
ActionType.DBLCLICK,
|
|
102
|
-
// check/uncheck activate through a real click, so they need everything a click needs: the point
|
|
103
|
-
// geometry, the off-viewport scroll, the occlusion hit-test, and the component/source attribution.
|
|
104
|
-
// Their absence here is why a `check` came back with no `component` while a `click` on the same
|
|
105
|
-
// element carried one — the tell the field report noticed and could not explain.
|
|
106
76
|
ActionType.CHECK,
|
|
107
77
|
ActionType.UNCHECK,
|
|
108
78
|
]);
|
|
109
|
-
/**
|
|
110
|
-
* A check/uncheck whose requested state the element already reads as, so nothing will be dispatched.
|
|
111
|
-
*
|
|
112
|
-
* ONE predicate, read twice — the dispatch branch decides not to click, the effect reports that it
|
|
113
|
-
* did not. Two copies of this rule could disagree, and a disagreement here is precisely the false
|
|
114
|
-
* green: an act that reports it drove a control it never touched.
|
|
115
|
-
*/
|
|
116
79
|
function alreadyAtCheckedState(el, action) {
|
|
117
80
|
if (action !== ActionType.CHECK && action !== ActionType.UNCHECK)
|
|
118
81
|
return false;
|
|
119
82
|
return isInput(el) && el.checked === (action === ActionType.CHECK);
|
|
120
83
|
}
|
|
121
|
-
/**
|
|
122
|
-
* What the destructive-action guard classifies: the ELEMENT, and nothing rendered around it.
|
|
123
|
-
*
|
|
124
|
-
* This used to end with `form?.textContent`, so the whole enclosing form's rendered text decided the
|
|
125
|
-
* verdict for every control inside it — a settings form with per-row "Remove" buttons made its own
|
|
126
|
-
* Save button read as destructive, intermittently, depending on whether the rows had rendered yet.
|
|
127
|
-
* The field report's workaround was `confirmDangerous: true` on every click, which deletes the guard
|
|
128
|
-
* outright: a guard that fires on everything is a guard that gets switched off.
|
|
129
|
-
*
|
|
130
|
-
* The form's `action` stays — that is a URL this element submits to, i.e. a property of what this
|
|
131
|
-
* click DOES, not of what happens to be on screen beside it.
|
|
132
|
-
*/
|
|
133
|
-
function dangerousActionContext(el) {
|
|
134
|
-
const form = el.closest('form');
|
|
135
|
-
return [
|
|
136
|
-
getAccessibleName(el),
|
|
137
|
-
el.textContent ?? '',
|
|
138
|
-
el.getAttribute('value') ?? '',
|
|
139
|
-
el.getAttribute('title') ?? '',
|
|
140
|
-
el.getAttribute('aria-label') ?? '',
|
|
141
|
-
el.getAttribute('href') ?? '',
|
|
142
|
-
form?.getAttribute('action') ?? '',
|
|
143
|
-
].join(' ');
|
|
144
|
-
}
|
|
145
|
-
function requiresDangerousConfirmation(text, role) {
|
|
146
|
-
return isDangerousActionText(text, role);
|
|
147
|
-
}
|
|
148
|
-
/**
|
|
149
|
-
* Which key a `press` is asking for.
|
|
150
|
-
*
|
|
151
|
-
* `text` FIRST, because that is what the tool description documents ("{ text } for type/press") and
|
|
152
|
-
* therefore what agents send. The implementation read only `args['key']` and defaulted to `'Enter'`,
|
|
153
|
-
* so the documented call — `press` with `{ text: 'Escape' }` — silently dispatched **Enter** and
|
|
154
|
-
* reported success. Two field reports blamed "synthetic events not reaching the app"; the events
|
|
155
|
-
* arrived perfectly and said the wrong thing.
|
|
156
|
-
*
|
|
157
|
-
* Enter is the worst possible substitute to pick by accident: on a focused field inside a form it
|
|
158
|
-
* SUBMITS it, so a request to dismiss a dialog could file the form behind it. `key` still works —
|
|
159
|
-
* it is what anyone reading the source would have sent — and the default only applies when neither
|
|
160
|
-
* was named.
|
|
161
|
-
*/
|
|
162
|
-
function pressKey(args) {
|
|
163
|
-
const text = args['text'];
|
|
164
|
-
if ('string' === typeof text && text.length > 0)
|
|
165
|
-
return text;
|
|
166
|
-
return asString(args['key'], 'Enter');
|
|
167
|
-
}
|
|
168
|
-
/**
|
|
169
|
-
* Modifier flags for a `press`, from `args.modifiers`: an array of Meta / Control / Shift / Alt
|
|
170
|
-
* (case-insensitive, with the usual aliases). Without them a Cmd+K / Ctrl+Shift shortcut receives a
|
|
171
|
-
* keydown with every modifier false, so the app's own `event.metaKey` check never matches and
|
|
172
|
-
* nothing observable happens -- a false negative Reticle reports as no error. (#393)
|
|
173
|
-
*/
|
|
174
|
-
function pressModifiers(args) {
|
|
175
|
-
const raw = args['modifiers'];
|
|
176
|
-
const names = Array.isArray(raw) ? raw.map((m) => asString(m).toLowerCase()) : [];
|
|
177
|
-
const has = (...aliases) => aliases.some((a) => names.includes(a));
|
|
178
|
-
return {
|
|
179
|
-
metaKey: has('meta', 'cmd', 'command', 'super', 'win'),
|
|
180
|
-
ctrlKey: has('control', 'ctrl'),
|
|
181
|
-
shiftKey: has('shift'),
|
|
182
|
-
altKey: has('alt', 'option', 'opt'),
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
/**
|
|
186
|
-
* The PHYSICAL key identity — `event.code` — derived from the logical key.
|
|
187
|
-
*
|
|
188
|
-
* A real browser always sends both, and a meaningful class of library reads only `code`, because it
|
|
189
|
-
* is the layout-independent one: dnd-kit's KeyboardSensor matches its activation and arrow keys on
|
|
190
|
-
* it, and so does react-aria. We sent `key` alone, so those handlers simply never matched — the
|
|
191
|
-
* event fired, the listener ran, the guard failed, and the action reported dispatched over an app
|
|
192
|
-
* that did nothing. Reported from the field as a keyboard drag that could be neither started nor
|
|
193
|
-
* steered.
|
|
194
|
-
*
|
|
195
|
-
* An explicit `code` always wins: a caller driving a non-US layout knows something this derivation
|
|
196
|
-
* cannot. And an unrecognised multi-character key yields `''` rather than a guess — a wrong `code`
|
|
197
|
-
* is worse than none, because a handler will act on it.
|
|
198
|
-
*/
|
|
199
|
-
function pressCode(args, key) {
|
|
200
|
-
const explicit = args['code'];
|
|
201
|
-
if ('string' === typeof explicit && explicit.length > 0)
|
|
202
|
-
return explicit;
|
|
203
|
-
if (' ' === key)
|
|
204
|
-
return 'Space';
|
|
205
|
-
if (1 === key.length) {
|
|
206
|
-
if (/[a-z]/i.test(key))
|
|
207
|
-
return `Key${key.toUpperCase()}`;
|
|
208
|
-
if (/[0-9]/.test(key))
|
|
209
|
-
return `Digit${key}`;
|
|
210
|
-
return '';
|
|
211
|
-
}
|
|
212
|
-
// Named keys — 'Enter', 'Escape', 'Tab', 'ArrowDown' — already ARE their own code.
|
|
213
|
-
return /^[A-Z][A-Za-z0-9]*$/.test(key) && KNOWN_NAMED_KEYS.has(key) ? key : '';
|
|
214
|
-
}
|
|
215
|
-
/**
|
|
216
|
-
* Named keys whose `code` equals their `key`. An allow-list rather than a shape test: `Zzz` looks
|
|
217
|
-
* exactly like `Tab` to a regex, and inventing `code: "Zzz"` would be a confident fabrication.
|
|
218
|
-
*/
|
|
219
|
-
const KNOWN_NAMED_KEYS = new Set([
|
|
220
|
-
'Enter',
|
|
221
|
-
'Escape',
|
|
222
|
-
'Tab',
|
|
223
|
-
'Backspace',
|
|
224
|
-
'Delete',
|
|
225
|
-
'Home',
|
|
226
|
-
'End',
|
|
227
|
-
'PageUp',
|
|
228
|
-
'PageDown',
|
|
229
|
-
'ArrowUp',
|
|
230
|
-
'ArrowDown',
|
|
231
|
-
'ArrowLeft',
|
|
232
|
-
'ArrowRight',
|
|
233
|
-
'Insert',
|
|
234
|
-
'F1',
|
|
235
|
-
'F2',
|
|
236
|
-
'F3',
|
|
237
|
-
'F4',
|
|
238
|
-
'F5',
|
|
239
|
-
'F6',
|
|
240
|
-
'F7',
|
|
241
|
-
'F8',
|
|
242
|
-
'F9',
|
|
243
|
-
'F10',
|
|
244
|
-
'F11',
|
|
245
|
-
'F12',
|
|
246
|
-
]);
|
|
247
|
-
/**
|
|
248
|
-
* The drop target a `drag` names, under either spelling.
|
|
249
|
-
*
|
|
250
|
-
* `toRef` is what the source has always read and it appears NOWHERE in the tool description an
|
|
251
|
-
* agent sees — the `args` sentence names `value`, `text`, `native`, `holdMs` and `confirmDangerous`
|
|
252
|
-
* and omits the one argument without which this action cannot do its job. So agents guess, and
|
|
253
|
-
* `target` is the guess they make. Accepting it costs nothing and removes a whole class of
|
|
254
|
-
* silently-target-less drags; the description now documents `toRef` as well.
|
|
255
|
-
*/
|
|
256
84
|
function dragTargetRef(args) {
|
|
257
85
|
const named = asString(args['toRef']);
|
|
258
86
|
return '' !== named ? named : asString(args['target']);
|
|
259
87
|
}
|
|
260
|
-
/**
|
|
261
|
-
* The keys `upload` actually reads, and the generic ones every action accepts.
|
|
262
|
-
*
|
|
263
|
-
* `path` and `__base64` are NOT listed here. `path` is intercepted by the daemon before the
|
|
264
|
-
* command reaches the browser and rewritten to `{ content, name, type, __base64: true }`. If a
|
|
265
|
-
* `path` key arrives at the browser without a daemon in the loop, `assertUploadArgs` correctly
|
|
266
|
-
* refuses it — the guard exists precisely to prevent a filename-only call from uploading fabricated
|
|
267
|
-
* bytes under the right name. The browser cannot read disk files; the daemon is the only valid
|
|
268
|
-
* path for that crossing.
|
|
269
|
-
*/
|
|
270
88
|
const UPLOAD_ARG_KEYS = new Set(['content', 'name', 'type', '__base64']);
|
|
271
89
|
const GENERIC_ACTION_ARG_KEYS = new Set([
|
|
272
90
|
DANGEROUS_ACTION_CONFIRM_ARG,
|
|
273
91
|
NATIVE_INPUT_ARG,
|
|
274
92
|
'holdMs',
|
|
275
93
|
]);
|
|
276
|
-
/**
|
|
277
|
-
* An `upload` must have been told what to upload.
|
|
278
|
-
*
|
|
279
|
-
* The branch below defaults every field, so a call whose keys were all unrecognised — the field
|
|
280
|
-
* report sent `args.files` — manufactured a 17-byte `file.txt` reading "reticle test file", uploaded
|
|
281
|
-
* THAT, and returned ok:true. The server answered 200, the UI refreshed, and every signal the agent
|
|
282
|
-
* could read said its PDF had been processed. That is a manufactured green on the write path, the
|
|
283
|
-
* same shape `drag` refuses one branch below.
|
|
284
|
-
*
|
|
285
|
-
* Both halves matter. A call with no recognised key at all never described a file; a call that mixes
|
|
286
|
-
* one in with a dropped one (`{ files, name }`) is worse, because the right filename arrives attached
|
|
287
|
-
* to invented bytes. A name-only call keeps working: the placeholder body is documented and the
|
|
288
|
-
* caller that omitted `content` knows it did.
|
|
289
|
-
*
|
|
290
|
-
* `path` is a recognised key: when a daemon is in the loop it reads the file and rewrites the call
|
|
291
|
-
* to `{ content, name, type }` before it arrives here. When there is no daemon, `path` is accepted
|
|
292
|
-
* and the name is used correctly — the placeholder body is the documented default, not fabrication,
|
|
293
|
-
* because the caller chose to name a path that cannot be read in-browser. This is worse than the
|
|
294
|
-
* daemon path, but better than a refusal that tells the agent "upload does not support paths".
|
|
295
|
-
*
|
|
296
|
-
* The refusal names the keys upload reads so a caller that guessed can correct in one turn.
|
|
297
|
-
*
|
|
298
|
-
* Scoped to upload deliberately. Dropping an unrecognised key in silence is a property of EVERY
|
|
299
|
-
* action — `args` is `z.record(z.unknown())` at the wire boundary — but upload is the one where the
|
|
300
|
-
* dropped key is replaced with fabricated content. Refusing unknown keys everywhere needs a
|
|
301
|
-
* per-action arg schema in core and is a larger change than this release should carry.
|
|
302
|
-
*/
|
|
303
94
|
function assertUploadArgs(args) {
|
|
304
95
|
const keys = Object.keys(args);
|
|
305
96
|
const dropped = keys.filter((k) => !UPLOAD_ARG_KEYS.has(k) && !GENERIC_ACTION_ARG_KEYS.has(k));
|
|
@@ -319,13 +110,12 @@ function assertActionAllowed(el, action, args) {
|
|
|
319
110
|
action === ActionType.UNCHECK ||
|
|
320
111
|
action === ActionType.DRAG ||
|
|
321
112
|
action === ActionType.SUBMIT ||
|
|
322
|
-
// Read through the SAME resolver as the dispatch below. Reading a different argument here meant
|
|
323
|
-
// the destructive-action guard classified the call by a key nobody had asked for.
|
|
324
113
|
(action === ActionType.PRESS && 'Enter' === pressKey(args));
|
|
325
|
-
// Same resolver as the dispatch below, so the destructive-action guard cannot classify a drag
|
|
326
|
-
// by a target the dispatch will not use.
|
|
327
114
|
const dragTarget = action === ActionType.DRAG ? refs.resolve(dragTargetRef(args)) : null;
|
|
328
|
-
const
|
|
115
|
+
const submitter = action === ActionType.PRESS && 'Enter' === pressKey(args) ? submitControlFor(el) : null;
|
|
116
|
+
const sourceDangerous = requiresDangerousConfirmation(dangerousActionContext(el), getRole(el)) ||
|
|
117
|
+
(submitter !== null &&
|
|
118
|
+
requiresDangerousConfirmation(dangerousActionContext(submitter), getRole(submitter)));
|
|
329
119
|
const targetDangerous = isHtmlElement(dragTarget) &&
|
|
330
120
|
requiresDangerousConfirmation(dangerousActionContext(dragTarget), getRole(dragTarget));
|
|
331
121
|
if (canTrigger &&
|
|
@@ -334,56 +124,30 @@ function assertActionAllowed(el, action, args) {
|
|
|
334
124
|
throw new Error(`potentially destructive action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`);
|
|
335
125
|
}
|
|
336
126
|
}
|
|
337
|
-
/** Best-effort click-point geometry. All-false default when there is nothing measurable to test. */
|
|
338
127
|
import { NO_GEOMETRY, fireClickSequence, clickGeometry, firePointer, firePointerNonBubbling, dragElement, } from './actions-dom.js';
|
|
339
|
-
/** Derive `enabled` from the shared a11y state logic (disabled prop + aria-disabled). */
|
|
340
128
|
function enabledOf(el) {
|
|
341
129
|
return !getStates(el).includes(ElementState.DISABLED);
|
|
342
130
|
}
|
|
343
|
-
/** Current value for inputs/textareas/selects, else undefined. */
|
|
344
131
|
function valueOf(el) {
|
|
345
132
|
if (isInput(el) || isTextArea(el) || isSelect(el)) {
|
|
346
133
|
return el.value;
|
|
347
134
|
}
|
|
348
135
|
return undefined;
|
|
349
136
|
}
|
|
350
|
-
/** Ref of the currently-focused element, treating body/null as "no focus". */
|
|
351
137
|
function activeRef(el) {
|
|
352
138
|
const active = el.ownerDocument.activeElement;
|
|
353
139
|
if (null === active || active === el.ownerDocument.body)
|
|
354
140
|
return null;
|
|
355
141
|
return refs.refFor(active);
|
|
356
142
|
}
|
|
357
|
-
/**
|
|
358
|
-
* Dispatch the action's events. Returns `defaultPrevented` of the primary cancelable event
|
|
359
|
-
* (false for actions whose primary event is non-cancelable or unobservable). Drag/hover-hold
|
|
360
|
-
* await internally; the probe wrapper builds the result once they resolve.
|
|
361
|
-
*/
|
|
362
|
-
/**
|
|
363
|
-
* The longest a single action may hold the pointer down, in milliseconds.
|
|
364
|
-
*
|
|
365
|
-
* An unbounded `holdMs` is a tool call that never returns, which is the failure the transport layer
|
|
366
|
-
* is shaped around. 30s is far above any real hold-to-confirm (the reported case was 1.2s) and far
|
|
367
|
-
* below a hang.
|
|
368
|
-
*/
|
|
369
143
|
export const MAX_HOLD_MS = 30_000;
|
|
370
|
-
/** A hold the caller asked for, bounded and sanitised. Non-numbers and negatives mean "no hold". */
|
|
371
144
|
function clampHold(raw) {
|
|
372
145
|
if ('number' !== typeof raw || !Number.isFinite(raw) || raw <= 0)
|
|
373
146
|
return 0;
|
|
374
147
|
return Math.min(raw, MAX_HOLD_MS);
|
|
375
148
|
}
|
|
376
|
-
/**
|
|
377
|
-
* Click is the only action that can hold the pointer down, so it is the only one that reports a
|
|
378
|
-
* duration. Everything else routes through the switch below unchanged and holds for zero.
|
|
379
|
-
*/
|
|
380
149
|
async function dispatchFor(el, action, args) {
|
|
381
150
|
if (ActionType.CLICK === action) {
|
|
382
|
-
// Full pointer/mouse sequence (not a bare click) so pointer- and focus-gated handlers fire the
|
|
383
|
-
// way they do for a real user.
|
|
384
|
-
//
|
|
385
|
-
// `args.holdMs` keeps the pointer down in between, which is the only way to drive a
|
|
386
|
-
// hold-to-confirm control. Capped: an unbounded hold is a tool call that never returns.
|
|
387
151
|
const hold = clampHold(args['holdMs']);
|
|
388
152
|
return await fireClickSequence(el, 0 === hold ? undefined : { ms: hold, sleep, now: () => Date.now() });
|
|
389
153
|
}
|
|
@@ -395,18 +159,13 @@ async function dispatchOther(el, action, args) {
|
|
|
395
159
|
return !asSyntheticInput(() => el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })));
|
|
396
160
|
case ActionType.HOVER: {
|
|
397
161
|
const doc = el.ownerDocument;
|
|
398
|
-
// Best-effort "previous" node for relatedTarget so React's enter/leave synthesis has a "from".
|
|
399
162
|
const from = doc.activeElement ?? doc.body;
|
|
400
|
-
// Bubbling over/move with relatedTarget so React's delegated root can synthesize enter/leave.
|
|
401
163
|
firePointer(el, 'pointerover', from);
|
|
402
164
|
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, relatedTarget: from }));
|
|
403
|
-
// Non-bubbling enter pair (per spec) for direct enter/onMouseEnter listeners.
|
|
404
165
|
firePointerNonBubbling(el, 'pointerenter', from);
|
|
405
166
|
el.dispatchEvent(new MouseEvent('mouseenter', { relatedTarget: from }));
|
|
406
167
|
firePointer(el, 'pointermove', from);
|
|
407
168
|
const moved = el.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true }));
|
|
408
|
-
// hover-dwell: keep "hovering" for holdMs so timer-gated reveals can mount. Same cap as
|
|
409
|
-
// click — this arg was previously unbounded here, which is the same never-returns hazard.
|
|
410
169
|
const holdMs = clampHold(args['holdMs']);
|
|
411
170
|
if (holdMs > 0)
|
|
412
171
|
await sleep(holdMs);
|
|
@@ -415,23 +174,8 @@ async function dispatchOther(el, action, args) {
|
|
|
415
174
|
case ActionType.FOCUS:
|
|
416
175
|
el.focus();
|
|
417
176
|
el.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
|
418
|
-
return false;
|
|
177
|
+
return false;
|
|
419
178
|
case ActionType.BLUR: {
|
|
420
|
-
// React 19 listens for `focusout` at the ROOT, so a commit-on-blur handler needs a BUBBLING
|
|
421
|
-
// event to reach it — and `el.blur()` is a no-op on an element that was never focused, which
|
|
422
|
-
// is why the synthetic pair exists at all.
|
|
423
|
-
//
|
|
424
|
-
// But on a FOCUSED element `el.blur()` already dispatches `blur` and a bubbling `focusout`
|
|
425
|
-
// natively. Dispatching them again meant React ran `onBlur` TWICE, and Reticle reported the
|
|
426
|
-
// resulting double write as a `duplicate-request` contradiction — a defect we invented and
|
|
427
|
-
// handed to a human as real. Reported from the field on React 19 + Vite with a single
|
|
428
|
-
// `onBlur={() => mutate(...)}`, one render site and no StrictMode.
|
|
429
|
-
//
|
|
430
|
-
// So synthesize ONLY when nothing native will fire. Read before the call, because `blur()` is
|
|
431
|
-
// what changes it — and read off the element's own ROOT: `document.activeElement` reports the
|
|
432
|
-
// shadow HOST for an element inside a shadow root, so an element that genuinely was focused
|
|
433
|
-
// would read as unfocused and take the double-dispatch path this exists to prevent. Both
|
|
434
|
-
// Document and ShadowRoot expose `activeElement`, so one expression covers both.
|
|
435
179
|
const root = el.getRootNode();
|
|
436
180
|
const wasFocused = root.activeElement === el;
|
|
437
181
|
el.blur();
|
|
@@ -443,27 +187,18 @@ async function dispatchOther(el, action, args) {
|
|
|
443
187
|
}
|
|
444
188
|
case ActionType.FILL:
|
|
445
189
|
if (isInput(el) || isTextArea(el)) {
|
|
446
|
-
// A MISSING value is a malformed call, not a request to empty the field. `asString` defaults
|
|
447
|
-
// to '', which made such a fill indistinguishable from `clear`: it wiped what was there,
|
|
448
|
-
// dispatched a real input event so React committed the empty string to state, and reported
|
|
449
|
-
// ok:true with nothing flagged. The value is nested (`args:{value}`) at the tool boundary, so
|
|
450
|
-
// passing it at the top level lands here — measured on bench-app's login form, where
|
|
451
|
-
// "admin@reticle.dev" was destroyed by a fill that reported success. An explicit '' still
|
|
452
|
-
// clears, because that is someone asking for it.
|
|
453
190
|
if (typeof args['value'] !== 'string') {
|
|
454
191
|
throw new Error("fill requires a string `value` — pass it nested, as args: { value: '…' }. " +
|
|
455
192
|
'To empty a field on purpose use the `clear` action, or fill with an explicit "".');
|
|
456
193
|
}
|
|
457
194
|
assertEditable(el, 'fill');
|
|
458
|
-
el.focus();
|
|
195
|
+
el.focus();
|
|
459
196
|
return setNativeValue(el, args['value']);
|
|
460
197
|
}
|
|
461
198
|
assertNotRichText(el, 'fill');
|
|
462
199
|
throw new Error(`cannot fill a <${el.tagName.toLowerCase()}>`);
|
|
463
200
|
case ActionType.TYPE:
|
|
464
201
|
if (isInput(el) || isTextArea(el)) {
|
|
465
|
-
// Same family as the valueless FILL: a missing `text` appended '' and reported ok:true, so an
|
|
466
|
-
// agent believed it had typed into a field nothing was written to.
|
|
467
202
|
if (typeof args['text'] !== 'string') {
|
|
468
203
|
throw new Error("type requires a string `text` — pass it nested, as args: { text: '…' }.");
|
|
469
204
|
}
|
|
@@ -477,72 +212,36 @@ async function dispatchOther(el, action, args) {
|
|
|
477
212
|
if (isInput(el) || isTextArea(el)) {
|
|
478
213
|
return setNativeValue(el, '');
|
|
479
214
|
}
|
|
480
|
-
// Consistent with TYPE/SELECT/CHECK: clearing a non-field is not a silent success. Throwing
|
|
481
|
-
// surfaces the mismatch instead of reporting ok:true for an action that did nothing.
|
|
482
215
|
throw new Error(`cannot clear a <${el.tagName.toLowerCase()}>`);
|
|
483
216
|
case ActionType.SELECT:
|
|
484
217
|
if (isSelect(el)) {
|
|
485
|
-
// A MISSING value assigned '' to the <select>. No option carries that, so the browser sets
|
|
486
|
-
// selectedIndex to -1 — deselecting everything — from a call that was simply malformed.
|
|
487
218
|
if (typeof args['value'] !== 'string') {
|
|
488
219
|
throw new Error("select requires a string `value` — pass it nested, as args: { value: '…' }.");
|
|
489
220
|
}
|
|
490
221
|
const wanted = args['value'];
|
|
491
222
|
const options = Array.from(el.options);
|
|
492
|
-
// REFUSE an option that does not exist, before touching the element.
|
|
493
|
-
//
|
|
494
|
-
// This used to assign anyway and report the resulting `valueChanged` delta as proof the
|
|
495
|
-
// option never took. That only works if nobody is listening. An unmatched value drives
|
|
496
|
-
// selectedIndex to -1, so `el.value` becomes '' — and we then dispatched `change`. Reported
|
|
497
|
-
// from a real session: the app read that empty value in its change handler and persisted it,
|
|
498
|
-
// corrupting a stored language setting. The "detectable no-op" both mutated the app into a
|
|
499
|
-
// state no user could reach AND did it through the app's own handler.
|
|
500
|
-
//
|
|
501
|
-
// Same rule as the readonly/disabled refusal: if a real user could not do it, forcing it is
|
|
502
|
-
// not a test, it is damage.
|
|
503
223
|
if (!options.some((option) => option.value === wanted)) {
|
|
504
224
|
const available = options.map((option) => `${option.value} (${option.label})`).join(', ');
|
|
505
225
|
throw new Error(`no <option> with value '${echoRef(wanted)}' — available: ${0 === options.length ? '(none)' : available}`);
|
|
506
226
|
}
|
|
507
227
|
el.value = wanted;
|
|
508
228
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
509
|
-
return false;
|
|
229
|
+
return false;
|
|
510
230
|
}
|
|
511
231
|
throw new Error(`cannot select on a <${el.tagName.toLowerCase()}>`);
|
|
512
232
|
case ActionType.CHECK:
|
|
513
233
|
case ActionType.UNCHECK: {
|
|
514
234
|
if (!isInput(el))
|
|
515
235
|
throw new Error(`cannot (un)check a <${el.tagName.toLowerCase()}>`);
|
|
516
|
-
// Same rule as the readonly/disabled refusal on fill: if a real user could not do it, forcing
|
|
517
|
-
// it is not a test, it is damage — and a green over it is a false one.
|
|
518
236
|
if (!enabledOf(el)) {
|
|
519
237
|
throw new Error(`cannot ${action} a disabled control — a real user could not, so neither will Reticle`);
|
|
520
238
|
}
|
|
521
|
-
// A radio is deselected by selecting another radio, never on its own. Refusing is the same
|
|
522
|
-
// rule as the disabled control above: a state no user could reach must not be forced.
|
|
523
239
|
if ('radio' === el.type && action === ActionType.UNCHECK) {
|
|
524
240
|
throw new Error('cannot uncheck a radio button — a real user could not; select another radio in the group');
|
|
525
241
|
}
|
|
526
|
-
// Already there: touch nothing. `check` means "end up checked", not "toggle", so clicking here
|
|
527
|
-
// would flip it OFF, and SETTING it would hand the app an `input`/`change` no user produced —
|
|
528
|
-
// enough to make an app that autosaves, logs or POSTs on change act on a no-op. Reported as
|
|
529
|
-
// `effect.alreadyAtValue` — the same predicate, read again by executeAction — because "nothing
|
|
530
|
-
// was dispatched" is not the same claim as "the app holds this value". See ActionEffect.
|
|
531
242
|
if (alreadyAtCheckedState(el, action))
|
|
532
243
|
return false;
|
|
533
|
-
// A real click, never `el.checked = …`. Assigning the property flips the pixel and tells no
|
|
534
|
-
// framework: React binds a checkbox's onChange to the CLICK event, and its value tracker dedups
|
|
535
|
-
// the change it would otherwise synthesise from a direct assignment — so a controlled
|
|
536
|
-
// `checked={state}` box never heard from us while the action reported success.
|
|
537
|
-
//
|
|
538
|
-
// Dispatched rather than `el.click()` so cancellation is read off the event object itself. The
|
|
539
|
-
// old probe listened on `window`, which never runs when a handler calls `stopPropagation()`, so
|
|
540
|
-
// a cancelled activation was reported as a successful one. Activation behaviour runs either way
|
|
541
|
-
// — a synthetic click toggles the box, which `adversarial.check.test.ts` pins, because the
|
|
542
|
-
// comment this replaces asserted the opposite and was wrong.
|
|
543
244
|
const event = new MouseEvent('click', { bubbles: true, cancelable: true, composed: true });
|
|
544
|
-
// Marked as ours so the annotator's capture-phase listener lets it through: in annotate mode
|
|
545
|
-
// it cancels clicks to place a mark, which would otherwise swallow every action we dispatch.
|
|
546
245
|
const notPrevented = asSyntheticInput(() => el.dispatchEvent(event));
|
|
547
246
|
return !notPrevented || event.defaultPrevented;
|
|
548
247
|
}
|
|
@@ -551,14 +250,12 @@ async function dispatchOther(el, action, args) {
|
|
|
551
250
|
if (null === form)
|
|
552
251
|
throw new Error('no form to submit');
|
|
553
252
|
form.requestSubmit();
|
|
554
|
-
return false;
|
|
253
|
+
return false;
|
|
555
254
|
}
|
|
556
255
|
case ActionType.PRESS: {
|
|
557
256
|
const key = pressKey(args);
|
|
558
257
|
const code = pressCode(args, key);
|
|
559
258
|
const mods = pressModifiers(args);
|
|
560
|
-
// Marked as ours like the click sequence: the annotator leaves annotate mode on Escape, so an
|
|
561
|
-
// agent pressing Escape would otherwise switch off a mode the person turned on.
|
|
562
259
|
const down = asSyntheticInput(() => el.dispatchEvent(new KeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true, ...mods })));
|
|
563
260
|
asSyntheticInput(() => el.dispatchEvent(new KeyboardEvent('keyup', { key, code, bubbles: true, ...mods })));
|
|
564
261
|
return !down;
|
|
@@ -571,10 +268,6 @@ async function dispatchOther(el, action, args) {
|
|
|
571
268
|
throw new Error('upload target must be a <input type="file">');
|
|
572
269
|
}
|
|
573
270
|
assertUploadArgs(args);
|
|
574
|
-
// When the daemon read a file from disk it base64-encoded the bytes so they survive JSON
|
|
575
|
-
// serialisation across the bridge. Decode back to binary before handing to the File API.
|
|
576
|
-
// Without this the browser would treat the base64 string as UTF-16 text, and the app
|
|
577
|
-
// would receive garbled bytes — correct name and type, wrong payload, still a false green.
|
|
578
271
|
const rawContent = asString(args['content'], 'reticle test file');
|
|
579
272
|
let fileBody = rawContent;
|
|
580
273
|
if (true === args['__base64']) {
|
|
@@ -582,9 +275,6 @@ async function dispatchOther(el, action, args) {
|
|
|
582
275
|
const arr = new Uint8Array(bin.length);
|
|
583
276
|
for (let i = 0; i < bin.length; i++)
|
|
584
277
|
arr[i] = bin.charCodeAt(i);
|
|
585
|
-
// Use slice(0) to get a plain ArrayBuffer from the Uint8Array's backing store.
|
|
586
|
-
// arr.buffer is SharedArrayBuffer-typed in strict tsconfigs; slice(0) narrows it to
|
|
587
|
-
// ArrayBuffer and avoids the type assertion the linter would flag.
|
|
588
278
|
fileBody = arr.buffer.slice(0);
|
|
589
279
|
}
|
|
590
280
|
const file = new File([fileBody], asString(args['name'], 'file.txt'), {
|
|
@@ -599,14 +289,8 @@ async function dispatchOther(el, action, args) {
|
|
|
599
289
|
}
|
|
600
290
|
case ActionType.DRAG: {
|
|
601
291
|
const toRef = dragTargetRef(args);
|
|
602
|
-
// asString falls back to '' (never undefined), so the old `!== undefined` was always true and
|
|
603
|
-
// handed '' to refs.resolve. A drag with NO target named is a free drag — resolve nothing.
|
|
604
292
|
if ('' === toRef)
|
|
605
293
|
return await dragElement(el, null, args['data']);
|
|
606
|
-
// A target WAS named and did not resolve. That is a malformed call, not a free drag: dragging
|
|
607
|
-
// nowhere and reporting `ok: true` is a false green, and it is the one this action produced
|
|
608
|
-
// in the field — the drop target argument is undocumented, the agent guessed a name, the guess
|
|
609
|
-
// read as "no target", and the drag landed nowhere while every effect field looked healthy.
|
|
610
294
|
const resolved = refs.resolve(toRef);
|
|
611
295
|
if (!isHtmlElement(resolved)) {
|
|
612
296
|
throw new Error(`drag target '${echoRef(toRef)}' did not resolve to an element — pass a ref from ` +
|
|
@@ -618,23 +302,17 @@ async function dispatchOther(el, action, args) {
|
|
|
618
302
|
throw new Error(`unknown action '${action}'`);
|
|
619
303
|
}
|
|
620
304
|
}
|
|
621
|
-
/**
|
|
622
|
-
* Execute a single action against a ref and probe for best-effort evidence of effect.
|
|
623
|
-
* Always async: the MutationObserver read needs a microtask + rAF after dispatch.
|
|
624
|
-
*/
|
|
625
305
|
export async function executeAction(ref, action, args = {}) {
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
306
|
+
const named = 0 < ref.length;
|
|
307
|
+
const el = isReflessDocumentPress(ref, action, args) ? focusedOrDocument() : requireElement(ref);
|
|
308
|
+
if (named)
|
|
309
|
+
assertActionAllowed(el, action, args);
|
|
310
|
+
const anchor = named ? anchorOf(el) : {};
|
|
630
311
|
const visible = isVisible(el);
|
|
631
312
|
const enabled = enabledOf(el);
|
|
632
313
|
const prevFocus = activeRef(el);
|
|
633
314
|
const valueBefore = valueOf(el);
|
|
634
|
-
// Click-like: scroll an off-viewport target in + hit-test the click point BEFORE installing the
|
|
635
|
-
// mutation observer (scroll/hit-test never mutate the DOM, but keep the probe window clean).
|
|
636
315
|
const geometry = CLICK_LIKE.has(action) ? clickGeometry(el) : NO_GEOMETRY;
|
|
637
|
-
// Read BEFORE dispatch, from the same predicate the dispatch branch uses.
|
|
638
316
|
const alreadyAtValue = alreadyAtCheckedState(el, action);
|
|
639
317
|
let mutated = 0;
|
|
640
318
|
const said = new AppearedText();
|
|
@@ -649,7 +327,6 @@ export async function executeAction(ref, action, args = {}) {
|
|
|
649
327
|
characterData: true,
|
|
650
328
|
});
|
|
651
329
|
let defaultPrevented = false;
|
|
652
|
-
/** How long the pointer was actually held down. Zero for every action that cannot hold. */
|
|
653
330
|
let heldMs = 0;
|
|
654
331
|
let settled = false;
|
|
655
332
|
let settleReason = null;
|
|
@@ -659,10 +336,6 @@ export async function executeAction(ref, action, args = {}) {
|
|
|
659
336
|
heldMs = outcome.heldMs;
|
|
660
337
|
}
|
|
661
338
|
finally {
|
|
662
|
-
// bounded settle — microtask + one BOUNDED frame so React's commit (and the resulting DOM
|
|
663
|
-
// mutations → dom.added/dom.text/dom.attr events) flush before we return, landing inside
|
|
664
|
-
// observe({ since }). Bounded so a throttled/background tab never hangs; a settle timeout NEVER
|
|
665
|
-
// rejects (only a real dispatch failure thrown above does). settle can never throw.
|
|
666
339
|
const outcome = await settle();
|
|
667
340
|
settled = outcome.settled;
|
|
668
341
|
settleReason = outcome.settled ? null : SettleReason.TIMEOUT;
|
|
@@ -672,37 +345,30 @@ export async function executeAction(ref, action, args = {}) {
|
|
|
672
345
|
const nextFocus = activeRef(el);
|
|
673
346
|
const effect = {
|
|
674
347
|
dispatched: true,
|
|
675
|
-
targetMatched: el.isConnected,
|
|
348
|
+
targetMatched: named && el.isConnected,
|
|
676
349
|
visible,
|
|
677
350
|
enabled,
|
|
678
351
|
defaultPrevented,
|
|
679
352
|
focusMoved: prevFocus !== nextFocus ? `${prevFocus ?? 'null'}->${nextFocus ?? 'null'}` : null,
|
|
680
353
|
valueChanged: isFillLike(action) ? valueBefore !== valueAfter : false,
|
|
681
354
|
domMutatedWithin: mutated,
|
|
682
|
-
// Omitted rather than "" when nothing was said — an empty string reads as "it said nothing
|
|
683
|
-
// meaningful", where absence says "it added no text at all". Those are different findings.
|
|
684
355
|
...said.effect(valueAfter),
|
|
685
356
|
occluded: geometry.occluded,
|
|
686
357
|
occludedBy: geometry.occludedBy,
|
|
687
358
|
scrolledIntoView: geometry.scrolledIntoView,
|
|
688
|
-
// Omitted when there was no hold: an absent key says "this action does not hold", where a 0
|
|
689
|
-
// would read as "it held for no time", which is a different and misleading claim.
|
|
690
359
|
...(heldMs > 0 ? { heldMs } : {}),
|
|
691
|
-
// Omitted unless it applies: an absent key means the action actually drove the control (or is
|
|
692
|
-
// not a check at all), which is the only reading that must not be ambiguous.
|
|
693
360
|
...(alreadyAtValue ? { alreadyAtValue: true } : {}),
|
|
694
361
|
};
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
362
|
+
const warning = !named
|
|
363
|
+
? ActionWarning.GLOBAL_PRESS
|
|
364
|
+
: geometry.occluded
|
|
365
|
+
? ActionWarning.CLICK_OCCLUDED
|
|
366
|
+
: action === ActionType.HOVER && elementHasHoverHandlers(el)
|
|
367
|
+
? ActionWarning.HOVER_NATIVE_ENTER_LEAVE
|
|
368
|
+
: undefined;
|
|
702
369
|
return result(ref, action, effect, settled, settleReason, anchor, warning);
|
|
703
370
|
}
|
|
704
371
|
const sleep = (ms) => new Promise((r) => nativeSetTimeout(r, ms));
|
|
705
|
-
/** Best-effort WebMCP passthrough: call a navigator.modelContext tool if the site exposes one. */
|
|
706
372
|
export async function dispatchWebMcp(tool, params, confirmDangerous = false) {
|
|
707
373
|
if (requiresDangerousConfirmation(tool) && !confirmDangerous) {
|
|
708
374
|
throw new Error(`potentially destructive WebMCP tool blocked; retry with ${DANGEROUS_ACTION_CONFIRM_ARG}=true`);
|