@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.
- package/dist/portal/input/drive-error.d.ts +6 -0
- package/dist/portal/input/drive-error.js +9 -0
- package/dist/portal/input/real-input-press.d.ts +34 -0
- package/dist/portal/input/real-input-press.js +150 -0
- package/dist/portal/input/real-input.d.ts +6 -23
- package/dist/portal/input/real-input.js +95 -192
- package/dist/surface/tools/act-tools.js +2 -2
- package/dist/surface/tools/real-input-attempt.js +70 -31
- package/docs/packages/react.mdx +14 -2
- package/docs/packages/vite-plugin.mdx +1 -0
- package/docs/skill.md +1 -1
- package/package.json +7 -7
|
@@ -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,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 {
|
|
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
|
-
|
|
156
|
-
export
|
|
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,
|
|
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
|
-
|
|
24
|
-
export
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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);
|
|
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)
|
package/docs/packages/react.mdx
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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/
|
|
27
|
-
"@reticlehq/
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"@reticlehq/
|
|
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": "
|
|
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": {
|