@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.
Files changed (75) hide show
  1. package/dist/actions/actions-dom.d.ts +8 -1
  2. package/dist/actions/actions-dom.js +26 -12
  3. package/dist/actions/actions.d.ts +46 -0
  4. package/dist/actions/actions.js +114 -26
  5. package/dist/actions/appeared-text.d.ts +27 -0
  6. package/dist/actions/appeared-text.js +92 -0
  7. package/dist/actions/scroll.js +1 -1
  8. package/dist/commands/commands.js +45 -22
  9. package/dist/connect-options.d.ts +6 -0
  10. package/dist/dom/a11y.js +20 -20
  11. package/dist/dom/auto-anchor.js +1 -1
  12. package/dist/dom/blind-spots.js +1 -1
  13. package/dist/dom/chart.js +5 -5
  14. package/dist/dom/desktop-capture.d.ts +2 -1
  15. package/dist/dom/desktop-capture.js +4 -4
  16. package/dist/dom/occlusion.js +2 -2
  17. package/dist/dom/query.js +68 -25
  18. package/dist/dom/realm.js +13 -13
  19. package/dist/dom/refs.d.ts +14 -0
  20. package/dist/dom/refs.js +20 -1
  21. package/dist/dom/shadow-registry.js +2 -2
  22. package/dist/dom/snapshot.js +32 -11
  23. package/dist/dom/source.js +3 -3
  24. package/dist/dom/theme.js +6 -6
  25. package/dist/dom/virtualized.js +1 -1
  26. package/dist/observers/blind-spots.js +2 -2
  27. package/dist/observers/console.js +3 -3
  28. package/dist/observers/dom.js +5 -5
  29. package/dist/observers/download.d.ts +2 -1
  30. package/dist/observers/download.js +2 -2
  31. package/dist/observers/focus.js +1 -1
  32. package/dist/observers/frames.js +1 -1
  33. package/dist/observers/health.js +70 -2
  34. package/dist/observers/install-all.d.ts +2 -1
  35. package/dist/observers/ipc.d.ts +2 -7
  36. package/dist/observers/ipc.js +7 -7
  37. package/dist/observers/network-body.js +2 -2
  38. package/dist/observers/network-redact.js +5 -5
  39. package/dist/observers/network-stream.js +2 -2
  40. package/dist/observers/network.d.ts +3 -3
  41. package/dist/observers/network.js +9 -9
  42. package/dist/observers/perf.js +1 -1
  43. package/dist/observers/scroll.js +1 -1
  44. package/dist/observers/state.js +1 -1
  45. package/dist/observers/storage.js +7 -7
  46. package/dist/presenter/presenter-brand.d.ts +19 -0
  47. package/dist/presenter/presenter-brand.js +21 -0
  48. package/dist/presenter/presenter-config.d.ts +17 -0
  49. package/dist/presenter/presenter-config.js +21 -0
  50. package/dist/presenter/presenter-controls.d.ts +1 -11
  51. package/dist/presenter/presenter-controls.js +6 -7
  52. package/dist/presenter/presenter-log.js +2 -2
  53. package/dist/presenter/presenter-run-state.js +1 -1
  54. package/dist/presenter/presenter-styles.d.ts +1 -1
  55. package/dist/presenter/presenter-styles.js +20 -9
  56. package/dist/presenter/presenter-tally.js +3 -3
  57. package/dist/presenter/presenter-test-helpers.d.ts +2 -1
  58. package/dist/presenter/presenter-test-helpers.js +2 -2
  59. package/dist/presenter/presenter-verbs.js +16 -11
  60. package/dist/presenter/presenter.js +13 -10
  61. package/dist/recorder/recorder.js +10 -9
  62. package/dist/registry/store-adapters.js +1 -1
  63. package/dist/registry/stores.js +3 -3
  64. package/dist/reticle-presenter-helpers.js +3 -3
  65. package/dist/reticle.js +31 -13
  66. package/dist/review/annotator.js +7 -7
  67. package/dist/security/serialization.js +9 -9
  68. package/dist/session-continuity.d.ts +17 -0
  69. package/dist/session-continuity.js +42 -0
  70. package/dist/timers/clock.js +28 -15
  71. package/dist/timers/native-console.js +1 -1
  72. package/dist/timers/native-timers.js +4 -4
  73. package/dist/transport/transport.d.ts +23 -0
  74. package/dist/transport/transport.js +85 -25
  75. 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): boolean;
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 === 'function')
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 (win === null)
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 top === null
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 === 'function') {
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 === 'function') {
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 === 'function') {
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 === 'object' && entry !== null) {
104
+ if ('object' === typeof entry && entry !== null) {
91
105
  const e = entry;
92
- if (typeof e.mime === 'string' && typeof e.value === 'string')
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 === 'function' && type.startsWith('pointer')) {
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 === 'function') {
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
  }
@@ -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 === 'string' ? value : fallback;
10
+ return 'string' === typeof value ? value : fallback;
10
11
  }
11
12
  function requireElement(ref) {
12
13
  const el = refs.resolve(ref);
13
- if (el === null)
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 && name.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') === '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 (active === null || active === el.ownerDocument.body)
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
- const holdMs = typeof args['holdMs'] === 'number' ? args['holdMs'] : 0;
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
- // Fire a bubbling focusout so React 19's delegated root listener runs onBlur
179
- // (commit-on-blur). el.blur alone only works if the element was truly focused.
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
- el.dispatchEvent(new FocusEvent('blur'));
182
- el.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
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
- el.value = args['value'];
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 (form === null)
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
- defaultPrevented = await dispatchFor(el, action, args);
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
+ }
@@ -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 ((oy === 'auto' || oy === 'scroll') && cur.scrollHeight > cur.clientHeight)
9
+ if (('auto' === oy || 'scroll' === oy) && cur.scrollHeight > cur.clientHeight)
10
10
  return cur;
11
11
  cur = cur.parentElement;
12
12
  }