@reticlehq/server 3.8.1 → 3.8.2

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.
@@ -0,0 +1,6 @@
1
+ import type { DriveErrorCode } from '@reticlehq/core';
2
+ /** Structured, code-tagged failure so callers branch on cause, not message text. */
3
+ export declare class DriveError extends Error {
4
+ readonly code: DriveErrorCode;
5
+ constructor(code: DriveErrorCode, message: string);
6
+ }
@@ -0,0 +1,9 @@
1
+ /** Structured, code-tagged failure so callers branch on cause, not message text. */
2
+ export class DriveError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = 'DriveError';
8
+ }
9
+ }
@@ -0,0 +1,34 @@
1
+ import type { Page } from 'playwright';
2
+ import { InputModeReason } from '@reticlehq/core';
3
+ import type { RealInputArgs } from './real-input.js';
4
+ type SleepFn = (ms: number) => Promise<void>;
5
+ /**
6
+ * Drive a `press` with a real keyboard — coordinate-free; the key goes to whatever holds focus.
7
+ * Key and modifiers come from the same core helpers the synthetic dispatcher uses, so the two paths
8
+ * cannot read the same args differently.
9
+ *
10
+ * A `holdMs` splits the chord into down / wait / up, the same shape the page uses for a held key.
11
+ * `keyboard.press` would send it instantly instead, reporting a hold-to-confirm that never held.
12
+ * The one thing this cannot reproduce is the auto-repeat a held key emits: the driver sends a single
13
+ * keydown, where the in-page path sends the repeats a browser would. Named rather than papered over
14
+ * — an app that counts repeats needs the synthetic path, and a caller that sees a held key do
15
+ * nothing now has a reason to try it.
16
+ */
17
+ export declare function pressViaKeyboard(page: Page, args: RealInputArgs, sleep: SleepFn, now: () => number): Promise<number>;
18
+ /**
19
+ * The `press`es this module will NOT drive: a chord that only the in-page dispatcher can express.
20
+ *
21
+ * `args.keys` asks for several keys held down TOGETHER, pressed in order and released in reverse.
22
+ * That is not a `page.keyboard.press` chord — a non-modifier key cannot appear before the last `+`
23
+ * — and a real keyboard cannot aim a key at a named element. `args.code` is the third: the driver
24
+ * presses by KEY name and offers no way to send a physical `code` that disagrees with it, so
25
+ * `{ text: 'z', code: 'KeyY' }` would strike a different key than the one asked for. All three are
26
+ * routed to the synthetic path by the caller, which reports the reason rather than pretending a
27
+ * different gesture happened.
28
+ *
29
+ * Ordered by how specifically the caller asked: `keys` then `code` name the KEY, `ref` names only
30
+ * the target. Two can apply at once, and the earlier one is the more unusual request — the reason
31
+ * should name that, not the generic "you passed a ref".
32
+ */
33
+ export declare function unspellablePressReason(ref: string, args: Record<string, unknown>): InputModeReason | undefined;
34
+ export {};
@@ -0,0 +1,150 @@
1
+ import { clampHoldMs, DriveErrorCode, explicitCodeFromArgs, InputModeReason, pressKeyFromArgs, pressKeysFromArgs, pressModifiersFromArgs, } from '@reticlehq/core';
2
+ import { DriveError } from './drive-error.js';
3
+ /**
4
+ * Drive a `press` with a real keyboard — coordinate-free; the key goes to whatever holds focus.
5
+ * Key and modifiers come from the same core helpers the synthetic dispatcher uses, so the two paths
6
+ * cannot read the same args differently.
7
+ *
8
+ * A `holdMs` splits the chord into down / wait / up, the same shape the page uses for a held key.
9
+ * `keyboard.press` would send it instantly instead, reporting a hold-to-confirm that never held.
10
+ * The one thing this cannot reproduce is the auto-repeat a held key emits: the driver sends a single
11
+ * keydown, where the in-page path sends the repeats a browser would. Named rather than papered over
12
+ * — an app that counts repeats needs the synthetic path, and a caller that sees a held key do
13
+ * nothing now has a reason to try it.
14
+ */
15
+ export async function pressViaKeyboard(page, args, sleep, now) {
16
+ const key = pressKeyFromArgs(args);
17
+ const modifiers = pressModifiersFromArgs(args);
18
+ const chord = 0 < modifiers.length ? `${modifiers.join('+')}+${key}` : key;
19
+ const holdMs = clampHoldMs(args.holdMs);
20
+ if (0 === holdMs) {
21
+ // `press` DOES take a chord string ("Control+k"), so the tap path needs no splitting.
22
+ await page.keyboard.press(chord);
23
+ return 0;
24
+ }
25
+ /*
26
+ * `down`/`up` take ONE key each, unlike `press`: `down('Control+k')` throws
27
+ * `Unknown key: "Control+k"` — measured on a real Chromium, not assumed. The chord string the
28
+ * tap path uses therefore cannot express a hold, so the chord is split here: modifiers down in
29
+ * chord order, the key last, and released in reverse (key first, modifiers reversed), which is
30
+ * what a hand does and what an app's keyup bookkeeping expects.
31
+ *
32
+ * Without the split a held shortcut threw before a single key moved, the caller fell back to
33
+ * the synthetic path, and a real hold was impossible for any press with modifiers — the exact
34
+ * gesture `holdMs` exists for.
35
+ *
36
+ * ## Why every key that went down is tracked, and every exit path cleans up
37
+ *
38
+ * The down calls are the ones that MOVE the keyboard, and they can fail partway: `Control` goes
39
+ * down, the key after it rejects, and the driver's copy of `Control` is still held. That path
40
+ * used to fall straight to the caller's synthetic replay — which presses the same chord again on
41
+ * a keyboard that is already holding a modifier. Tracking what actually went down is what makes
42
+ * the cleanup below able to release the right keys, and only those: releasing a key that never
43
+ * went down is its own lie, and it would turn a clean failure into an unnecessary refusal.
44
+ */
45
+ const pressed = [];
46
+ const pressDown = async (k) => {
47
+ await page.keyboard.down(k);
48
+ pressed.push(k);
49
+ };
50
+ try {
51
+ for (const modifier of modifiers) {
52
+ await pressDown(modifier);
53
+ }
54
+ await pressDown(key);
55
+ }
56
+ catch (error) {
57
+ // The chord is PARTIALLY down. Release what is held before reporting the failure; if that
58
+ // cannot be proved, `releaseKeys` throws RELEASE_FAILED and the caller refuses the replay —
59
+ // the same rule as a failed release on the ordinary path, for the same reason. Only when the
60
+ // cleanup DOES succeed is the original error safe to hand back.
61
+ await releaseKeys(page, pressed);
62
+ throw error;
63
+ }
64
+ const startedAt = now();
65
+ try {
66
+ await sleep(holdMs);
67
+ }
68
+ catch (error) {
69
+ // The wait failed, and the caller is about to run the synthetic path — which presses this SAME
70
+ // key a second time. If the driver's copy stays down, a held modifier then colours every later
71
+ // action for the rest of the run. Best-effort on purpose: whatever went wrong with the wait is
72
+ // the error worth reporting. A cleanup that cannot be proved is the exception — that one is a
73
+ // stuck key, and it must reach the caller as a refusal rather than be swallowed.
74
+ try {
75
+ await releaseKeys(page, pressed);
76
+ }
77
+ catch {
78
+ throw new DriveError(DriveErrorCode.RELEASE_FAILED, 'the wait failed and the keys it pressed could not be released, so they may still be held');
79
+ }
80
+ throw error;
81
+ }
82
+ // NOT best-effort: on the ordinary path a failed release is a real failure the caller must hear
83
+ // about, and swallowing it would report a press whose key is still down.
84
+ await releaseKeys(page, pressed);
85
+ return now() - startedAt;
86
+ }
87
+ /**
88
+ * Release the given keys, in reverse press order, and report a failure as one that leaves the
89
+ * keyboard UNKNOWN rather than as an ordinary provider error.
90
+ *
91
+ * Every release is attempted even after one fails: a stuck `Control` is worse than a stuck letter,
92
+ * so a failure on one key must not skip the rest. The first failure is what is thrown, since it is
93
+ * the one that happened first, and it is wrapped in `DriveError` so the caller can tell "this
94
+ * gesture failed" from "this gesture may have left a key down" — the difference between a safe
95
+ * synthetic replay and an unsafe one.
96
+ */
97
+ async function releaseKeys(page, pressed) {
98
+ let failure;
99
+ for (const k of [...pressed].reverse()) {
100
+ try {
101
+ await page.keyboard.up(k);
102
+ }
103
+ catch (error) {
104
+ failure ??= error;
105
+ }
106
+ }
107
+ if (failure !== undefined) {
108
+ throw new DriveError(DriveErrorCode.RELEASE_FAILED, `the key was pressed but could not be released, so it may still be held: ${describeFailure(failure)}`);
109
+ }
110
+ }
111
+ /**
112
+ * A thrown value as a line a reader can act on.
113
+ *
114
+ * Not `String(error)`: a non-Error thrown value (a rejected plain object, an SDK that throws a
115
+ * record) stringifies to `[object Object]`, which names nothing and hides the one fact the message
116
+ * exists to carry. Anything that is neither an Error nor a string says so rather than pretending.
117
+ */
118
+ function describeFailure(error) {
119
+ if (error instanceof Error) {
120
+ return error.message;
121
+ }
122
+ return 'string' === typeof error ? error : 'an unknown error';
123
+ }
124
+ /**
125
+ * The `press`es this module will NOT drive: a chord that only the in-page dispatcher can express.
126
+ *
127
+ * `args.keys` asks for several keys held down TOGETHER, pressed in order and released in reverse.
128
+ * That is not a `page.keyboard.press` chord — a non-modifier key cannot appear before the last `+`
129
+ * — and a real keyboard cannot aim a key at a named element. `args.code` is the third: the driver
130
+ * presses by KEY name and offers no way to send a physical `code` that disagrees with it, so
131
+ * `{ text: 'z', code: 'KeyY' }` would strike a different key than the one asked for. All three are
132
+ * routed to the synthetic path by the caller, which reports the reason rather than pretending a
133
+ * different gesture happened.
134
+ *
135
+ * Ordered by how specifically the caller asked: `keys` then `code` name the KEY, `ref` names only
136
+ * the target. Two can apply at once, and the earlier one is the more unusual request — the reason
137
+ * should name that, not the generic "you passed a ref".
138
+ */
139
+ export function unspellablePressReason(ref, args) {
140
+ if (0 < pressKeysFromArgs(args).length) {
141
+ return InputModeReason.SYNTHETIC_MULTI_KEY_PRESS_PREFERRED;
142
+ }
143
+ if (explicitCodeFromArgs(args) !== undefined) {
144
+ return InputModeReason.SYNTHETIC_KEY_CODE_PRESS_PREFERRED;
145
+ }
146
+ if (0 < ref.length) {
147
+ return InputModeReason.SYNTHETIC_ELEMENT_PRESS_PREFERRED;
148
+ }
149
+ return undefined;
150
+ }
@@ -11,7 +11,8 @@
11
11
  */
12
12
  import { type ScriptCoverage } from './js-coverage.js';
13
13
  import type { Browser, Page } from 'playwright';
14
- import { ActionType, DriveErrorCode, InputMode, InputModeReason } from '@reticlehq/core';
14
+ import { DriveError } from './drive-error.js';
15
+ import { ActionType, InputMode } from '@reticlehq/core';
15
16
  import { type MockRule } from './network-mock.js';
16
17
  import { type NetworkDetail } from './network-detail.js';
17
18
  /** Viewport CSS-px box as returned by the INSPECT command (getBoundingClientRect). */
@@ -34,6 +35,8 @@ export type RealInputArgs = {
34
35
  key?: string;
35
36
  /** For press: modifier names (Meta/Control/Shift/Alt; aliases like `cmd` accepted). */
36
37
  modifiers?: string[];
38
+ /** For click: which mouse button to press (left, middle or right). Absent means left. */
39
+ button?: string;
37
40
  /**
38
41
  * For press: several keys held down TOGETHER, pressed in order and released in reverse. Read but
39
42
  * never forwarded — a chord with a non-modifier key has no `page.keyboard.press` spelling, so the
@@ -152,11 +155,8 @@ export interface OwnedRealInputProvider extends RealInputProvider {
152
155
  /** Close the owned browser. Idempotent. */
153
156
  dispose(): Promise<void>;
154
157
  }
155
- /** Structured, code-tagged failure so callers branch on cause, not message text. */
156
- export declare class DriveError extends Error {
157
- readonly code: DriveErrorCode;
158
- constructor(code: DriveErrorCode, message: string);
159
- }
158
+ export { DriveError };
159
+ export { unspellablePressReason } from './real-input-press.js';
160
160
  /** Center of a viewport box in CSS px. Pure — unit-tested directly. */
161
161
  export declare function boxCenter(box: ElementBox): {
162
162
  cx: number;
@@ -174,22 +174,6 @@ export declare function isPointerAction(action: ActionType): boolean;
174
174
  export declare function isRealInputAction(action: ActionType): boolean;
175
175
  type SleepFn = (ms: number) => Promise<void>;
176
176
  type ConnectFn = (url: string) => Promise<Browser>;
177
- /**
178
- * The `press`es this module will NOT drive: a chord that only the in-page dispatcher can express.
179
- *
180
- * `args.keys` asks for several keys held down TOGETHER, pressed in order and released in reverse.
181
- * That is not a `page.keyboard.press` chord — a non-modifier key cannot appear before the last `+`
182
- * — and a real keyboard cannot aim a key at a named element. `args.code` is the third: the driver
183
- * presses by KEY name and offers no way to send a physical `code` that disagrees with it, so
184
- * `{ text: 'z', code: 'KeyY' }` would strike a different key than the one asked for. All three are
185
- * routed to the synthetic path by the caller, which reports the reason rather than pretending a
186
- * different gesture happened.
187
- *
188
- * Ordered by how specifically the caller asked: `keys` then `code` name the KEY, `ref` names only
189
- * the target. Two can apply at once, and the earlier one is the more unusual request — the reason
190
- * should name that, not the generic "you passed a ref".
191
- */
192
- export declare function unspellablePressReason(ref: string, args: Record<string, unknown>): InputModeReason | undefined;
193
177
  /**
194
178
  * Shared gesture executor: drive a native gesture on an already-resolved Page. Used by both the
195
179
  * CDP-attached and the launched (drive) providers so the pointer logic lives in one place.
@@ -342,4 +326,3 @@ export declare class LaunchedRealInputProvider implements OwnedRealInputProvider
342
326
  export declare function selectPage<T extends {
343
327
  url(): string;
344
328
  }>(pages: readonly T[], sessionUrl: string): T | undefined;
345
- export {};
@@ -10,25 +10,20 @@
10
10
  * pay for it; the type-only import is elided by `tsc`, so the build stays green without it.
11
11
  */
12
12
  import { takeJsCoverage } from './js-coverage.js';
13
+ import { DriveError } from './drive-error.js';
14
+ import { pressViaKeyboard } from './real-input-press.js';
13
15
  import { stampedDriveUrl } from './drive-url-stamp.js';
14
16
  import { launchChromium } from '../../launch-chromium.js';
15
17
  import { chromiumLaunchHint, gotoOptions, SCREENSHOT_DETERMINISM, captureWithChromeHidden, } from '../pool/playwright-launcher.js';
16
18
  import { BrowserLaunchKind } from '@reticlehq/core/telemetry';
17
19
  import { getSessionMetrics } from '../../telemetry/session-metrics.js';
18
20
  import { classifyConnectFailure } from '../../telemetry/connect-failure.js';
19
- import { ActionType, clampHoldMs, DriveErrorCode, DRIVE_PLAYWRIGHT_MISSING_MSG, explicitCodeFromArgs, InputMode, InputModeReason, pressKeyFromArgs, pressKeysFromArgs, pressModifiersFromArgs, } from '@reticlehq/core';
21
+ import { ActionType, DriveErrorCode, DRIVE_PLAYWRIGHT_MISSING_MSG, ClickButton, clickButtonFromArgs, InputMode, pressModifiersFromArgs, } from '@reticlehq/core';
20
22
  import { installNetworkMocks } from './network-mock.js';
21
23
  import { attachNetworkDetail } from './network-detail.js';
22
24
  import { injectedConnectArgs } from '../pool/zero-install.js';
23
- /** Structured, code-tagged failure so callers branch on cause, not message text. */
24
- export class DriveError extends Error {
25
- code;
26
- constructor(code, message) {
27
- super(message);
28
- this.code = code;
29
- this.name = 'DriveError';
30
- }
31
- }
25
+ export { DriveError };
26
+ export { unspellablePressReason } from './real-input-press.js';
32
27
  /** Center of a viewport box in CSS px. Pure — unit-tested directly. */
33
28
  export function boxCenter(box) {
34
29
  return { cx: box.x + box.width / 2, cy: box.y + box.height / 2 };
@@ -63,149 +58,12 @@ function undriven(action, box) {
63
58
  const REAL_INPUT_SETTLE_MS = 16;
64
59
  /** Default number of interpolation steps for a native drag. */
65
60
  const DEFAULT_DRAG_STEPS = 8;
66
- /**
67
- * Drive a `press` with a real keyboard — coordinate-free; the key goes to whatever holds focus.
68
- * Key and modifiers come from the same core helpers the synthetic dispatcher uses, so the two paths
69
- * cannot read the same args differently.
70
- *
71
- * A `holdMs` splits the chord into down / wait / up, the same shape the page uses for a held key.
72
- * `keyboard.press` would send it instantly instead, reporting a hold-to-confirm that never held.
73
- * The one thing this cannot reproduce is the auto-repeat a held key emits: the driver sends a single
74
- * keydown, where the in-page path sends the repeats a browser would. Named rather than papered over
75
- * — an app that counts repeats needs the synthetic path, and a caller that sees a held key do
76
- * nothing now has a reason to try it.
77
- */
78
- async function pressViaKeyboard(page, args, sleep, now) {
79
- const key = pressKeyFromArgs(args);
80
- const modifiers = pressModifiersFromArgs(args);
81
- const chord = 0 < modifiers.length ? `${modifiers.join('+')}+${key}` : key;
82
- const holdMs = clampHoldMs(args.holdMs);
83
- if (0 === holdMs) {
84
- // `press` DOES take a chord string ("Control+k"), so the tap path needs no splitting.
85
- await page.keyboard.press(chord);
86
- return 0;
87
- }
88
- /*
89
- * `down`/`up` take ONE key each, unlike `press`: `down('Control+k')` throws
90
- * `Unknown key: "Control+k"` — measured on a real Chromium, not assumed. The chord string the
91
- * tap path uses therefore cannot express a hold, so the chord is split here: modifiers down in
92
- * chord order, the key last, and released in reverse (key first, modifiers reversed), which is
93
- * what a hand does and what an app's keyup bookkeeping expects.
94
- *
95
- * Without the split a held shortcut threw before a single key moved, the caller fell back to
96
- * the synthetic path, and a real hold was impossible for any press with modifiers — the exact
97
- * gesture `holdMs` exists for.
98
- *
99
- * ## Why every key that went down is tracked, and every exit path cleans up
100
- *
101
- * The down calls are the ones that MOVE the keyboard, and they can fail partway: `Control` goes
102
- * down, the key after it rejects, and the driver's copy of `Control` is still held. That path
103
- * used to fall straight to the caller's synthetic replay — which presses the same chord again on
104
- * a keyboard that is already holding a modifier. Tracking what actually went down is what makes
105
- * the cleanup below able to release the right keys, and only those: releasing a key that never
106
- * went down is its own lie, and it would turn a clean failure into an unnecessary refusal.
107
- */
108
- const pressed = [];
109
- const pressDown = async (k) => {
110
- await page.keyboard.down(k);
111
- pressed.push(k);
112
- };
113
- try {
114
- for (const modifier of modifiers)
115
- await pressDown(modifier);
116
- await pressDown(key);
117
- }
118
- catch (error) {
119
- // The chord is PARTIALLY down. Release what is held before reporting the failure; if that
120
- // cannot be proved, `releaseKeys` throws RELEASE_FAILED and the caller refuses the replay —
121
- // the same rule as a failed release on the ordinary path, for the same reason. Only when the
122
- // cleanup DOES succeed is the original error safe to hand back.
123
- await releaseKeys(page, pressed);
124
- throw error;
125
- }
126
- const startedAt = now();
127
- try {
128
- await sleep(holdMs);
129
- }
130
- catch (error) {
131
- // The wait failed, and the caller is about to run the synthetic path — which presses this SAME
132
- // key a second time. If the driver's copy stays down, a held modifier then colours every later
133
- // action for the rest of the run. Best-effort on purpose: whatever went wrong with the wait is
134
- // the error worth reporting. A cleanup that cannot be proved is the exception — that one is a
135
- // stuck key, and it must reach the caller as a refusal rather than be swallowed.
136
- try {
137
- await releaseKeys(page, pressed);
138
- }
139
- catch {
140
- throw new DriveError(DriveErrorCode.RELEASE_FAILED, 'the wait failed and the keys it pressed could not be released, so they may still be held');
141
- }
142
- throw error;
143
- }
144
- // NOT best-effort: on the ordinary path a failed release is a real failure the caller must hear
145
- // about, and swallowing it would report a press whose key is still down.
146
- await releaseKeys(page, pressed);
147
- return now() - startedAt;
148
- }
149
- /**
150
- * Release the given keys, in reverse press order, and report a failure as one that leaves the
151
- * keyboard UNKNOWN rather than as an ordinary provider error.
152
- *
153
- * Every release is attempted even after one fails: a stuck `Control` is worse than a stuck letter,
154
- * so a failure on one key must not skip the rest. The first failure is what is thrown, since it is
155
- * the one that happened first, and it is wrapped in `DriveError` so the caller can tell "this
156
- * gesture failed" from "this gesture may have left a key down" — the difference between a safe
157
- * synthetic replay and an unsafe one.
158
- */
159
- async function releaseKeys(page, pressed) {
160
- let failure;
161
- for (const k of [...pressed].reverse()) {
162
- try {
163
- await page.keyboard.up(k);
164
- }
165
- catch (error) {
166
- failure ??= error;
167
- }
168
- }
169
- if (failure !== undefined) {
170
- throw new DriveError(DriveErrorCode.RELEASE_FAILED, `the key was pressed but could not be released, so it may still be held: ${describeFailure(failure)}`);
171
- }
172
- }
173
- /**
174
- * A thrown value as a line a reader can act on.
175
- *
176
- * Not `String(error)`: a non-Error thrown value (a rejected plain object, an SDK that throws a
177
- * record) stringifies to `[object Object]`, which names nothing and hides the one fact the message
178
- * exists to carry. Anything that is neither an Error nor a string says so rather than pretending.
179
- */
180
- function describeFailure(error) {
181
- if (error instanceof Error)
182
- return error.message;
183
- return 'string' === typeof error ? error : 'an unknown error';
184
- }
185
- /**
186
- * The `press`es this module will NOT drive: a chord that only the in-page dispatcher can express.
187
- *
188
- * `args.keys` asks for several keys held down TOGETHER, pressed in order and released in reverse.
189
- * That is not a `page.keyboard.press` chord — a non-modifier key cannot appear before the last `+`
190
- * — and a real keyboard cannot aim a key at a named element. `args.code` is the third: the driver
191
- * presses by KEY name and offers no way to send a physical `code` that disagrees with it, so
192
- * `{ text: 'z', code: 'KeyY' }` would strike a different key than the one asked for. All three are
193
- * routed to the synthetic path by the caller, which reports the reason rather than pretending a
194
- * different gesture happened.
195
- *
196
- * Ordered by how specifically the caller asked: `keys` then `code` name the KEY, `ref` names only
197
- * the target. Two can apply at once, and the earlier one is the more unusual request — the reason
198
- * should name that, not the generic "you passed a ref".
199
- */
200
- export function unspellablePressReason(ref, args) {
201
- if (0 < pressKeysFromArgs(args).length) {
202
- return InputModeReason.SYNTHETIC_MULTI_KEY_PRESS_PREFERRED;
61
+ /** The mouse options a click needs: the button, only when it is not the default left one. */
62
+ function clickOptionsOf(args) {
63
+ const button = clickButtonFromArgs({ button: args.button });
64
+ if (button === ClickButton.RIGHT || button === ClickButton.MIDDLE) {
65
+ return { button };
203
66
  }
204
- if (explicitCodeFromArgs(args) !== undefined) {
205
- return InputModeReason.SYNTHETIC_KEY_CODE_PRESS_PREFERRED;
206
- }
207
- if (0 < ref.length)
208
- return InputModeReason.SYNTHETIC_ELEMENT_PRESS_PREFERRED;
209
67
  return undefined;
210
68
  }
211
69
  /**
@@ -241,19 +99,32 @@ now = Date.now) {
241
99
  await sleep(REAL_INPUT_SETTLE_MS);
242
100
  return real(true);
243
101
  }
244
- if (action === ActionType.CLICK) {
245
- await page.mouse.move(cx, cy);
246
- await page.mouse.click(cx, cy);
247
- return real(true);
248
- }
249
- if (action === ActionType.DBLCLICK) {
102
+ if (action === ActionType.CLICK || action === ActionType.DBLCLICK) {
250
103
  await page.mouse.move(cx, cy);
251
- await page.mouse.dblclick(cx, cy);
104
+ const options = clickOptionsOf(args);
105
+ const modifiers = pressModifiersFromArgs(args);
106
+ for (const modifier of modifiers) {
107
+ await page.keyboard.down(modifier);
108
+ }
109
+ try {
110
+ if (action === ActionType.CLICK) {
111
+ await page.mouse.click(cx, cy, options);
112
+ }
113
+ else {
114
+ await page.mouse.dblclick(cx, cy, options);
115
+ }
116
+ }
117
+ finally {
118
+ for (const modifier of [...modifiers].reverse()) {
119
+ await page.keyboard.up(modifier);
120
+ }
121
+ }
252
122
  return real(true);
253
123
  }
254
124
  if (action === ActionType.DRAG) {
255
- if (args.toBox === undefined)
125
+ if (args.toBox === undefined) {
256
126
  return real(false);
127
+ }
257
128
  const dst = boxCenter(args.toBox);
258
129
  const steps = args.steps ?? DEFAULT_DRAG_STEPS;
259
130
  await page.mouse.move(cx, cy);
@@ -341,8 +212,9 @@ export class CdpRealInputProvider {
341
212
  /** Attach the response listener the first time we see a page; a no-op afterwards. */
342
213
  #listen(page) {
343
214
  const sink = this.#onNetworkDetail;
344
- if (sink === undefined || this.#listening.has(page))
215
+ if (sink === undefined || this.#listening.has(page)) {
345
216
  return;
217
+ }
346
218
  this.#listening.add(page);
347
219
  attachNetworkDetail(page, sink);
348
220
  }
@@ -354,8 +226,9 @@ export class CdpRealInputProvider {
354
226
  // Playwright's Browser exposes isConnected(); a test fake may not, so only an EXPLICIT false
355
227
  // drops the cache — a fake without the method is assumed live.
356
228
  const alive = this.#browser.isConnected?.() ?? true;
357
- if (alive)
229
+ if (alive) {
358
230
  return this.#browser;
231
+ }
359
232
  this.#browser = undefined; // fall through and reconnect
360
233
  }
361
234
  try {
@@ -369,11 +242,13 @@ export class CdpRealInputProvider {
369
242
  }
370
243
  async #pageFor(sessionUrl) {
371
244
  const browser = await this.#ensureBrowser();
372
- if (browser === undefined)
245
+ if (browser === undefined) {
373
246
  return undefined;
247
+ }
374
248
  const page = selectPage(browser.contexts().flatMap((c) => c.pages()), sessionUrl);
375
- if (page !== undefined)
249
+ if (page !== undefined) {
376
250
  this.#listen(page);
251
+ }
377
252
  return page;
378
253
  }
379
254
  async takeCodeCoverage(sessionUrl) {
@@ -390,22 +265,25 @@ export class CdpRealInputProvider {
390
265
  }
391
266
  async perform(sessionUrl, action, box, args) {
392
267
  const page = await this.#pageFor(sessionUrl);
393
- if (page === undefined)
268
+ if (page === undefined) {
394
269
  return undriven(action, box);
270
+ }
395
271
  return performGesture(page, action, box, args, this.#sleep, this.#now);
396
272
  }
397
273
  /** PNG of the correlated page, or undefined if none matches. */
398
274
  async screenshot(sessionUrl, opts) {
399
275
  const page = await this.#pageFor(sessionUrl);
400
- if (page === undefined)
276
+ if (page === undefined) {
401
277
  return undefined;
278
+ }
402
279
  return capturePage(page, opts);
403
280
  }
404
281
  /** The context's cookies and per-origin storage; undefined when no driven page matches. */
405
282
  async storageState(sessionUrl) {
406
283
  const page = await this.#pageFor(sessionUrl);
407
- if (page === undefined)
284
+ if (page === undefined) {
408
285
  return undefined;
286
+ }
409
287
  return page.context().storageState();
410
288
  }
411
289
  /**
@@ -416,27 +294,32 @@ export class CdpRealInputProvider {
416
294
  */
417
295
  async applyStorageState(sessionUrl, state) {
418
296
  const page = await this.#pageFor(sessionUrl);
419
- if (page === undefined)
297
+ if (page === undefined) {
420
298
  return false;
299
+ }
421
300
  const saved = state;
422
- if (saved.cookies !== undefined)
301
+ if (saved.cookies !== undefined) {
423
302
  await page.context().addCookies(saved.cookies);
303
+ }
424
304
  for (const origin of saved.origins ?? []) {
425
305
  // Only the document for an origin can write that origin's localStorage, so this is applied in
426
306
  // the page rather than through the context. A page sitting on a different origin cannot be
427
307
  // given another origin's storage, and silently succeeding there would be the quiet half-restore
428
308
  // this whole path exists to avoid.
429
- if (!page.url().startsWith(origin.origin))
309
+ if (!page.url().startsWith(origin.origin)) {
430
310
  continue;
311
+ }
431
312
  await page.evaluate((entries) => {
432
313
  // Reached through `globalThis` rather than `window`: this body is serialised and runs in the
433
314
  // PAGE, but it is typechecked here, and this package never pulls in the DOM lib — a server
434
315
  // that can name `window` is one step from a server that uses it.
435
316
  const store = globalThis.localStorage;
436
- if (store === undefined)
317
+ if (store === undefined) {
437
318
  return;
438
- for (const entry of entries)
319
+ }
320
+ for (const entry of entries) {
439
321
  store.setItem(entry.name, entry.value);
322
+ }
440
323
  }, origin.localStorage ?? []);
441
324
  }
442
325
  return true;
@@ -444,16 +327,18 @@ export class CdpRealInputProvider {
444
327
  /** Apply network-mock rules to the correlated page; false when no driven page matches. */
445
328
  async setMocks(sessionUrl, rules) {
446
329
  const page = await this.#pageFor(sessionUrl);
447
- if (page === undefined)
330
+ if (page === undefined) {
448
331
  return false;
332
+ }
449
333
  await installNetworkMocks(page, rules);
450
334
  return true;
451
335
  }
452
336
  /** Pin the correlated page's viewport to fixed dimensions; false when no driven page matches. */
453
337
  async setViewport(sessionUrl, size) {
454
338
  const page = await this.#pageFor(sessionUrl);
455
- if (page === undefined)
339
+ if (page === undefined) {
456
340
  return false;
341
+ }
457
342
  await page.setViewportSize({ width: size.width, height: size.height });
458
343
  return true;
459
344
  }
@@ -461,8 +346,9 @@ export class CdpRealInputProvider {
461
346
  async dispose() {
462
347
  const browser = this.#browser;
463
348
  this.#browser = undefined;
464
- if (browser !== undefined)
349
+ if (browser !== undefined) {
465
350
  await browser.close();
351
+ }
466
352
  }
467
353
  }
468
354
  const INJECT_CONNECT_WAIT_MS = 8_000;
@@ -519,8 +405,9 @@ export class LaunchedRealInputProvider {
519
405
  const page = await this.#browser.newPage(this.#storageState !== undefined ? { storageState: this.#storageState } : undefined);
520
406
  this.#page = page;
521
407
  // Capture CDP-authoritative response detail into the driven session's journal (best-effort).
522
- if (this.#onNetworkDetail !== undefined)
408
+ if (this.#onNetworkDetail !== undefined) {
523
409
  attachNetworkDetail(page, this.#onNetworkDetail);
410
+ }
524
411
  try {
525
412
  // Same navigation rule as the pool, and for the same measured reason: Playwright's default
526
413
  // waits for `load`, which an app with one never-finishing subresource never fires — 30s of
@@ -542,8 +429,9 @@ export class LaunchedRealInputProvider {
542
429
  */
543
430
  async #tryInjectConnect(page) {
544
431
  const opts = this.#injectConnect;
545
- if (opts === undefined)
432
+ if (opts === undefined) {
546
433
  return;
434
+ }
547
435
  try {
548
436
  await page.waitForFunction('!!globalThis.__reticleInstance', {
549
437
  timeout: INJECT_CONNECT_WAIT_MS,
@@ -567,8 +455,9 @@ export class LaunchedRealInputProvider {
567
455
  */
568
456
  #livePage() {
569
457
  const page = this.#page;
570
- if (page === undefined)
458
+ if (page === undefined) {
571
459
  return undefined;
460
+ }
572
461
  if (true === page.isClosed?.()) {
573
462
  this.#page = undefined; // never ask a corpse twice
574
463
  return undefined;
@@ -577,10 +466,12 @@ export class LaunchedRealInputProvider {
577
466
  }
578
467
  isAvailableFor(sessionUrl) {
579
468
  const page = this.#livePage();
580
- if (page === undefined)
469
+ if (page === undefined) {
581
470
  return Promise.resolve(false);
582
- if (page.url() === sessionUrl)
471
+ }
472
+ if (page.url() === sessionUrl) {
583
473
  return Promise.resolve(true);
474
+ }
584
475
  return Promise.resolve(stripVolatile(page.url()) === stripVolatile(sessionUrl));
585
476
  }
586
477
  /**
@@ -593,8 +484,9 @@ export class LaunchedRealInputProvider {
593
484
  */
594
485
  async storageState(_sessionUrl) {
595
486
  const page = this.#livePage();
596
- if (page === undefined)
487
+ if (page === undefined) {
597
488
  return undefined;
489
+ }
598
490
  return page.context().storageState();
599
491
  }
600
492
  /**
@@ -603,37 +495,44 @@ export class LaunchedRealInputProvider {
603
495
  */
604
496
  async applyStorageState(_sessionUrl, state) {
605
497
  const page = this.#livePage();
606
- if (page === undefined)
498
+ if (page === undefined) {
607
499
  return false;
500
+ }
608
501
  const saved = state;
609
- if (saved.cookies !== undefined)
502
+ if (saved.cookies !== undefined) {
610
503
  await page.context().addCookies(saved.cookies);
504
+ }
611
505
  for (const origin of saved.origins ?? []) {
612
506
  // A page sitting on a different origin cannot be given another origin's storage, and silently
613
507
  // succeeding there is the quiet half-restore this whole path exists to avoid.
614
- if (!page.url().startsWith(origin.origin))
508
+ if (!page.url().startsWith(origin.origin)) {
615
509
  continue;
510
+ }
616
511
  await page.evaluate((entries) => {
617
512
  const store = globalThis.localStorage;
618
- if (store === undefined)
513
+ if (store === undefined) {
619
514
  return;
620
- for (const entry of entries)
515
+ }
516
+ for (const entry of entries) {
621
517
  store.setItem(entry.name, entry.value);
518
+ }
622
519
  }, origin.localStorage ?? []);
623
520
  }
624
521
  return true;
625
522
  }
626
523
  perform(_sessionUrl, action, box, args) {
627
524
  const page = this.#livePage();
628
- if (page === undefined)
525
+ if (page === undefined) {
629
526
  return Promise.resolve(undriven(action, box));
527
+ }
630
528
  return performGesture(page, action, box, args, this.#sleep, this.#now);
631
529
  }
632
530
  /** PNG of the owned page, or undefined before navigate / after dispose. */
633
531
  screenshot(_sessionUrl, opts) {
634
532
  const page = this.#livePage();
635
- if (page === undefined)
533
+ if (page === undefined) {
636
534
  return Promise.resolve(undefined);
535
+ }
637
536
  return capturePage(page, opts);
638
537
  }
639
538
  /**
@@ -646,8 +545,9 @@ export class LaunchedRealInputProvider {
646
545
  */
647
546
  async setViewport(_sessionUrl, size) {
648
547
  const page = this.#livePage();
649
- if (page === undefined)
548
+ if (page === undefined) {
650
549
  return false;
550
+ }
651
551
  await page.setViewportSize({ width: size.width, height: size.height });
652
552
  return true;
653
553
  }
@@ -658,8 +558,9 @@ export class LaunchedRealInputProvider {
658
558
  /** Apply network-mock rules to the owned page; false before navigate / after dispose. */
659
559
  async setMocks(_sessionUrl, rules) {
660
560
  const page = this.#livePage();
661
- if (page === undefined)
561
+ if (page === undefined) {
662
562
  return false;
563
+ }
663
564
  await installNetworkMocks(page, rules);
664
565
  return true;
665
566
  }
@@ -668,8 +569,9 @@ export class LaunchedRealInputProvider {
668
569
  const browser = this.#browser;
669
570
  this.#browser = undefined;
670
571
  this.#page = undefined;
671
- if (browser !== undefined)
572
+ if (browser !== undefined) {
672
573
  await browser.close();
574
+ }
673
575
  }
674
576
  }
675
577
  /** Drop hash/query so a page whose URL drifted by fragment still correlates to the session. */
@@ -688,8 +590,9 @@ export class LaunchedRealInputProvider {
688
590
  */
689
591
  export function selectPage(pages, sessionUrl) {
690
592
  const exact = pages.find((p) => p.url() === sessionUrl);
691
- if (exact !== undefined)
593
+ if (exact !== undefined) {
692
594
  return exact;
595
+ }
693
596
  const target = stripVolatile(sessionUrl);
694
597
  const loose = pages.filter((p) => stripVolatile(p.url()) === target);
695
598
  return 1 === loose.length ? loose[0] : undefined;
@@ -123,7 +123,7 @@ export const ACT_TOOLS = [
123
123
  args: z
124
124
  .record(z.string(), z.unknown())
125
125
  .optional()
126
- .describe('Action-specific arguments: { value } for fill/select, { text } for type/press (the key NAME, e.g. Escape or Tab), { modifiers: ["Meta","Shift"] } for a press shortcut (Meta/Control/Shift/Alt — a Cmd+K), { toRef } for drag (the ref to drop ON — without it the drag lands nowhere), { native: true } to force a trusted native click, { holdMs: N } to keep the pointer or the key DOWN for N ms (hold-to-confirm controls; effect.heldMs reports what was achieved), { confirmDangerous: true } to allow a potentially destructive control — a permission gate, NOT a duration. For press: { keys: ["Control","k"] } holds several keys down TOGETHER (pressed in order, released in reverse) and { code } names the physical key — both stay on the in-page dispatcher, the only path that can express them. For upload: { path } is a path on disk (absolute or relative to the project root; the daemon reads the file and delivers real bytes to the file picker — this is the way to verify document-ingestion flows); or { name, content?, type? } to supply inline bytes directly.'),
126
+ .describe('Action-specific arguments: { value } for fill/select (select also drives an ARIA combobox: it opens it and clicks the option by its label), { text } for type/press (the key NAME, e.g. Escape or Tab), { modifiers: ["Meta","Shift"] } for a press shortcut (Meta/Control/Shift/Alt — a Cmd+K) or a click/dblclick (Shift+click range select), { button: "right" | "middle" } for a click (right sends contextmenu), { toRef } for drag (the ref to drop ON — without it the drag lands nowhere), { native: true } to force a trusted native click, { holdMs: N } to keep the pointer or the key DOWN for N ms (hold-to-confirm controls; effect.heldMs reports what was achieved), { confirmDangerous: true } to allow a potentially destructive control — a permission gate, NOT a duration. For press: { keys: ["Control","k"] } holds several keys down TOGETHER (pressed in order, released in reverse) and { code } names the physical key — both stay on the in-page dispatcher, the only path that can express them. For upload: { path } is a path on disk (absolute or relative to the project root; the daemon reads the file and delivers real bytes to the file picker — this is the way to verify document-ingestion flows); or { name, content?, type? } to supply inline bytes directly.'),
127
127
  refuseWhenThrottled: z
128
128
  .boolean()
129
129
  .optional()
@@ -271,7 +271,7 @@ export const ACT_TOOLS = [
271
271
  args: z
272
272
  .record(z.string(), z.unknown())
273
273
  .optional()
274
- .describe('Action-specific arguments: { value } for fill/select, { text } for type/press (the key NAME, e.g. Escape or Tab), { modifiers: ["Meta","Shift"] } for a press shortcut (Meta/Control/Shift/Alt), { toRef } for drag (the ref to drop ON — without it the drag lands nowhere), { confirmDangerous: true } for a potentially destructive control. For upload: { path } is a path on disk (absolute or relative to project root; daemon reads real bytes) or { name, content?, type? } for inline bytes.'),
274
+ .describe('Action-specific arguments: { value } for fill/select (select also drives an ARIA combobox: it opens it and clicks the option by its label), { text } for type/press (the key NAME, e.g. Escape or Tab), { modifiers: ["Meta","Shift"] } for a press shortcut or a click (Meta/Control/Shift/Alt), { button: "right" | "middle" } for a click, { toRef } for drag (the ref to drop ON — without it the drag lands nowhere), { confirmDangerous: true } for a potentially destructive control. For upload: { path } is a path on disk (absolute or relative to project root; daemon reads real bytes) or { name, content?, type? } for inline bytes.'),
275
275
  predicate: PredicateSchema.optional().describe('Alias for `until`.'),
276
276
  until: PredicateSchema.optional().describe('Predicate to wait for after the action completes (same shape as reticle_assert). OMIT to wait for the page to SETTLE — network + DOM idle — the deterministic default instead of a sleep. To assert a consequence AND settle, allOf them: { kind: "allOf", predicates: [<your predicate>, { kind: "settled" }] }.'),
277
277
  timeout_ms: timeoutMsSchema
@@ -19,7 +19,7 @@
19
19
  * the base64 4/3 inflation factor applied, so the encoded payload always fits in one WebSocket
20
20
  * frame.
21
21
  */
22
- import { ActionType, DriveErrorCode, ElementState, INSPECT_IN_VIEWPORT_FIELD, INSPECT_REVEAL_ARG, InputMode, InputModeReason, ReticleCommand, TRANSPORT_LIMITS, } from '@reticlehq/core';
22
+ import { ActionType, clickInputProblem, DriveErrorCode, ElementState, INSPECT_IN_VIEWPORT_FIELD, INSPECT_REVEAL_ARG, InputMode, InputModeReason, ReticleCommand, TRANSPORT_LIMITS, } from '@reticlehq/core';
23
23
  import { boxCenter, DriveError, isRealInputAction, unspellablePressReason, } from '../../portal/input/real-input.js';
24
24
  import { assertDragNotDestructive, assertNotDestructive } from './act/act-danger.js';
25
25
  import { assertNativeAllowed } from '../../portal/production/production-drive.js';
@@ -101,10 +101,12 @@ const UPLOAD_MAX_BYTES = Math.floor((TRANSPORT_LIMITS.MAX_MESSAGE_BYTES / (4 / 3
101
101
  * through unchanged. `home` is injected so unit tests do not depend on the machine's real home.
102
102
  */
103
103
  function expandUserPath(rawPath, home) {
104
- if ('~' === rawPath)
104
+ if ('~' === rawPath) {
105
105
  return home;
106
- if (rawPath.startsWith('~/') || rawPath.startsWith('~\\'))
106
+ }
107
+ if (rawPath.startsWith('~/') || rawPath.startsWith('~\\')) {
107
108
  return join(home, rawPath.slice(2));
109
+ }
108
110
  return rawPath;
109
111
  }
110
112
  /** True when `real` is inside `root` after both have been realpath'd / resolved. */
@@ -132,15 +134,18 @@ async function readUploadRoots(projectRoot, fs, home) {
132
134
  catch {
133
135
  return [];
134
136
  }
135
- if ('object' !== typeof parsed || null === parsed || Array.isArray(parsed))
137
+ if ('object' !== typeof parsed || null === parsed || Array.isArray(parsed)) {
136
138
  return [];
139
+ }
137
140
  const listed = parsed['uploadRoots'];
138
- if (!Array.isArray(listed))
141
+ if (!Array.isArray(listed)) {
139
142
  return [];
143
+ }
140
144
  const roots = [];
141
145
  for (const entry of listed) {
142
- if ('string' !== typeof entry || 0 === entry.trim().length)
146
+ if ('string' !== typeof entry || 0 === entry.trim().length) {
143
147
  continue;
148
+ }
144
149
  roots.push(resolve(expandUserPath(entry.trim(), home)));
145
150
  }
146
151
  return roots;
@@ -191,11 +196,13 @@ async function resolveUploadPath(rawPath, projectRoot, fs, uploadRoots, home) {
191
196
  export async function rewriteUploadArgs(deps, action, innerArgs,
192
197
  /** Injected for tests; production uses the process home directory. */
193
198
  home = homedir()) {
194
- if (action !== ActionType.UPLOAD)
199
+ if (action !== ActionType.UPLOAD) {
195
200
  return innerArgs;
201
+ }
196
202
  const rawPath = asString(innerArgs['path']);
197
- if (rawPath === undefined)
198
- return innerArgs; // no path → browser handles as inline upload
203
+ if (rawPath === undefined) {
204
+ return innerArgs;
205
+ } // no path → browser handles as inline upload
199
206
  // Project root is one level above .reticle/
200
207
  const projectRoot = resolve(join(deps.reticleRoot, '..'));
201
208
  const uploadRoots = await readUploadRoots(projectRoot, deps.fs, home);
@@ -236,14 +243,17 @@ function synthetic(reason) {
236
243
  function performArgsOf(inner) {
237
244
  const performArgs = {};
238
245
  const value = asString(inner['value']);
239
- if (value !== undefined)
246
+ if (value !== undefined) {
240
247
  performArgs.value = value;
248
+ }
241
249
  const text = asString(inner['text']);
242
- if (text !== undefined)
250
+ if (text !== undefined) {
243
251
  performArgs.text = text;
252
+ }
244
253
  const key = asString(inner['key']);
245
- if (key !== undefined)
254
+ if (key !== undefined) {
246
255
  performArgs.key = key;
256
+ }
247
257
  const modifiers = inner['modifiers'];
248
258
  if (Array.isArray(modifiers)) {
249
259
  performArgs.modifiers = modifiers.filter((m) => 'string' === typeof m);
@@ -251,9 +261,14 @@ function performArgsOf(inner) {
251
261
  // A held key is the whole point of a hold-to-confirm control, and the synthetic path has honoured
252
262
  // `holdMs` all along. Not forwarding it made the real keyboard the WEAKER of the two paths: it
253
263
  // drove the key instantly and reported a hold that never held.
264
+ const button = asString(inner['button']);
265
+ if (button !== undefined) {
266
+ performArgs.button = button;
267
+ }
254
268
  const holdMs = asNumber(inner['holdMs']);
255
- if (holdMs !== undefined)
269
+ if (holdMs !== undefined) {
256
270
  performArgs.holdMs = holdMs;
271
+ }
257
272
  // `keys` and `code` are read but not forwarded: both are routed away by the caller — a multi-key
258
273
  // sequence and an explicit physical code have no driver spelling — so carrying them into the
259
274
  // provider would only invite it to guess at a gesture the caller did not ask for.
@@ -285,11 +300,13 @@ const ANCHOR_FIELDS = [
285
300
  */
286
301
  async function pressOnLease(deps, session, ref, inner) {
287
302
  const pool = deps.pool;
288
- if (pool === undefined || !pool.hasKeyboard(session.id))
303
+ if (pool === undefined || !pool.hasKeyboard(session.id)) {
289
304
  return undefined;
305
+ }
290
306
  const unspellable = unspellablePressReason('', inner);
291
- if (unspellable !== undefined)
307
+ if (unspellable !== undefined) {
292
308
  return synthetic(unspellable);
309
+ }
293
310
  // What the focused element is called, from the focus act's own report, so the saved step can
294
311
  // find it again: the real keyboard reports no element of its own.
295
312
  let anchor = {};
@@ -307,8 +324,9 @@ async function pressOnLease(deps, session, ref, inner) {
307
324
  }
308
325
  }
309
326
  const performed = await pool.pressLease(session.id, performArgsOf(inner));
310
- if (performed?.performed !== true)
327
+ if (performed?.performed !== true) {
311
328
  return undefined;
329
+ }
312
330
  return {
313
331
  result: {
314
332
  ...anchor,
@@ -382,15 +400,17 @@ async function hoverForReal(deps, session, ref, inner, provider) {
382
400
  if (box === undefined) {
383
401
  throw new Error(HOVER_NEEDS_POINTER_MSG);
384
402
  }
385
- if (!inView)
403
+ if (!inView) {
386
404
  throw new Error(HOVER_OFF_VIEWPORT_MSG);
405
+ }
387
406
  if (providerReady && provider !== undefined) {
388
407
  try {
389
408
  const performed = await provider.perform(session.url, ActionType.HOVER, box, {});
390
409
  // Hover always has a resolved box, so there is always a center to report: the provider's own
391
410
  // reading, or the box's if it reported none. Both are the same point, not a fabricated one.
392
- if (performed.performed)
411
+ if (performed.performed) {
393
412
  return hoverSucceeded(performed.center ?? boxCenter(box), scrolled);
413
+ }
394
414
  }
395
415
  catch {
396
416
  // A provider error used to fall back to synthetic dispatch. For hover that is a lie.
@@ -428,8 +448,9 @@ export async function tryRealInput(deps, session, ref, action, args) {
428
448
  if (provider === undefined) {
429
449
  if (action === ActionType.PRESS) {
430
450
  const leased = await pressOnLease(deps, session, ref, inner);
431
- if (leased !== undefined)
451
+ if (leased !== undefined) {
432
452
  return leased;
453
+ }
433
454
  }
434
455
  // Silent by default: with no provider EVERY action is synthetic, and a reason on all of them is
435
456
  // noise on the most-used tool in the product. But an agent that passed native:true asked a
@@ -441,11 +462,13 @@ export async function tryRealInput(deps, session, ref, action, args) {
441
462
  // one was asked for, instead of a different gesture being sent. See `unspellablePressReason`.
442
463
  if (action === ActionType.PRESS) {
443
464
  const unspellable = unspellablePressReason(ref, inner);
444
- if (unspellable !== undefined)
465
+ if (unspellable !== undefined) {
445
466
  return synthetic(unspellable);
467
+ }
446
468
  }
447
- if (!isRealInputAction(action))
448
- return synthetic(InputModeReason.NOT_POINTER); // fill/type stay synthetic
469
+ if (!isRealInputAction(action)) {
470
+ return synthetic(InputModeReason.NOT_POINTER);
471
+ } // fill/type stay synthetic
449
472
  // "Don't click, run the code": a click/dblclick runs the occlusion-honest SYNTHETIC path by default
450
473
  // even with a provider configured — no coordinate gesture to be intercepted by the HUD or missed
451
474
  // off-screen. Opt into a trusted native click with args.native:true (file pickers, clipboard,
@@ -453,6 +476,13 @@ export async function tryRealInput(deps, session, ref, action, args) {
453
476
  if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) && !askedForNative) {
454
477
  return synthetic(InputModeReason.SYNTHETIC_CLICK_PREFERRED);
455
478
  }
479
+ // The page refuses the same arguments on the synthetic path. A native click must not drop one.
480
+ if (action === ActionType.CLICK || action === ActionType.DBLCLICK) {
481
+ const problem = clickInputProblem(action, inner);
482
+ if (problem !== undefined) {
483
+ throw new Error(`cannot ${action}: ${problem}`);
484
+ }
485
+ }
456
486
  // Under version skew CDP is unusable while DOM tools still work (#688). Surface the skew sentence
457
487
  // rather than "page not correlated" / a silent provider-error fallback — those send the agent
458
488
  // hunting a dead context that is not dead.
@@ -466,15 +496,17 @@ export async function tryRealInput(deps, session, ref, action, args) {
466
496
  if (action !== ActionType.PRESS && session.versionSkew !== undefined) {
467
497
  throw new Error(session.versionSkew);
468
498
  }
469
- if (!(await provider.isAvailableFor(session.url)))
499
+ if (!(await provider.isAvailableFor(session.url))) {
470
500
  return synthetic(InputModeReason.PAGE_NOT_CORRELATED);
501
+ }
471
502
  const performArgs = performArgsOf(inner);
472
503
  // A document key has no element of its own: skip INSPECT and the box entirely.
473
504
  if (action === ActionType.PRESS) {
474
505
  try {
475
506
  const performed = await provider.perform(session.url, action, PRESS_HAS_NO_BOX, performArgs);
476
- if (!performed.performed)
507
+ if (!performed.performed) {
477
508
  return synthetic(InputModeReason.PROVIDER_DECLINED);
509
+ }
478
510
  // No `center`: a key addresses focus, not a point. The placeholder's zero happened nowhere.
479
511
  //
480
512
  // `heldMs` IS forwarded, and it is the whole reason this object is not just the three fields
@@ -521,13 +553,15 @@ export async function tryRealInput(deps, session, ref, action, args) {
521
553
  let box = asBox(inspected);
522
554
  // A box outside the viewport after the reveal is a gesture that would land on nothing. The
523
555
  // synthetic path scrolls and hit-tests on its own, so it is the honest answer.
524
- if (box === undefined || !source.inView)
556
+ if (box === undefined || !source.inView) {
525
557
  return synthetic(InputModeReason.ELEMENT_NOT_LOCATABLE);
558
+ }
526
559
  let toBox;
527
560
  if (action === ActionType.DRAG) {
528
561
  const toRef = asString(inner['toRef']);
529
- if (toRef === undefined)
562
+ if (toRef === undefined) {
530
563
  return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED);
564
+ }
531
565
  const target = await inspectRevealed(deps, session, toRef);
532
566
  const targetInspected = target.inspected;
533
567
  // A drag is judged on BOTH ends: dropping onto "Trash" is destructive however innocent the
@@ -549,12 +583,14 @@ export async function tryRealInput(deps, session, ref, action, args) {
549
583
  }
550
584
  }
551
585
  }
552
- if (toBox !== undefined)
586
+ if (toBox !== undefined) {
553
587
  performArgs.toBox = toBox;
588
+ }
554
589
  try {
555
590
  const performed = await provider.perform(session.url, action, box, performArgs);
556
- if (!performed.performed)
591
+ if (!performed.performed) {
557
592
  return synthetic(InputModeReason.PROVIDER_DECLINED);
593
+ }
558
594
  return {
559
595
  result: {
560
596
  performed: true,
@@ -566,8 +602,9 @@ export async function tryRealInput(deps, session, ref, action, args) {
566
602
  };
567
603
  }
568
604
  catch {
569
- if (session.versionSkew !== undefined)
605
+ if (session.versionSkew !== undefined) {
570
606
  throw new Error(session.versionSkew);
607
+ }
571
608
  return {
572
609
  result: undefined,
573
610
  settled: false,
@@ -585,8 +622,9 @@ export async function tryRealInput(deps, session, ref, action, args) {
585
622
  * A Proxy, not a copy: the session keeps private state its own methods read.
586
623
  */
587
624
  export function withLeaseKeyboard(deps, session) {
588
- if (deps.realInput !== undefined || deps.pool?.hasKeyboard(session.id) !== true)
625
+ if (deps.realInput !== undefined || deps.pool?.hasKeyboard(session.id) !== true) {
589
626
  return session;
627
+ }
590
628
  const command = async (name, args = {}, timeoutMs) => {
591
629
  if (ReticleCommand.ACT === name && ActionType.PRESS === args['action']) {
592
630
  const ref = asString(args['ref']) ?? '';
@@ -600,8 +638,9 @@ export function withLeaseKeyboard(deps, session) {
600
638
  };
601
639
  return new Proxy(session, {
602
640
  get(target, key) {
603
- if ('command' === key)
641
+ if ('command' === key) {
604
642
  return command;
643
+ }
605
644
  const value = Reflect.get(target, key, target);
606
645
  return 'function' === typeof value
607
646
  ? value.bind(target)
@@ -38,9 +38,21 @@ The bridge refuses a connect without the pairing token, also on localhost. `reti
38
38
 
39
39
  | Export | Signature | What it does |
40
40
  | --- | --- | --- |
41
- | `install` | `(): void` | Idempotent. Registers the `react` adapter and installs the render meter. Call it once, before `connect()` |
41
+ | `install` | `(options?: InstallOptions): void` | Idempotent. Registers the `react` adapter and installs the render meter. Call it once, before `connect()` |
42
42
 
43
- The [Vite plugin](/packages/vite-plugin) injects `install()` and `reticle.connect()` for you, so a Vite user never writes either line.
43
+ #### `InstallOptions`
44
+
45
+ | Option | Type | Default | What it does |
46
+ | --- | --- | --- | --- |
47
+ | `discoverStores` | `boolean` | `true` | When `true`, `install` walks the React fiber tree after each of the first ten commits and registers stores it finds in Redux `Provider`, TanStack `QueryClientProvider`, and similar context providers. Set to `false` to skip auto-discovery. Use this when your app has more than one context provider and you want only the stores you name with `registerStore` to appear in `reticle_look`. The render meter still runs. |
48
+
49
+ ```ts
50
+ // Only expose the store you control:
51
+ install({ discoverStores: false });
52
+ registerStore('safe', safeStore);
53
+ ```
54
+
55
+ The [Vite plugin](/packages/vite-plugin) injects `install()` and `reticle.connect()` for you. In Vite, pass `discoverStores: false` to the plugin (`reticle({ discoverStores: false })`) to disable auto-discovery. With manual setup, pass `{ discoverStores: false }` to `install()` before calling `reticle.connect()`.
44
56
 
45
57
  ### Mapping a node to its component
46
58
 
@@ -67,6 +67,7 @@ Plus a dynamic import of your dev module if one exists. It looks for `src/reticl
67
67
  | `present` | `boolean` | `true`. Set `false` to hide Reticle's presenter while keeping the agent connection active |
68
68
  | `sourceMapping` | `boolean` | `true`. Set `false` to skip the Babel pass |
69
69
  | `inject` | `boolean` | `true`. Set `false` to call `connect()` yourself |
70
+ | `discoverStores` | `boolean` | `true`. Set `false` to disable auto-discovery of context stores in React apps (`install({ discoverStores: false })`) |
70
71
  | `desktop` | `boolean` | `false`. When `true` the connect passes `allowInProduction: true`, which an Electron or Tauri renderer needs, and a `vite build` in a non-production mode (`--mode development`) is instrumented too. A production-mode build is stubbed like a web build |
71
72
  | `captureNetworkBodies` | `boolean` | `false`. Also enabled by `VITE_RETICLE_CAPTURE_BODIES=1` |
72
73
  | `networkBodyMaxChars` | `number` | `8192`, clamped to 256 to 262144. Also `VITE_RETICLE_BODY_MAX_CHARS` |
package/docs/skill.md CHANGED
@@ -3,7 +3,7 @@ name: reticle-docs
3
3
  description: Install, instrument and verify this running web app from the inside (DOM, network, routing, console and framework state) instead of screenshots or guessing. Drives one real flow end to end and returns a verdict with the file:line to fix. Use when the user asks to set up or install Reticle, when a user-facing change needs proving before you call it done, when a test passes but the UI is broken, or when the user types /reticle.
4
4
  license: Apache-2.0
5
5
  metadata:
6
- version: 3.8.1
6
+ version: 3.8.2
7
7
  homepage: https://docs.reticle.sh
8
8
  repository: https://github.com/reticlehq/reticle
9
9
  internal: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/server",
3
- "version": "3.8.1",
3
+ "version": "3.8.2",
4
4
  "mcpName": "io.github.reticlehq/reticle",
5
5
  "description": "Reticle CLI, bridge and MCP server: a coding agent drives your running web app from the inside and gets a verdict with evidence, for change verification, access control, accessibility, performance and SEO checks.",
6
6
  "type": "module",
@@ -23,11 +23,11 @@
23
23
  "@modelcontextprotocol/sdk": "^1.31.0",
24
24
  "ws": "^8.21.3",
25
25
  "zod": "^4.6.6",
26
- "@reticlehq/engine": "3.8.1",
27
- "@reticlehq/core": "3.8.1",
28
- "@reticlehq/init": "3.8.1",
29
- "open-verification": "1.0.0",
30
- "@reticlehq/browser": "3.8.1"
26
+ "@reticlehq/browser": "3.8.2",
27
+ "@reticlehq/engine": "3.8.2",
28
+ "open-verification": "1.1.0",
29
+ "@reticlehq/core": "3.8.2",
30
+ "@reticlehq/init": "3.8.2"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/pngjs": "^6.0.5",
@@ -64,7 +64,7 @@
64
64
  "cli"
65
65
  ],
66
66
  "reticle": {
67
- "changelog": "On Windows, the daemon now keeps running when the agent process that started it ends. A collapsed Next.js \"1 Issue\" badge no longer turns every check into unknown. Reticle moves to zod 4 for a high-severity memory advisory. reticle_lease can omit url and lease the project's own dev server. reticle doctor checks that the daemon answers, and streamed SSE response bodies can be captured. Replay waits for a start page that is still loading, a re-acquired lease moves to the page asked for, and a guarded app driven by fast clicks is no longer accused of applying a stale response. Update the SDK in your app as well as the CLI.",
67
+ "changelog": "3.8.1 could not record a verdict: every run it built or read failed with an Invalid element at key subject error. 3.8.2 fixes it. Update the CLI and the SDK in your app.",
68
68
  "breakingChanges": []
69
69
  },
70
70
  "publishConfig": {