@reticlehq/browser 2.4.0 → 2.6.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 -1
- package/dist/actions/actions-dom.js +26 -12
- package/dist/actions/actions.d.ts +46 -0
- package/dist/actions/actions.js +114 -26
- package/dist/actions/appeared-text.d.ts +27 -0
- package/dist/actions/appeared-text.js +92 -0
- package/dist/actions/scroll.js +1 -1
- package/dist/commands/commands.js +45 -22
- package/dist/connect-options.d.ts +6 -0
- package/dist/dom/a11y.js +20 -20
- package/dist/dom/auto-anchor.js +1 -1
- package/dist/dom/blind-spots.js +1 -1
- package/dist/dom/chart.js +5 -5
- package/dist/dom/desktop-capture.d.ts +2 -1
- package/dist/dom/desktop-capture.js +4 -4
- package/dist/dom/occlusion.js +2 -2
- package/dist/dom/query.js +68 -25
- package/dist/dom/realm.js +13 -13
- package/dist/dom/refs.d.ts +14 -0
- package/dist/dom/refs.js +20 -1
- package/dist/dom/shadow-registry.js +2 -2
- package/dist/dom/snapshot.js +32 -11
- package/dist/dom/source.js +3 -3
- package/dist/dom/theme.js +6 -6
- package/dist/dom/virtualized.js +1 -1
- package/dist/observers/blind-spots.js +2 -2
- package/dist/observers/console.js +3 -3
- package/dist/observers/dom.js +5 -5
- package/dist/observers/download.d.ts +2 -1
- package/dist/observers/download.js +2 -2
- package/dist/observers/focus.js +1 -1
- package/dist/observers/frames.js +1 -1
- package/dist/observers/health.js +70 -2
- package/dist/observers/install-all.d.ts +2 -1
- package/dist/observers/ipc.d.ts +2 -7
- package/dist/observers/ipc.js +7 -7
- package/dist/observers/network-body.js +2 -2
- package/dist/observers/network-redact.js +5 -5
- package/dist/observers/network-stream.js +2 -2
- package/dist/observers/network.d.ts +3 -3
- package/dist/observers/network.js +9 -9
- package/dist/observers/perf.js +1 -1
- package/dist/observers/scroll.js +1 -1
- package/dist/observers/state.js +1 -1
- package/dist/observers/storage.js +7 -7
- package/dist/presenter/presenter-brand.d.ts +19 -0
- package/dist/presenter/presenter-brand.js +21 -0
- package/dist/presenter/presenter-config.d.ts +17 -0
- package/dist/presenter/presenter-config.js +21 -0
- package/dist/presenter/presenter-controls.d.ts +1 -11
- package/dist/presenter/presenter-controls.js +6 -7
- package/dist/presenter/presenter-log.js +2 -2
- package/dist/presenter/presenter-run-state.js +1 -1
- package/dist/presenter/presenter-styles.d.ts +1 -1
- package/dist/presenter/presenter-styles.js +20 -9
- package/dist/presenter/presenter-tally.js +3 -3
- package/dist/presenter/presenter-test-helpers.d.ts +2 -1
- package/dist/presenter/presenter-test-helpers.js +2 -2
- package/dist/presenter/presenter-verbs.js +16 -11
- package/dist/presenter/presenter.js +13 -10
- package/dist/recorder/recorder.js +10 -9
- package/dist/registry/store-adapters.js +1 -1
- package/dist/registry/stores.js +3 -3
- package/dist/reticle-presenter-helpers.js +3 -3
- package/dist/reticle.js +31 -13
- package/dist/review/annotator.js +7 -7
- package/dist/security/serialization.js +9 -9
- package/dist/session-continuity.d.ts +17 -0
- package/dist/session-continuity.js +42 -0
- package/dist/timers/clock.js +28 -15
- package/dist/timers/native-console.js +1 -1
- package/dist/timers/native-timers.js +4 -4
- package/dist/transport/transport.d.ts +23 -0
- package/dist/transport/transport.js +85 -25
- package/package.json +6 -4
|
@@ -10,7 +10,14 @@ export declare const NO_GEOMETRY: ClickGeometry;
|
|
|
10
10
|
* `defaultPrevented` so the probe is unchanged. Focus only moves for focusable targets (tabIndex>=0),
|
|
11
11
|
* so a plain <div> click still reports focusMoved=null.
|
|
12
12
|
*/
|
|
13
|
-
export declare function fireClickSequence(el: HTMLElement
|
|
13
|
+
export declare function fireClickSequence(el: HTMLElement, hold?: {
|
|
14
|
+
ms: number;
|
|
15
|
+
sleep: (ms: number) => Promise<void>;
|
|
16
|
+
now: () => number;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
prevented: boolean;
|
|
19
|
+
heldMs: number;
|
|
20
|
+
}>;
|
|
14
21
|
/**
|
|
15
22
|
* Click-like geometry honesty: scroll an off-viewport target into view, then hit-test the click
|
|
16
23
|
* point. Synthetic dispatch always reaches the target regardless — this is purely so the agent
|
|
@@ -12,17 +12,31 @@ export const NO_GEOMETRY = {
|
|
|
12
12
|
* `defaultPrevented` so the probe is unchanged. Focus only moves for focusable targets (tabIndex>=0),
|
|
13
13
|
* so a plain <div> click still reports focusMoved=null.
|
|
14
14
|
*/
|
|
15
|
-
export function fireClickSequence(el) {
|
|
15
|
+
export async function fireClickSequence(el, hold) {
|
|
16
16
|
const doc = el.ownerDocument;
|
|
17
17
|
const from = doc.activeElement ?? doc.body;
|
|
18
18
|
firePointer(el, 'pointerdown', from);
|
|
19
19
|
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
|
20
|
-
if (el.tabIndex >= 0 && typeof el.focus
|
|
20
|
+
if (el.tabIndex >= 0 && 'function' === typeof el.focus)
|
|
21
21
|
el.focus();
|
|
22
|
+
// The gap that makes hold-to-confirm driveable. Everything between down and up used to be
|
|
23
|
+
// synchronous, so a control whose contract is "the button is down for N ms" could not be
|
|
24
|
+
// expressed at all — the reported case cancelled its own confirm on the mouseup that arrived 7ms
|
|
25
|
+
// later. `drag` already splits the pair across a gap; this is that shape with a timer.
|
|
26
|
+
//
|
|
27
|
+
// The ACHIEVED hold is measured and returned rather than echoed back: `holdMs: 1200` against a
|
|
28
|
+
// 1200ms animation is a race by construction, and a caller needs to tell "held 1200" from
|
|
29
|
+
// "held 1204". A backgrounded tab throttles timers, so this can legitimately overshoot by a lot.
|
|
30
|
+
let heldMs = 0;
|
|
31
|
+
if (hold !== undefined && hold.ms > 0) {
|
|
32
|
+
const startedAt = hold.now();
|
|
33
|
+
await hold.sleep(hold.ms);
|
|
34
|
+
heldMs = hold.now() - startedAt;
|
|
35
|
+
}
|
|
22
36
|
firePointer(el, 'pointerup', from);
|
|
23
37
|
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
|
|
24
38
|
const notPrevented = el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
25
|
-
return !notPrevented;
|
|
39
|
+
return { prevented: !notPrevented, heldMs };
|
|
26
40
|
}
|
|
27
41
|
/** A box has layout we can reason about (jsdom returns an all-zero box — nothing to hit-test). */
|
|
28
42
|
function isMeasurable(rect) {
|
|
@@ -31,7 +45,7 @@ function isMeasurable(rect) {
|
|
|
31
45
|
/** The center of `rect` falls outside the visible viewport. */
|
|
32
46
|
function isOffViewport(el, rect) {
|
|
33
47
|
const win = el.ownerDocument.defaultView;
|
|
34
|
-
if (
|
|
48
|
+
if (null === win)
|
|
35
49
|
return false;
|
|
36
50
|
const cx = rect.left + rect.width / 2;
|
|
37
51
|
const cy = rect.top + rect.height / 2;
|
|
@@ -40,7 +54,7 @@ function isOffViewport(el, rect) {
|
|
|
40
54
|
/** Hit-test the center: occluded iff the top NON-Reticle element is a foreign subtree (not target/ancestor/descendant). */
|
|
41
55
|
function hitTest(el, rect) {
|
|
42
56
|
const top = hitTestOccluder(el, rect);
|
|
43
|
-
return
|
|
57
|
+
return null === top
|
|
44
58
|
? { occluded: false, occludedBy: null }
|
|
45
59
|
: { occluded: true, occludedBy: refs.refFor(top) };
|
|
46
60
|
}
|
|
@@ -56,7 +70,7 @@ export function clickGeometry(el) {
|
|
|
56
70
|
if (!isMeasurable(rect))
|
|
57
71
|
return NO_GEOMETRY;
|
|
58
72
|
let scrolledIntoView = false;
|
|
59
|
-
if (isOffViewport(el, rect) && typeof el.scrollIntoView
|
|
73
|
+
if (isOffViewport(el, rect) && 'function' === typeof el.scrollIntoView) {
|
|
60
74
|
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
61
75
|
scrolledIntoView = true;
|
|
62
76
|
rect = el.getBoundingClientRect();
|
|
@@ -64,7 +78,7 @@ export function clickGeometry(el) {
|
|
|
64
78
|
return { ...hitTest(el, rect), scrolledIntoView };
|
|
65
79
|
}
|
|
66
80
|
export function firePointer(el, type, relatedTarget = null) {
|
|
67
|
-
if (typeof PointerEvent
|
|
81
|
+
if ('function' === typeof PointerEvent) {
|
|
68
82
|
el.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, relatedTarget }));
|
|
69
83
|
}
|
|
70
84
|
else {
|
|
@@ -73,7 +87,7 @@ export function firePointer(el, type, relatedTarget = null) {
|
|
|
73
87
|
}
|
|
74
88
|
/** Enter/leave pointer events are non-bubbling per spec; keep them so to avoid double-firing. */
|
|
75
89
|
export function firePointerNonBubbling(el, type, relatedTarget = null) {
|
|
76
|
-
if (typeof PointerEvent
|
|
90
|
+
if ('function' === typeof PointerEvent) {
|
|
77
91
|
el.dispatchEvent(new PointerEvent(type, { bubbles: false, cancelable: true, relatedTarget }));
|
|
78
92
|
}
|
|
79
93
|
else {
|
|
@@ -87,9 +101,9 @@ function makeDataTransfer(data) {
|
|
|
87
101
|
// data: { mime, value } or [{ mime, value }, …]
|
|
88
102
|
const entries = Array.isArray(data) ? data : data !== undefined ? [data] : [];
|
|
89
103
|
for (const entry of entries) {
|
|
90
|
-
if (typeof entry
|
|
104
|
+
if ('object' === typeof entry && entry !== null) {
|
|
91
105
|
const e = entry;
|
|
92
|
-
if (typeof e.mime
|
|
106
|
+
if ('string' === typeof e.mime && 'string' === typeof e.value)
|
|
93
107
|
dt.setData(e.mime, e.value);
|
|
94
108
|
}
|
|
95
109
|
}
|
|
@@ -102,7 +116,7 @@ function makeDataTransfer(data) {
|
|
|
102
116
|
export async function dragElement(source, target, data) {
|
|
103
117
|
const dest = target ?? source;
|
|
104
118
|
const fire = (el, type) => {
|
|
105
|
-
if (typeof PointerEvent
|
|
119
|
+
if ('function' === typeof PointerEvent && type.startsWith('pointer')) {
|
|
106
120
|
el.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true }));
|
|
107
121
|
}
|
|
108
122
|
else {
|
|
@@ -118,7 +132,7 @@ export async function dragElement(source, target, data) {
|
|
|
118
132
|
fire(dest, 'pointerup');
|
|
119
133
|
fire(dest, 'mouseup');
|
|
120
134
|
let dropPrevented = false;
|
|
121
|
-
if (typeof DragEvent
|
|
135
|
+
if ('function' === typeof DragEvent) {
|
|
122
136
|
const dataTransfer = makeDataTransfer(data);
|
|
123
137
|
const init = { bubbles: true, cancelable: true };
|
|
124
138
|
if (dataTransfer !== null)
|
|
@@ -21,6 +21,18 @@ interface ActionEffect {
|
|
|
21
21
|
valueChanged: boolean;
|
|
22
22
|
/** Mutation records counted by a short-lived MutationObserver (one microtask + rAF window). */
|
|
23
23
|
domMutatedWithin: number;
|
|
24
|
+
/**
|
|
25
|
+
* Text the action put on the page, truncated. OMITTED when it added none.
|
|
26
|
+
*
|
|
27
|
+
* `domMutatedWithin` says the click did SOMETHING; it cannot say the app rejected you. A login
|
|
28
|
+
* that fails returns ok/settled/mutated and reads exactly like one that succeeded, so an agent
|
|
29
|
+
* moves on and asserts against a page it misread. The observer already receives these records
|
|
30
|
+
* and was keeping only `.length` — the message was being thrown away, not gathered.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately NOT a verdict: this is what appeared, not what it means. Absence means no text
|
|
33
|
+
* was added, which is why a silent class toggle omits the key rather than reporting "".
|
|
34
|
+
*/
|
|
35
|
+
appeared?: string;
|
|
24
36
|
/**
|
|
25
37
|
* Click-like only: the center hit-tested to a foreign element (an overlay is on top). Synthetic
|
|
26
38
|
* dispatch STILL delivered the event to the target, but a real user could not click it — treat
|
|
@@ -31,6 +43,14 @@ interface ActionEffect {
|
|
|
31
43
|
occludedBy: string | null;
|
|
32
44
|
/** Click-like only: the target was off-viewport, so it was scrolled into view before dispatch. */
|
|
33
45
|
scrolledIntoView: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* How long the pointer was ACTUALLY held down, for a click with `args.holdMs`. Absent otherwise.
|
|
48
|
+
*
|
|
49
|
+
* Measured, not echoed. `holdMs: 1200` against a 1200ms animation is a race by construction, so a
|
|
50
|
+
* caller needs to tell "held 1200" from "held 1204" — and a backgrounded tab throttles timers, so
|
|
51
|
+
* the achieved hold can legitimately be much longer than the one requested.
|
|
52
|
+
*/
|
|
53
|
+
heldMs?: number;
|
|
34
54
|
}
|
|
35
55
|
interface ActionResult {
|
|
36
56
|
ok: true;
|
|
@@ -62,6 +82,19 @@ interface ActionResult {
|
|
|
62
82
|
/** Best-effort caveat the agent should heed (e.g. synthetic hover may not fire enter/leave). */
|
|
63
83
|
warning?: string;
|
|
64
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Dispatch the action's events. Returns `defaultPrevented` of the primary cancelable event
|
|
87
|
+
* (false for actions whose primary event is non-cancelable or unobservable). Drag/hover-hold
|
|
88
|
+
* await internally; the probe wrapper builds the result once they resolve.
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* The longest a single action may hold the pointer down, in milliseconds.
|
|
92
|
+
*
|
|
93
|
+
* An unbounded `holdMs` is a tool call that never returns, which is the failure the transport layer
|
|
94
|
+
* is shaped around. 30s is far above any real hold-to-confirm (the reported case was 1.2s) and far
|
|
95
|
+
* below a hang.
|
|
96
|
+
*/
|
|
97
|
+
export declare const MAX_HOLD_MS = 30000;
|
|
65
98
|
/**
|
|
66
99
|
* Execute a single action against a ref and probe for best-effort evidence of effect.
|
|
67
100
|
* Always async: the MutationObserver read needs a microtask + rAF after dispatch.
|
|
@@ -81,7 +114,20 @@ interface SequenceStepResult {
|
|
|
81
114
|
dispatched: boolean;
|
|
82
115
|
settled: boolean;
|
|
83
116
|
settleReason: SettleReason | null;
|
|
117
|
+
/**
|
|
118
|
+
* The SAME anchors a single act reports. Reporting only the testid meant a testid-less app
|
|
119
|
+
* compiled every sub-step to a volatile ref, so the saved flow carried the degraded sentinel and
|
|
120
|
+
* every replay drifted.
|
|
121
|
+
*/
|
|
84
122
|
testid?: string;
|
|
123
|
+
component?: string;
|
|
124
|
+
role?: string;
|
|
125
|
+
name?: string;
|
|
126
|
+
source?: {
|
|
127
|
+
file: string;
|
|
128
|
+
line: number;
|
|
129
|
+
column?: number;
|
|
130
|
+
};
|
|
85
131
|
/** best-effort caveat for this step (e.g. synthetic hover may not fire enter/leave). */
|
|
86
132
|
warning?: string;
|
|
87
133
|
}
|
package/dist/actions/actions.js
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { ActionType, ActionWarning, DANGEROUS_ACTION_CONFIRM_ARG, ElementState, isDangerousActionText, SettleReason, } from '@reticlehq/core';
|
|
2
|
-
import { refs } from '../dom/refs.js';
|
|
2
|
+
import { echoRef, refs } from '../dom/refs.js';
|
|
3
3
|
import { assertEditable, assertNotRichText, setNativeValue } from './value-input.js';
|
|
4
4
|
import { getAccessibleName, getRole, isVisible, getStates } from '../dom/a11y.js';
|
|
5
5
|
import { elementHasHoverHandlers, identifyComponent } from '../registry/adapters.js';
|
|
6
6
|
import { isForm, isHtmlElement, isInput, isSelect, isTextArea } from '../dom/realm.js';
|
|
7
7
|
import { nativeSetTimeout, settle } from '../timers/native-timers.js';
|
|
8
|
+
import { AppearedText } from './appeared-text.js';
|
|
8
9
|
function asString(value, fallback = '') {
|
|
9
|
-
return typeof value
|
|
10
|
+
return 'string' === typeof value ? value : fallback;
|
|
10
11
|
}
|
|
11
12
|
function requireElement(ref) {
|
|
12
13
|
const el = refs.resolve(ref);
|
|
13
|
-
if (
|
|
14
|
-
throw new Error(`ref '${ref}' no longer resolves to an element`);
|
|
14
|
+
if (null === el)
|
|
15
|
+
throw new Error(`ref '${echoRef(ref)}' no longer resolves to an element`);
|
|
15
16
|
if (!isHtmlElement(el))
|
|
16
|
-
throw new Error(`ref '${ref}' is not an HTMLElement`);
|
|
17
|
+
throw new Error(`ref '${echoRef(ref)}' is not an HTMLElement`);
|
|
17
18
|
return el;
|
|
18
19
|
}
|
|
19
20
|
/**
|
|
@@ -39,12 +40,20 @@ function anchorOf(el) {
|
|
|
39
40
|
out.component = component;
|
|
40
41
|
if (info?.source !== undefined)
|
|
41
42
|
out.source = info.source;
|
|
43
|
+
// Reported INDEPENDENTLY. They used to be all-or-nothing, so an element with a role and no
|
|
44
|
+
// accessible name — an icon button, a clickable div, a control labelled by an SVG — came back with
|
|
45
|
+
// no identity at all once identifyComponent (React-specific) also found nothing. Two features read
|
|
46
|
+
// this and both silently degrade without it: the flow recorder anchors a step by it, and coverage
|
|
47
|
+
// recognises a control it already drove across a re-render by it.
|
|
48
|
+
//
|
|
49
|
+
// A role alone is not a unique anchor, and the flow compiler still requires role AND name before it
|
|
50
|
+
// will anchor a step that way. But it is real information, and reporting it beats reporting nothing.
|
|
42
51
|
const role = getRole(el);
|
|
43
52
|
const name = getAccessibleName(el);
|
|
44
|
-
if (role.length > 0
|
|
53
|
+
if (role.length > 0)
|
|
45
54
|
out.role = role;
|
|
55
|
+
if (name.length > 0)
|
|
46
56
|
out.name = name;
|
|
47
|
-
}
|
|
48
57
|
return out;
|
|
49
58
|
}
|
|
50
59
|
const result = (ref, action, effect, settled, settleReason, anchor, warning) => {
|
|
@@ -108,7 +117,7 @@ function assertActionAllowed(el, action, args) {
|
|
|
108
117
|
action === ActionType.DBLCLICK ||
|
|
109
118
|
action === ActionType.DRAG ||
|
|
110
119
|
action === ActionType.SUBMIT ||
|
|
111
|
-
(action === ActionType.PRESS && asString(args['key'], 'Enter')
|
|
120
|
+
(action === ActionType.PRESS && 'Enter' === asString(args['key'], 'Enter'));
|
|
112
121
|
const dragTarget = action === ActionType.DRAG ? refs.resolve(asString(args['toRef'])) : null;
|
|
113
122
|
const context = isHtmlElement(dragTarget)
|
|
114
123
|
? `${dangerousActionContext(el)} ${dangerousActionContext(dragTarget)}`
|
|
@@ -135,7 +144,7 @@ function valueOf(el) {
|
|
|
135
144
|
/** Ref of the currently-focused element, treating body/null as "no focus". */
|
|
136
145
|
function activeRef(el) {
|
|
137
146
|
const active = el.ownerDocument.activeElement;
|
|
138
|
-
if (
|
|
147
|
+
if (null === active || active === el.ownerDocument.body)
|
|
139
148
|
return null;
|
|
140
149
|
return refs.refFor(active);
|
|
141
150
|
}
|
|
@@ -144,12 +153,38 @@ function activeRef(el) {
|
|
|
144
153
|
* (false for actions whose primary event is non-cancelable or unobservable). Drag/hover-hold
|
|
145
154
|
* await internally; the probe wrapper builds the result once they resolve.
|
|
146
155
|
*/
|
|
156
|
+
/**
|
|
157
|
+
* The longest a single action may hold the pointer down, in milliseconds.
|
|
158
|
+
*
|
|
159
|
+
* An unbounded `holdMs` is a tool call that never returns, which is the failure the transport layer
|
|
160
|
+
* is shaped around. 30s is far above any real hold-to-confirm (the reported case was 1.2s) and far
|
|
161
|
+
* below a hang.
|
|
162
|
+
*/
|
|
163
|
+
export const MAX_HOLD_MS = 30_000;
|
|
164
|
+
/** A hold the caller asked for, bounded and sanitised. Non-numbers and negatives mean "no hold". */
|
|
165
|
+
function clampHold(raw) {
|
|
166
|
+
if ('number' !== typeof raw || !Number.isFinite(raw) || raw <= 0)
|
|
167
|
+
return 0;
|
|
168
|
+
return Math.min(raw, MAX_HOLD_MS);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Click is the only action that can hold the pointer down, so it is the only one that reports a
|
|
172
|
+
* duration. Everything else routes through the switch below unchanged and holds for zero.
|
|
173
|
+
*/
|
|
147
174
|
async function dispatchFor(el, action, args) {
|
|
175
|
+
if (ActionType.CLICK === action) {
|
|
176
|
+
// Full pointer/mouse sequence (not a bare click) so pointer- and focus-gated handlers fire the
|
|
177
|
+
// way they do for a real user.
|
|
178
|
+
//
|
|
179
|
+
// `args.holdMs` keeps the pointer down in between, which is the only way to drive a
|
|
180
|
+
// hold-to-confirm control. Capped: an unbounded hold is a tool call that never returns.
|
|
181
|
+
const hold = clampHold(args['holdMs']);
|
|
182
|
+
return await fireClickSequence(el, 0 === hold ? undefined : { ms: hold, sleep, now: () => Date.now() });
|
|
183
|
+
}
|
|
184
|
+
return { prevented: await dispatchOther(el, action, args), heldMs: 0 };
|
|
185
|
+
}
|
|
186
|
+
async function dispatchOther(el, action, args) {
|
|
148
187
|
switch (action) {
|
|
149
|
-
case ActionType.CLICK:
|
|
150
|
-
// Full pointer/mouse sequence (not a bare click) so pointer- and focus-gated handlers fire
|
|
151
|
-
// the way they do for a real user. Returns the click event's defaultPrevented.
|
|
152
|
-
return fireClickSequence(el);
|
|
153
188
|
case ActionType.DBLCLICK:
|
|
154
189
|
return !el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
|
|
155
190
|
case ActionType.HOVER: {
|
|
@@ -164,8 +199,9 @@ async function dispatchFor(el, action, args) {
|
|
|
164
199
|
el.dispatchEvent(new MouseEvent('mouseenter', { relatedTarget: from }));
|
|
165
200
|
firePointer(el, 'pointermove', from);
|
|
166
201
|
const moved = el.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true }));
|
|
167
|
-
// hover-dwell: keep "hovering" for holdMs so timer-gated reveals can mount.
|
|
168
|
-
|
|
202
|
+
// hover-dwell: keep "hovering" for holdMs so timer-gated reveals can mount. Same cap as
|
|
203
|
+
// click — this arg was previously unbounded here, which is the same never-returns hazard.
|
|
204
|
+
const holdMs = clampHold(args['holdMs']);
|
|
169
205
|
if (holdMs > 0)
|
|
170
206
|
await sleep(holdMs);
|
|
171
207
|
return !moved;
|
|
@@ -174,13 +210,31 @@ async function dispatchFor(el, action, args) {
|
|
|
174
210
|
el.focus();
|
|
175
211
|
el.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
|
176
212
|
return false; // FocusEvents are not cancelable.
|
|
177
|
-
case ActionType.BLUR:
|
|
178
|
-
//
|
|
179
|
-
//
|
|
213
|
+
case ActionType.BLUR: {
|
|
214
|
+
// React 19 listens for `focusout` at the ROOT, so a commit-on-blur handler needs a BUBBLING
|
|
215
|
+
// event to reach it — and `el.blur()` is a no-op on an element that was never focused, which
|
|
216
|
+
// is why the synthetic pair exists at all.
|
|
217
|
+
//
|
|
218
|
+
// But on a FOCUSED element `el.blur()` already dispatches `blur` and a bubbling `focusout`
|
|
219
|
+
// natively. Dispatching them again meant React ran `onBlur` TWICE, and Reticle reported the
|
|
220
|
+
// resulting double write as a `duplicate-request` contradiction — a defect we invented and
|
|
221
|
+
// handed to a human as real. Reported from the field on React 19 + Vite with a single
|
|
222
|
+
// `onBlur={() => mutate(...)}`, one render site and no StrictMode.
|
|
223
|
+
//
|
|
224
|
+
// So synthesize ONLY when nothing native will fire. Read before the call, because `blur()` is
|
|
225
|
+
// what changes it — and read off the element's own ROOT: `document.activeElement` reports the
|
|
226
|
+
// shadow HOST for an element inside a shadow root, so an element that genuinely was focused
|
|
227
|
+
// would read as unfocused and take the double-dispatch path this exists to prevent. Both
|
|
228
|
+
// Document and ShadowRoot expose `activeElement`, so one expression covers both.
|
|
229
|
+
const root = el.getRootNode();
|
|
230
|
+
const wasFocused = root.activeElement === el;
|
|
180
231
|
el.blur();
|
|
181
|
-
|
|
182
|
-
|
|
232
|
+
if (!wasFocused) {
|
|
233
|
+
el.dispatchEvent(new FocusEvent('blur'));
|
|
234
|
+
el.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
|
235
|
+
}
|
|
183
236
|
return false;
|
|
237
|
+
}
|
|
184
238
|
case ActionType.FILL:
|
|
185
239
|
if (isInput(el) || isTextArea(el)) {
|
|
186
240
|
// A MISSING value is a malformed call, not a request to empty the field. `asString` defaults
|
|
@@ -224,13 +278,27 @@ async function dispatchFor(el, action, args) {
|
|
|
224
278
|
if (isSelect(el)) {
|
|
225
279
|
// A MISSING value assigned '' to the <select>. No option carries that, so the browser sets
|
|
226
280
|
// selectedIndex to -1 — deselecting everything — from a call that was simply malformed.
|
|
227
|
-
// (Selecting a value that no option carries is a different case and is deliberately NOT
|
|
228
|
-
// refused: `actions.effect.test.ts` pins it as a detectable no-op, where the reported
|
|
229
|
-
// valueChanged delta is what tells the agent the option never took.)
|
|
230
281
|
if (typeof args['value'] !== 'string') {
|
|
231
282
|
throw new Error("select requires a string `value` — pass it nested, as args: { value: '…' }.");
|
|
232
283
|
}
|
|
233
|
-
|
|
284
|
+
const wanted = args['value'];
|
|
285
|
+
const options = Array.from(el.options);
|
|
286
|
+
// REFUSE an option that does not exist, before touching the element.
|
|
287
|
+
//
|
|
288
|
+
// This used to assign anyway and report the resulting `valueChanged` delta as proof the
|
|
289
|
+
// option never took. That only works if nobody is listening. An unmatched value drives
|
|
290
|
+
// selectedIndex to -1, so `el.value` becomes '' — and we then dispatched `change`. Reported
|
|
291
|
+
// from a real session: the app read that empty value in its change handler and persisted it,
|
|
292
|
+
// corrupting a stored language setting. The "detectable no-op" both mutated the app into a
|
|
293
|
+
// state no user could reach AND did it through the app's own handler.
|
|
294
|
+
//
|
|
295
|
+
// Same rule as the readonly/disabled refusal: if a real user could not do it, forcing it is
|
|
296
|
+
// not a test, it is damage.
|
|
297
|
+
if (!options.some((option) => option.value === wanted)) {
|
|
298
|
+
const available = options.map((option) => `${option.value} (${option.label})`).join(', ');
|
|
299
|
+
throw new Error(`no <option> with value '${echoRef(wanted)}' — available: ${0 === options.length ? '(none)' : available}`);
|
|
300
|
+
}
|
|
301
|
+
el.value = wanted;
|
|
234
302
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
235
303
|
return false; // change is not cancelable.
|
|
236
304
|
}
|
|
@@ -245,7 +313,7 @@ async function dispatchFor(el, action, args) {
|
|
|
245
313
|
throw new Error(`cannot (un)check a <${el.tagName.toLowerCase()}>`);
|
|
246
314
|
case ActionType.SUBMIT: {
|
|
247
315
|
const form = isForm(el) ? el : el.closest('form');
|
|
248
|
-
if (
|
|
316
|
+
if (null === form)
|
|
249
317
|
throw new Error('no form to submit');
|
|
250
318
|
form.requestSubmit();
|
|
251
319
|
return false; // requestSubmit returns void; the internal submit event is unobservable.
|
|
@@ -301,8 +369,10 @@ export async function executeAction(ref, action, args = {}) {
|
|
|
301
369
|
// mutation observer (scroll/hit-test never mutate the DOM, but keep the probe window clean).
|
|
302
370
|
const geometry = CLICK_LIKE.has(action) ? clickGeometry(el) : NO_GEOMETRY;
|
|
303
371
|
let mutated = 0;
|
|
372
|
+
const said = new AppearedText();
|
|
304
373
|
const obs = new MutationObserver((records) => {
|
|
305
374
|
mutated += records.length;
|
|
375
|
+
said.collect(records);
|
|
306
376
|
});
|
|
307
377
|
obs.observe(el.ownerDocument.documentElement, {
|
|
308
378
|
subtree: true,
|
|
@@ -311,10 +381,14 @@ export async function executeAction(ref, action, args = {}) {
|
|
|
311
381
|
characterData: true,
|
|
312
382
|
});
|
|
313
383
|
let defaultPrevented = false;
|
|
384
|
+
/** How long the pointer was actually held down. Zero for every action that cannot hold. */
|
|
385
|
+
let heldMs = 0;
|
|
314
386
|
let settled = false;
|
|
315
387
|
let settleReason = null;
|
|
316
388
|
try {
|
|
317
|
-
|
|
389
|
+
const outcome = await dispatchFor(el, action, args);
|
|
390
|
+
defaultPrevented = outcome.prevented;
|
|
391
|
+
heldMs = outcome.heldMs;
|
|
318
392
|
}
|
|
319
393
|
finally {
|
|
320
394
|
// bounded settle — microtask + one BOUNDED frame so React's commit (and the resulting DOM
|
|
@@ -337,9 +411,15 @@ export async function executeAction(ref, action, args = {}) {
|
|
|
337
411
|
focusMoved: prevFocus !== nextFocus ? `${prevFocus ?? 'null'}->${nextFocus ?? 'null'}` : null,
|
|
338
412
|
valueChanged: isFillLike(action) ? valueBefore !== valueAfter : false,
|
|
339
413
|
domMutatedWithin: mutated,
|
|
414
|
+
// Omitted rather than "" when nothing was said — an empty string reads as "it said nothing
|
|
415
|
+
// meaningful", where absence says "it added no text at all". Those are different findings.
|
|
416
|
+
...said.effect(valueAfter),
|
|
340
417
|
occluded: geometry.occluded,
|
|
341
418
|
occludedBy: geometry.occludedBy,
|
|
342
419
|
scrolledIntoView: geometry.scrolledIntoView,
|
|
420
|
+
// Omitted when there was no hold: an absent key says "this action does not hold", where a 0
|
|
421
|
+
// would read as "it held for no time", which is a different and misleading claim.
|
|
422
|
+
...(heldMs > 0 ? { heldMs } : {}),
|
|
343
423
|
};
|
|
344
424
|
// Honesty caveats: a visually-occluded click is reported even though synthetic dispatch landed;
|
|
345
425
|
// else synthetic hover may not fire framework enter/leave handlers (no native hit-test).
|
|
@@ -377,6 +457,14 @@ export async function executeSequence(steps) {
|
|
|
377
457
|
};
|
|
378
458
|
if (res.testid !== undefined)
|
|
379
459
|
stepBase.testid = res.testid;
|
|
460
|
+
if (res.component !== undefined)
|
|
461
|
+
stepBase.component = res.component;
|
|
462
|
+
if (res.role !== undefined)
|
|
463
|
+
stepBase.role = res.role;
|
|
464
|
+
if (res.name !== undefined)
|
|
465
|
+
stepBase.name = res.name;
|
|
466
|
+
if (res.source !== undefined)
|
|
467
|
+
stepBase.source = res.source;
|
|
380
468
|
if (res.warning !== undefined)
|
|
381
469
|
stepBase.warning = res.warning;
|
|
382
470
|
stepResults.push(stepBase);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gathers the text an action put on the page, from mutation records the observer already receives.
|
|
3
|
+
*
|
|
4
|
+
* `domMutatedWithin` counts records and throws their content away, so a failed login reports
|
|
5
|
+
* `ok / settled / mutated` and reads exactly like a successful one. The message the app rendered
|
|
6
|
+
* was passing through this callback the whole time.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately reports what appeared, never what it means: no error/success classification, no
|
|
9
|
+
* guessing which fragment matters. Truncated, because a route change can add a whole page.
|
|
10
|
+
*/
|
|
11
|
+
export declare class AppearedText {
|
|
12
|
+
#private;
|
|
13
|
+
collect(records: readonly MutationRecord[]): void;
|
|
14
|
+
/**
|
|
15
|
+
* `{ appeared }` when the APP added text, `{}` otherwise — an absent key means it added none.
|
|
16
|
+
*
|
|
17
|
+
* `wrote` is the value the action itself just set, and is excluded: a textarea carries its value
|
|
18
|
+
* in a child text node, so a controlled one re-rendering after the write mutates characterData
|
|
19
|
+
* with the caller's own string. Handing that back is noise wearing the name of evidence, and
|
|
20
|
+
* `valueChanged` already reports that the write landed. Exact-match only, so an app that quotes
|
|
21
|
+
* your input inside a sentence of its own ("No results for zzz") is still reported — that is
|
|
22
|
+
* the app talking.
|
|
23
|
+
*/
|
|
24
|
+
effect(wrote?: string): {
|
|
25
|
+
appeared?: string;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { isIgnored } from '../dom/dom-ignore.js';
|
|
2
|
+
/** Cap on the reported text. Enough for an error message, far short of a re-rendered page. */
|
|
3
|
+
const APPEARED_MAX = 200;
|
|
4
|
+
/** Separator between distinct fragments that appeared in the same window. */
|
|
5
|
+
const JOIN = ' | ';
|
|
6
|
+
const TEXT_NODE = 3;
|
|
7
|
+
/** Any letter, in any script — Latin, Cyrillic, CJK, Arabic. */
|
|
8
|
+
const HAS_LETTER = /\p{L}/u;
|
|
9
|
+
/**
|
|
10
|
+
* A fragment with no letters at all is not the app saying something.
|
|
11
|
+
*
|
|
12
|
+
* The Hostile fixture mutates a counter every 16ms, and clicking a button on that page reported
|
|
13
|
+
* `appeared: "409"` — the ticker, not the action's effect. A count-up animation is exactly what
|
|
14
|
+
* emits a bare number into the settle window, and a bare number carries no message a reader can
|
|
15
|
+
* act on. Deliberately conservative: it drops only fragments with NO letters, so "status 500",
|
|
16
|
+
* "3 items deleted" and "Could not save" all survive. An app whose only feedback is a naked
|
|
17
|
+
* numeral loses it here — the snapshot still shows it, and that is the cheaper mistake.
|
|
18
|
+
*/
|
|
19
|
+
function saysSomething(text) {
|
|
20
|
+
return HAS_LETTER.test(text);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Gathers the text an action put on the page, from mutation records the observer already receives.
|
|
24
|
+
*
|
|
25
|
+
* `domMutatedWithin` counts records and throws their content away, so a failed login reports
|
|
26
|
+
* `ok / settled / mutated` and reads exactly like a successful one. The message the app rendered
|
|
27
|
+
* was passing through this callback the whole time.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately reports what appeared, never what it means: no error/success classification, no
|
|
30
|
+
* guessing which fragment matters. Truncated, because a route change can add a whole page.
|
|
31
|
+
*/
|
|
32
|
+
export class AppearedText {
|
|
33
|
+
#seen = new Set();
|
|
34
|
+
#length = 0;
|
|
35
|
+
collect(records) {
|
|
36
|
+
for (const record of records) {
|
|
37
|
+
if (this.#full())
|
|
38
|
+
return;
|
|
39
|
+
if ('characterData' === record.type) {
|
|
40
|
+
// The NEW value; oldValue is not requested, so this is what the reader now sees.
|
|
41
|
+
this.#add(record.target.textContent, record.target.parentElement);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
for (const node of record.addedNodes) {
|
|
45
|
+
if (this.#full())
|
|
46
|
+
return;
|
|
47
|
+
const owner = TEXT_NODE === node.nodeType ? node.parentElement : elementOf(node);
|
|
48
|
+
this.#add(node.textContent, owner);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* `{ appeared }` when the APP added text, `{}` otherwise — an absent key means it added none.
|
|
54
|
+
*
|
|
55
|
+
* `wrote` is the value the action itself just set, and is excluded: a textarea carries its value
|
|
56
|
+
* in a child text node, so a controlled one re-rendering after the write mutates characterData
|
|
57
|
+
* with the caller's own string. Handing that back is noise wearing the name of evidence, and
|
|
58
|
+
* `valueChanged` already reports that the write landed. Exact-match only, so an app that quotes
|
|
59
|
+
* your input inside a sentence of its own ("No results for zzz") is still reported — that is
|
|
60
|
+
* the app talking.
|
|
61
|
+
*/
|
|
62
|
+
effect(wrote) {
|
|
63
|
+
const said = [...this.#seen].filter((text) => text !== wrote);
|
|
64
|
+
if (0 === said.length)
|
|
65
|
+
return {};
|
|
66
|
+
const joined = said.join(JOIN);
|
|
67
|
+
return {
|
|
68
|
+
appeared: joined.length > APPEARED_MAX ? `${joined.slice(0, APPEARED_MAX)}…` : joined,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
#full() {
|
|
72
|
+
return this.#length > APPEARED_MAX;
|
|
73
|
+
}
|
|
74
|
+
#add(raw, owner) {
|
|
75
|
+
// Reticle's own overlay mutates constantly (the "live" panel); reporting it as the app's
|
|
76
|
+
// response would be worse than reporting nothing.
|
|
77
|
+
if (null !== owner && isIgnored(owner))
|
|
78
|
+
return;
|
|
79
|
+
const text = (raw ?? '').replace(/\s+/g, ' ').trim();
|
|
80
|
+
if (0 === text.length)
|
|
81
|
+
return;
|
|
82
|
+
if (!saysSomething(text))
|
|
83
|
+
return;
|
|
84
|
+
if (this.#seen.has(text))
|
|
85
|
+
return;
|
|
86
|
+
this.#seen.add(text);
|
|
87
|
+
this.#length += text.length + JOIN.length;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function elementOf(node) {
|
|
91
|
+
return node instanceof Element ? node : node.parentElement;
|
|
92
|
+
}
|
package/dist/actions/scroll.js
CHANGED
|
@@ -6,7 +6,7 @@ function nearestScrollable(el) {
|
|
|
6
6
|
let cur = el;
|
|
7
7
|
while (cur !== null && cur !== document.body) {
|
|
8
8
|
const oy = getComputedStyle(cur).overflowY;
|
|
9
|
-
if ((
|
|
9
|
+
if (('auto' === oy || 'scroll' === oy) && cur.scrollHeight > cur.clientHeight)
|
|
10
10
|
return cur;
|
|
11
11
|
cur = cur.parentElement;
|
|
12
12
|
}
|