@reticlehq/browser 2.13.1 → 3.1.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 +8 -20
- package/dist/actions/actions-dom.js +70 -69
- package/dist/actions/actions-press.d.ts +14 -0
- package/dist/actions/actions-press.js +123 -0
- package/dist/actions/actions.d.ts +1 -89
- package/dist/actions/actions.js +64 -365
- package/dist/actions/appeared-text.d.ts +0 -20
- package/dist/actions/appeared-text.js +6 -36
- package/dist/actions/danger-context.d.ts +3 -0
- package/dist/actions/danger-context.js +39 -0
- package/dist/actions/scroll.d.ts +8 -10
- package/dist/actions/scroll.js +28 -17
- package/dist/actions/synthetic/synthetic-input.d.ts +2 -0
- package/dist/actions/synthetic/synthetic-input.js +13 -0
- 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 +10 -70
- package/dist/connect-options.d.ts +2 -96
- package/dist/dom/a11y.d.ts +0 -29
- package/dist/dom/a11y.js +3 -90
- package/dist/dom/addressing/auto-anchor.d.ts +25 -0
- package/dist/dom/addressing/auto-anchor.js +41 -0
- package/dist/dom/addressing/refs.d.ts +14 -0
- package/dist/dom/addressing/refs.js +114 -0
- package/dist/dom/addressing/source.d.ts +9 -0
- package/dist/dom/addressing/source.js +29 -0
- 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 +1 -73
- package/dist/dom/dom-ignore.d.ts +0 -30
- package/dist/dom/dom-ignore.js +1 -48
- package/dist/dom/occlusion.d.ts +0 -7
- package/dist/dom/occlusion.js +31 -12
- package/dist/dom/paint-context.d.ts +6 -0
- package/dist/dom/paint-context.js +38 -0
- package/dist/dom/query.d.ts +0 -14
- package/dist/dom/query.js +50 -216
- package/dist/dom/realm.d.ts +0 -20
- package/dist/dom/realm.js +0 -28
- 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 +27 -152
- 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 +1 -23
- package/dist/edit-epoch.js +1 -53
- package/dist/index.d.ts +4 -5
- package/dist/index.js +4 -28
- package/dist/observers/animation.d.ts +0 -1
- package/dist/observers/animation.js +1 -4
- package/dist/observers/blind-spots.d.ts +0 -26
- package/dist/observers/blind-spots.js +3 -62
- 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 +28 -52
- package/dist/observers/download.d.ts +1 -2
- package/dist/observers/download.js +39 -56
- 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 +2 -34
- 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/net-detail/network-body.d.ts +12 -0
- package/dist/observers/net-detail/network-body.js +71 -0
- package/dist/observers/net-detail/network-redact.d.ts +1 -0
- package/dist/observers/net-detail/network-redact.js +1 -0
- package/dist/observers/network-stream.d.ts +0 -21
- package/dist/observers/network-stream.js +3 -46
- package/dist/observers/network.d.ts +3 -27
- package/dist/observers/network.js +178 -295
- 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 +2 -11
- 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/chrome/layers.d.ts +2 -0
- package/dist/presenter/chrome/layers.js +2 -0
- package/dist/presenter/chrome/log-kinds.d.ts +12 -0
- package/dist/presenter/chrome/log-kinds.js +7 -0
- package/dist/presenter/{overlay.d.ts → chrome/overlay.d.ts} +0 -1
- package/dist/presenter/{overlay.js → chrome/overlay.js} +0 -1
- package/dist/presenter/chrome/presenter-brand.d.ts +3 -0
- package/dist/presenter/{presenter-brand.js → chrome/presenter-brand.js} +0 -21
- package/dist/presenter/chrome/presenter-effects.d.ts +4 -0
- package/dist/presenter/{presenter-effects.js → chrome/presenter-effects.js} +1 -10
- package/dist/presenter/chrome/presenter-hud-chrome.d.ts +7 -0
- package/dist/presenter/{presenter-hud-chrome.js → chrome/presenter-hud-chrome.js} +0 -10
- package/dist/presenter/chrome/presenter-log.d.ts +17 -0
- package/dist/presenter/{presenter-log.js → chrome/presenter-log.js} +5 -54
- package/dist/presenter/chrome/presenter-report-copy.d.ts +51 -0
- package/dist/presenter/chrome/presenter-report-copy.js +95 -0
- package/dist/presenter/chrome/presenter-safe-html.d.ts +2 -0
- package/dist/presenter/chrome/presenter-safe-html.js +16 -0
- package/dist/presenter/{presenter-tally.d.ts → chrome/presenter-tally.d.ts} +0 -5
- package/dist/presenter/{presenter-tally.js → chrome/presenter-tally.js} +1 -5
- package/dist/presenter/{presenter-verbs.js → chrome/presenter-verbs.js} +0 -7
- package/dist/presenter/{presenter-heroicons-data.d.ts → icons/presenter-heroicons-data.d.ts} +1 -8
- package/dist/presenter/{presenter-heroicons-data.js → icons/presenter-heroicons-data.js} +1 -8
- package/dist/presenter/{presenter-icons.d.ts → icons/presenter-icons.d.ts} +1 -6
- package/dist/presenter/{presenter-icons.js → icons/presenter-icons.js} +1 -6
- package/dist/presenter/presenter-account-styles.d.ts +1 -0
- package/dist/presenter/presenter-account-styles.js +99 -0
- package/dist/presenter/presenter-account.d.ts +40 -0
- package/dist/presenter/presenter-account.js +178 -0
- package/dist/presenter/presenter-config.d.ts +10 -107
- package/dist/presenter/presenter-config.js +5 -92
- package/dist/presenter/presenter-controls.d.ts +1 -35
- package/dist/presenter/presenter-controls.js +10 -133
- package/dist/presenter/presenter-dock-layout.d.ts +0 -3
- package/dist/presenter/presenter-dock-layout.js +1 -5
- package/dist/presenter/presenter-drag.d.ts +2 -42
- package/dist/presenter/presenter-drag.js +3 -76
- package/dist/presenter/presenter-glow.d.ts +0 -21
- package/dist/presenter/presenter-glow.js +2 -28
- package/dist/presenter/presenter-hud-position.d.ts +18 -0
- package/dist/presenter/presenter-hud-position.js +50 -0
- package/dist/presenter/presenter-report-styles.d.ts +0 -6
- package/dist/presenter/presenter-report-styles.js +22 -7
- package/dist/presenter/presenter-report.d.ts +5 -7
- package/dist/presenter/presenter-report.js +44 -95
- package/dist/presenter/presenter-run-state.d.ts +1 -8
- package/dist/presenter/presenter-run-state.js +1 -6
- package/dist/presenter/presenter-settings-styles.d.ts +0 -1
- package/dist/presenter/presenter-settings-styles.js +2 -2
- package/dist/presenter/presenter-settings.d.ts +5 -23
- package/dist/presenter/presenter-settings.js +21 -33
- package/dist/presenter/presenter-shell-styles.js +23 -20
- package/dist/presenter/presenter-shell.d.ts +6 -18
- package/dist/presenter/presenter-shell.js +26 -77
- package/dist/presenter/presenter-styles.d.ts +2 -10
- package/dist/presenter/presenter-styles.js +6 -12
- package/dist/presenter/presenter-workspace.d.ts +4 -4
- package/dist/presenter/presenter-workspace.js +30 -10
- package/dist/presenter/presenter.d.ts +3 -63
- package/dist/presenter/presenter.js +29 -132
- package/dist/presenter/tour/tour-view.d.ts +33 -0
- package/dist/presenter/tour/tour-view.js +188 -0
- package/dist/presenter/tour/tour.d.ts +25 -0
- package/dist/presenter/tour/tour.js +245 -0
- 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 +6 -49
- 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/declared-channels.d.ts +2 -0
- package/dist/registry/declared-channels.js +14 -0
- package/dist/registry/declared-platform.d.ts +4 -0
- package/dist/registry/declared-platform.js +34 -0
- 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 +1 -11
- package/dist/registry/store-adapters.d.ts +0 -186
- package/dist/registry/store-adapters.js +1 -164
- package/dist/registry/{adapters.d.ts → stores/adapters.d.ts} +0 -10
- package/dist/registry/{adapters.js → stores/adapters.js} +0 -10
- package/dist/registry/stores.d.ts +0 -33
- package/dist/registry/stores.js +1 -60
- package/dist/reticle-presenter-helpers.d.ts +0 -13
- package/dist/reticle-presenter-helpers.js +10 -20
- package/dist/reticle-singleton.d.ts +2 -0
- package/dist/reticle-singleton.js +3 -0
- package/dist/reticle.d.ts +1 -52
- package/dist/reticle.js +115 -180
- package/dist/review/annotator-styles.js +1 -8
- package/dist/review/annotator.d.ts +2 -13
- package/dist/review/annotator.js +2 -42
- package/dist/review/mark-anchor.d.ts +0 -11
- package/dist/review/mark-anchor.js +3 -11
- 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/console/native-console.js +5 -0
- package/dist/timers/native/native-timers.d.ts +11 -0
- package/dist/timers/native/native-timers.js +48 -0
- package/dist/transport/transport.d.ts +0 -51
- package/dist/transport/transport.js +5 -131
- 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 +5 -4
- package/dist/actions/synthetic-input.d.ts +0 -9
- package/dist/actions/synthetic-input.js +0 -40
- package/dist/dom/auto-anchor.d.ts +0 -65
- package/dist/dom/auto-anchor.js +0 -74
- package/dist/dom/refs.d.ts +0 -90
- package/dist/dom/refs.js +0 -245
- package/dist/dom/source.d.ts +0 -53
- package/dist/dom/source.js +0 -74
- package/dist/observers/network-body.d.ts +0 -35
- package/dist/observers/network-body.js +0 -168
- package/dist/observers/network-redact.d.ts +0 -20
- package/dist/observers/network-redact.js +0 -80
- package/dist/presenter/presenter-brand.d.ts +0 -22
- package/dist/presenter/presenter-effects.d.ts +0 -13
- package/dist/presenter/presenter-hud-chrome.d.ts +0 -17
- package/dist/presenter/presenter-log.d.ts +0 -64
- package/dist/presenter/presenter-report-copy.d.ts +0 -112
- package/dist/presenter/presenter-report-copy.js +0 -156
- package/dist/presenter/presenter-test-helpers.d.ts +0 -16
- package/dist/presenter/presenter-test-helpers.js +0 -41
- package/dist/timers/native-console.js +0 -9
- package/dist/timers/native-timers.d.ts +0 -36
- package/dist/timers/native-timers.js +0 -78
- /package/dist/presenter/{presenter-verbs.d.ts → chrome/presenter-verbs.d.ts} +0 -0
- /package/dist/timers/{native-console.d.ts → console/native-console.d.ts} +0 -0
package/dist/actions/actions.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { ActionType, ActionWarning, DANGEROUS_ACTION_CONFIRM_ARG, ElementState,
|
|
2
|
-
import { asSyntheticInput } from './synthetic-input.js';
|
|
3
|
-
import { echoRef, refs } from '../dom/refs.js';
|
|
1
|
+
import { ActionType, ActionWarning, DANGEROUS_ACTION_CONFIRM_ARG, ElementState, NATIVE_INPUT_ARG, SettleReason, } from '@reticlehq/core';
|
|
2
|
+
import { asSyntheticInput } from './synthetic/synthetic-input.js';
|
|
3
|
+
import { echoRef, refs } from '../dom/addressing/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
|
-
import { elementHasHoverHandlers, identifyComponent } from '../registry/adapters.js';
|
|
7
|
+
import { elementHasHoverHandlers, identifyComponent } from '../registry/stores/adapters.js';
|
|
7
8
|
import { isForm, isHtmlElement, isInput, isSelect, isTextArea } from '../dom/realm.js';
|
|
8
|
-
import { nativeSetTimeout, settle } from '../timers/native-timers.js';
|
|
9
|
+
import { nativeSetTimeout, settle } from '../timers/native/native-timers.js';
|
|
9
10
|
import { AppearedText } from './appeared-text.js';
|
|
11
|
+
import { focusedOrDocument, isReflessDocumentPress, pressCode, pressKey, pressModifiers, pressKeys, holdKey, pressCombo, } 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,79 +124,59 @@ 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
|
-
|
|
338
|
-
import {
|
|
339
|
-
/** Derive `enabled` from the shared a11y state logic (disabled prop + aria-disabled). */
|
|
127
|
+
import { NO_GEOMETRY, fireClickSequence, fireTapSequence, clickGeometry, firePointer, firePointerNonBubbling, dragElement, } from './actions-dom.js';
|
|
128
|
+
import { scrollFrom } from './scroll.js';
|
|
340
129
|
function enabledOf(el) {
|
|
341
130
|
return !getStates(el).includes(ElementState.DISABLED);
|
|
342
131
|
}
|
|
343
|
-
/** Current value for inputs/textareas/selects, else undefined. */
|
|
344
132
|
function valueOf(el) {
|
|
345
133
|
if (isInput(el) || isTextArea(el) || isSelect(el)) {
|
|
346
134
|
return el.value;
|
|
347
135
|
}
|
|
348
136
|
return undefined;
|
|
349
137
|
}
|
|
350
|
-
/** Ref of the currently-focused element, treating body/null as "no focus". */
|
|
351
138
|
function activeRef(el) {
|
|
352
139
|
const active = el.ownerDocument.activeElement;
|
|
353
140
|
if (null === active || active === el.ownerDocument.body)
|
|
354
141
|
return null;
|
|
355
142
|
return refs.refFor(active);
|
|
356
143
|
}
|
|
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
144
|
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
145
|
function clampHold(raw) {
|
|
372
146
|
if ('number' !== typeof raw || !Number.isFinite(raw) || raw <= 0)
|
|
373
147
|
return 0;
|
|
374
148
|
return Math.min(raw, MAX_HOLD_MS);
|
|
375
149
|
}
|
|
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
150
|
async function dispatchFor(el, action, args) {
|
|
381
151
|
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
152
|
const hold = clampHold(args['holdMs']);
|
|
388
153
|
return await fireClickSequence(el, 0 === hold ? undefined : { ms: hold, sleep, now: () => Date.now() });
|
|
389
154
|
}
|
|
155
|
+
if (ActionType.TAP === action) {
|
|
156
|
+
const hold = clampHold(args['holdMs']);
|
|
157
|
+
return await fireTapSequence(el, 0 === hold ? undefined : { ms: hold, sleep, now: () => Date.now() });
|
|
158
|
+
}
|
|
390
159
|
return { prevented: await dispatchOther(el, action, args), heldMs: 0 };
|
|
391
160
|
}
|
|
161
|
+
export const ZOOM_NEEDS_REAL_BROWSER_MSG = 'cannot zoom from inside the page — CSS zoom changes how it LOOKS without changing the layout ' +
|
|
162
|
+
'viewport, visualViewport or media queries, so a layout that breaks at 200% would be reported as ' +
|
|
163
|
+
'checked and passing. Drive a real browser (reticle drive <url>, or reticle_lease) and zoom there.';
|
|
164
|
+
function asFiniteNumber(raw) {
|
|
165
|
+
return 'number' === typeof raw && Number.isFinite(raw) ? raw : undefined;
|
|
166
|
+
}
|
|
392
167
|
async function dispatchOther(el, action, args) {
|
|
393
168
|
switch (action) {
|
|
394
169
|
case ActionType.DBLCLICK:
|
|
395
170
|
return !asSyntheticInput(() => el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })));
|
|
396
171
|
case ActionType.HOVER: {
|
|
397
172
|
const doc = el.ownerDocument;
|
|
398
|
-
// Best-effort "previous" node for relatedTarget so React's enter/leave synthesis has a "from".
|
|
399
173
|
const from = doc.activeElement ?? doc.body;
|
|
400
|
-
// Bubbling over/move with relatedTarget so React's delegated root can synthesize enter/leave.
|
|
401
174
|
firePointer(el, 'pointerover', from);
|
|
402
175
|
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, relatedTarget: from }));
|
|
403
|
-
// Non-bubbling enter pair (per spec) for direct enter/onMouseEnter listeners.
|
|
404
176
|
firePointerNonBubbling(el, 'pointerenter', from);
|
|
405
177
|
el.dispatchEvent(new MouseEvent('mouseenter', { relatedTarget: from }));
|
|
406
178
|
firePointer(el, 'pointermove', from);
|
|
407
179
|
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
180
|
const holdMs = clampHold(args['holdMs']);
|
|
411
181
|
if (holdMs > 0)
|
|
412
182
|
await sleep(holdMs);
|
|
@@ -415,23 +185,8 @@ async function dispatchOther(el, action, args) {
|
|
|
415
185
|
case ActionType.FOCUS:
|
|
416
186
|
el.focus();
|
|
417
187
|
el.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
|
418
|
-
return false;
|
|
188
|
+
return false;
|
|
419
189
|
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
190
|
const root = el.getRootNode();
|
|
436
191
|
const wasFocused = root.activeElement === el;
|
|
437
192
|
el.blur();
|
|
@@ -443,27 +198,18 @@ async function dispatchOther(el, action, args) {
|
|
|
443
198
|
}
|
|
444
199
|
case ActionType.FILL:
|
|
445
200
|
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
201
|
if (typeof args['value'] !== 'string') {
|
|
454
202
|
throw new Error("fill requires a string `value` — pass it nested, as args: { value: '…' }. " +
|
|
455
203
|
'To empty a field on purpose use the `clear` action, or fill with an explicit "".');
|
|
456
204
|
}
|
|
457
205
|
assertEditable(el, 'fill');
|
|
458
|
-
el.focus();
|
|
206
|
+
el.focus();
|
|
459
207
|
return setNativeValue(el, args['value']);
|
|
460
208
|
}
|
|
461
209
|
assertNotRichText(el, 'fill');
|
|
462
210
|
throw new Error(`cannot fill a <${el.tagName.toLowerCase()}>`);
|
|
463
211
|
case ActionType.TYPE:
|
|
464
212
|
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
213
|
if (typeof args['text'] !== 'string') {
|
|
468
214
|
throw new Error("type requires a string `text` — pass it nested, as args: { text: '…' }.");
|
|
469
215
|
}
|
|
@@ -477,72 +223,36 @@ async function dispatchOther(el, action, args) {
|
|
|
477
223
|
if (isInput(el) || isTextArea(el)) {
|
|
478
224
|
return setNativeValue(el, '');
|
|
479
225
|
}
|
|
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
226
|
throw new Error(`cannot clear a <${el.tagName.toLowerCase()}>`);
|
|
483
227
|
case ActionType.SELECT:
|
|
484
228
|
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
229
|
if (typeof args['value'] !== 'string') {
|
|
488
230
|
throw new Error("select requires a string `value` — pass it nested, as args: { value: '…' }.");
|
|
489
231
|
}
|
|
490
232
|
const wanted = args['value'];
|
|
491
233
|
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
234
|
if (!options.some((option) => option.value === wanted)) {
|
|
504
235
|
const available = options.map((option) => `${option.value} (${option.label})`).join(', ');
|
|
505
236
|
throw new Error(`no <option> with value '${echoRef(wanted)}' — available: ${0 === options.length ? '(none)' : available}`);
|
|
506
237
|
}
|
|
507
238
|
el.value = wanted;
|
|
508
239
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
509
|
-
return false;
|
|
240
|
+
return false;
|
|
510
241
|
}
|
|
511
242
|
throw new Error(`cannot select on a <${el.tagName.toLowerCase()}>`);
|
|
512
243
|
case ActionType.CHECK:
|
|
513
244
|
case ActionType.UNCHECK: {
|
|
514
245
|
if (!isInput(el))
|
|
515
246
|
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
247
|
if (!enabledOf(el)) {
|
|
519
248
|
throw new Error(`cannot ${action} a disabled control — a real user could not, so neither will Reticle`);
|
|
520
249
|
}
|
|
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
250
|
if ('radio' === el.type && action === ActionType.UNCHECK) {
|
|
524
251
|
throw new Error('cannot uncheck a radio button — a real user could not; select another radio in the group');
|
|
525
252
|
}
|
|
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
253
|
if (alreadyAtCheckedState(el, action))
|
|
532
254
|
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
255
|
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
256
|
const notPrevented = asSyntheticInput(() => el.dispatchEvent(event));
|
|
547
257
|
return !notPrevented || event.defaultPrevented;
|
|
548
258
|
}
|
|
@@ -551,30 +261,39 @@ async function dispatchOther(el, action, args) {
|
|
|
551
261
|
if (null === form)
|
|
552
262
|
throw new Error('no form to submit');
|
|
553
263
|
form.requestSubmit();
|
|
554
|
-
return false;
|
|
264
|
+
return false;
|
|
555
265
|
}
|
|
556
266
|
case ActionType.PRESS: {
|
|
267
|
+
const mods = pressModifiers(args);
|
|
268
|
+
const combo = pressKeys(args);
|
|
269
|
+
if (combo.length > 0)
|
|
270
|
+
return await pressCombo(el, combo, mods, clampHold(args['holdMs']));
|
|
557
271
|
const key = pressKey(args);
|
|
558
272
|
const code = pressCode(args, key);
|
|
559
|
-
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
273
|
const down = asSyntheticInput(() => el.dispatchEvent(new KeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true, ...mods })));
|
|
274
|
+
const hold = clampHold(args['holdMs']);
|
|
275
|
+
if (hold > 0)
|
|
276
|
+
await holdKey(el, key, code, mods, hold);
|
|
563
277
|
asSyntheticInput(() => el.dispatchEvent(new KeyboardEvent('keyup', { key, code, bubbles: true, ...mods })));
|
|
564
278
|
return !down;
|
|
565
279
|
}
|
|
566
280
|
case ActionType.SCROLL_INTO_VIEW:
|
|
567
281
|
el.scrollIntoView();
|
|
568
282
|
return false;
|
|
283
|
+
case ActionType.ZOOM:
|
|
284
|
+
throw new Error(ZOOM_NEEDS_REAL_BROWSER_MSG);
|
|
285
|
+
case ActionType.SCROLL: {
|
|
286
|
+
const dy = asFiniteNumber(args['dy']);
|
|
287
|
+
const dx = asFiniteNumber(args['dx']);
|
|
288
|
+
const fraction = asFiniteNumber(args['fraction']);
|
|
289
|
+
scrollFrom(el, dy, fraction, dx);
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
569
292
|
case ActionType.UPLOAD: {
|
|
570
293
|
if (!isInput(el) || el.type !== 'file') {
|
|
571
294
|
throw new Error('upload target must be a <input type="file">');
|
|
572
295
|
}
|
|
573
296
|
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
297
|
const rawContent = asString(args['content'], 'reticle test file');
|
|
579
298
|
let fileBody = rawContent;
|
|
580
299
|
if (true === args['__base64']) {
|
|
@@ -582,9 +301,6 @@ async function dispatchOther(el, action, args) {
|
|
|
582
301
|
const arr = new Uint8Array(bin.length);
|
|
583
302
|
for (let i = 0; i < bin.length; i++)
|
|
584
303
|
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
304
|
fileBody = arr.buffer.slice(0);
|
|
589
305
|
}
|
|
590
306
|
const file = new File([fileBody], asString(args['name'], 'file.txt'), {
|
|
@@ -599,14 +315,8 @@ async function dispatchOther(el, action, args) {
|
|
|
599
315
|
}
|
|
600
316
|
case ActionType.DRAG: {
|
|
601
317
|
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
318
|
if ('' === toRef)
|
|
605
319
|
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
320
|
const resolved = refs.resolve(toRef);
|
|
611
321
|
if (!isHtmlElement(resolved)) {
|
|
612
322
|
throw new Error(`drag target '${echoRef(toRef)}' did not resolve to an element — pass a ref from ` +
|
|
@@ -618,23 +328,23 @@ async function dispatchOther(el, action, args) {
|
|
|
618
328
|
throw new Error(`unknown action '${action}'`);
|
|
619
329
|
}
|
|
620
330
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
331
|
+
function asSettleBudget(raw) {
|
|
332
|
+
if ('number' !== typeof raw || !Number.isFinite(raw))
|
|
333
|
+
return undefined;
|
|
334
|
+
return Math.max(0, Math.min(raw, FRAME_BUDGET_MS_CEILING));
|
|
335
|
+
}
|
|
336
|
+
const FRAME_BUDGET_MS_CEILING = 200;
|
|
625
337
|
export async function executeAction(ref, action, args = {}) {
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
338
|
+
const named = 0 < ref.length;
|
|
339
|
+
const el = isReflessDocumentPress(ref, action, args) ? focusedOrDocument() : requireElement(ref);
|
|
340
|
+
if (named)
|
|
341
|
+
assertActionAllowed(el, action, args);
|
|
342
|
+
const anchor = named ? anchorOf(el) : {};
|
|
630
343
|
const visible = isVisible(el);
|
|
631
344
|
const enabled = enabledOf(el);
|
|
632
345
|
const prevFocus = activeRef(el);
|
|
633
346
|
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
347
|
const geometry = CLICK_LIKE.has(action) ? clickGeometry(el) : NO_GEOMETRY;
|
|
637
|
-
// Read BEFORE dispatch, from the same predicate the dispatch branch uses.
|
|
638
348
|
const alreadyAtValue = alreadyAtCheckedState(el, action);
|
|
639
349
|
let mutated = 0;
|
|
640
350
|
const said = new AppearedText();
|
|
@@ -649,7 +359,6 @@ export async function executeAction(ref, action, args = {}) {
|
|
|
649
359
|
characterData: true,
|
|
650
360
|
});
|
|
651
361
|
let defaultPrevented = false;
|
|
652
|
-
/** How long the pointer was actually held down. Zero for every action that cannot hold. */
|
|
653
362
|
let heldMs = 0;
|
|
654
363
|
let settled = false;
|
|
655
364
|
let settleReason = null;
|
|
@@ -659,11 +368,8 @@ export async function executeAction(ref, action, args = {}) {
|
|
|
659
368
|
heldMs = outcome.heldMs;
|
|
660
369
|
}
|
|
661
370
|
finally {
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
const outcome = await settle();
|
|
371
|
+
const budget = asSettleBudget(args['settleMs']);
|
|
372
|
+
const outcome = await settle(budget);
|
|
667
373
|
settled = outcome.settled;
|
|
668
374
|
settleReason = outcome.settled ? null : SettleReason.TIMEOUT;
|
|
669
375
|
obs.disconnect();
|
|
@@ -672,37 +378,30 @@ export async function executeAction(ref, action, args = {}) {
|
|
|
672
378
|
const nextFocus = activeRef(el);
|
|
673
379
|
const effect = {
|
|
674
380
|
dispatched: true,
|
|
675
|
-
targetMatched: el.isConnected,
|
|
381
|
+
targetMatched: named && el.isConnected,
|
|
676
382
|
visible,
|
|
677
383
|
enabled,
|
|
678
384
|
defaultPrevented,
|
|
679
385
|
focusMoved: prevFocus !== nextFocus ? `${prevFocus ?? 'null'}->${nextFocus ?? 'null'}` : null,
|
|
680
386
|
valueChanged: isFillLike(action) ? valueBefore !== valueAfter : false,
|
|
681
387
|
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
388
|
...said.effect(valueAfter),
|
|
685
389
|
occluded: geometry.occluded,
|
|
686
390
|
occludedBy: geometry.occludedBy,
|
|
687
391
|
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
392
|
...(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
393
|
...(alreadyAtValue ? { alreadyAtValue: true } : {}),
|
|
694
394
|
};
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
395
|
+
const warning = !named
|
|
396
|
+
? ActionWarning.GLOBAL_PRESS
|
|
397
|
+
: geometry.occluded
|
|
398
|
+
? ActionWarning.CLICK_OCCLUDED
|
|
399
|
+
: action === ActionType.HOVER && elementHasHoverHandlers(el)
|
|
400
|
+
? ActionWarning.HOVER_NATIVE_ENTER_LEAVE
|
|
401
|
+
: undefined;
|
|
702
402
|
return result(ref, action, effect, settled, settleReason, anchor, warning);
|
|
703
403
|
}
|
|
704
404
|
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
405
|
export async function dispatchWebMcp(tool, params, confirmDangerous = false) {
|
|
707
406
|
if (requiresDangerousConfirmation(tool) && !confirmDangerous) {
|
|
708
407
|
throw new Error(`potentially destructive WebMCP tool blocked; retry with ${DANGEROUS_ACTION_CONFIRM_ARG}=true`);
|