dsh-browser-playwright-codex 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -0
- package/README.md +16 -4
- package/lib/browser-lifecycle.js +71 -4
- package/lib/client.js +97 -0
- package/lib/config.d.ts +11 -0
- package/lib/config.js +12 -1
- package/lib/contract.d.ts +6 -2
- package/lib/contract.js +8 -0
- package/lib/gates-patch.d.ts +29 -0
- package/lib/gates-patch.js +149 -0
- package/lib/provider.d.ts +6 -0
- package/lib/provider.js +10 -0
- package/lib/proxy.d.ts +56 -0
- package/lib/proxy.js +131 -0
- package/lib/script-runner.d.ts +65 -0
- package/lib/script-runner.js +280 -0
- package/lib/script-steps.d.ts +95 -0
- package/lib/script-steps.js +234 -0
- package/lib/script-store.d.ts +32 -0
- package/lib/script-store.js +104 -0
- package/lib/settings-card.js +62 -1
- package/lib/tool.js +143 -0
- package/package.json +1 -1
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { BrowserError } from "./errors.js";
|
|
2
|
+
import { describeStep, ScriptStepError } from "./script-steps.js";
|
|
3
|
+
/** How long a `urlContains` wait may take before the step fails. */
|
|
4
|
+
const URL_WAIT_TIMEOUT_MS = 15000;
|
|
5
|
+
/** How often a `urlContains` wait re-reads the URL. */
|
|
6
|
+
const URL_WAIT_INTERVAL_MS = 250;
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a target to a ref using the freshest snapshot.
|
|
9
|
+
*
|
|
10
|
+
* A given ref is passed through untouched: the session validates it, and its
|
|
11
|
+
* REF_NOT_FOUND is the honest answer when the page moved on. A label is matched
|
|
12
|
+
* by accessible name — exact first, then a unique case-insensitive substring —
|
|
13
|
+
* and an ambiguous or missing label fails loudly instead of clicking the first
|
|
14
|
+
* thing that looks close, because a script that silently clicks the wrong node
|
|
15
|
+
* is worse than one that stops.
|
|
16
|
+
*/
|
|
17
|
+
/** Every node in the tree, depth first: the labels a script targets live at any depth. */
|
|
18
|
+
function flatten(nodes) {
|
|
19
|
+
const out = [];
|
|
20
|
+
const walk = (list) => {
|
|
21
|
+
for (const node of list) {
|
|
22
|
+
out.push(node);
|
|
23
|
+
if (node.children.length > 0)
|
|
24
|
+
walk(node.children);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
walk(nodes);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
function resolveRef(snapshot, step, target) {
|
|
31
|
+
if (target.ref !== undefined)
|
|
32
|
+
return target.ref;
|
|
33
|
+
const wanted = String(target.text ?? '').trim();
|
|
34
|
+
// Walk the tree, not just the roots: a textbox sits three levels down under
|
|
35
|
+
// paragraph > form, and matching only the roots found nothing (real run on
|
|
36
|
+
// httpbin.org/forms/post, 2026-11-14).
|
|
37
|
+
const nodes = flatten(snapshot.nodes).filter((node) => node.ref !== undefined);
|
|
38
|
+
const exact = nodes.filter((node) => node.name.trim() === wanted);
|
|
39
|
+
if (exact.length === 1)
|
|
40
|
+
return String(exact[0]?.ref);
|
|
41
|
+
if (exact.length > 1) {
|
|
42
|
+
throw new ScriptStepError(step, String(exact.length) + ' elements are labelled "' + wanted + '"; use a ref instead');
|
|
43
|
+
}
|
|
44
|
+
const lower = wanted.toLowerCase();
|
|
45
|
+
const partial = nodes.filter((node) => node.name.toLowerCase().includes(lower));
|
|
46
|
+
if (partial.length === 1)
|
|
47
|
+
return String(partial[0]?.ref);
|
|
48
|
+
if (partial.length > 1) {
|
|
49
|
+
throw new ScriptStepError(step, String(partial.length) + ' elements contain "' + wanted + '"; use a ref instead');
|
|
50
|
+
}
|
|
51
|
+
throw new ScriptStepError(step, 'nothing on the page is labelled "' + wanted + '"');
|
|
52
|
+
}
|
|
53
|
+
/** Index of the step that failed, when the error came from a target lookup. */
|
|
54
|
+
function failedStepOf(error) {
|
|
55
|
+
const withStep = error;
|
|
56
|
+
return typeof withStep.step === 'number' ? withStep.step : undefined;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Execute every step in order, stopping at the first failure.
|
|
60
|
+
*
|
|
61
|
+
* @param session - The calling session's live browser.
|
|
62
|
+
* @param steps - Steps already validated by `parseScriptSteps`.
|
|
63
|
+
* @param options - Cancellation and the snapshot callback.
|
|
64
|
+
* @returns What ran, what failed, and the last working snapshot.
|
|
65
|
+
*/
|
|
66
|
+
export async function runScript(session, steps, options = {}) {
|
|
67
|
+
const outcomes = [];
|
|
68
|
+
const { signal } = options;
|
|
69
|
+
// A snapshot taken before the first step, so a `text` target in step 1 has
|
|
70
|
+
// something to resolve against without spending an extra tool call.
|
|
71
|
+
// Lazy: a script that starts with navigate must not pay for a snapshot of
|
|
72
|
+
// the page it is about to replace.
|
|
73
|
+
let snapshot;
|
|
74
|
+
const current = async () => {
|
|
75
|
+
if (snapshot === undefined) {
|
|
76
|
+
snapshot = await session.snapshot({ interactiveOnly: false });
|
|
77
|
+
options.onSnapshot?.(await current());
|
|
78
|
+
}
|
|
79
|
+
return snapshot;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a step's element, re-reading the page once when a label misses.
|
|
83
|
+
*
|
|
84
|
+
* A label that resolved on the first run can miss on a replay because the
|
|
85
|
+
* page rendered a beat late or a list came back one row shorter. Re-reading
|
|
86
|
+
* once costs a snapshot and fixes that whole class outright. An explicit
|
|
87
|
+
* `ref` is never retried: a ref belongs to the document that produced it, so
|
|
88
|
+
* a fresh snapshot can only return new ones — the honest answer stays "this
|
|
89
|
+
* ref is stale, take a new snapshot".
|
|
90
|
+
*/
|
|
91
|
+
const resolveTarget = async (index, target) => {
|
|
92
|
+
const first = await current();
|
|
93
|
+
try {
|
|
94
|
+
return resolveRef(first, index, target);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (target.ref !== undefined || signal?.aborted === true)
|
|
98
|
+
throw error;
|
|
99
|
+
snapshot = await session.snapshot({ interactiveOnly: false });
|
|
100
|
+
return resolveRef(snapshot, index, target);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Run one element action on a step's target, re-pointing it once when the
|
|
105
|
+
* page replaced the element between resolution and the action.
|
|
106
|
+
*
|
|
107
|
+
* Filling a search box is the canonical case: Wikipedia's header input is
|
|
108
|
+
* swapped for a new node the moment it takes focus, so the ref read from the
|
|
109
|
+
* snapshot `fill` returned is already stale for the `press` that follows
|
|
110
|
+
* (measured on the cloud box, 2026-11-14 — the Linux run failed at the Enter
|
|
111
|
+
* that had worked on Windows). A `REF_NOT_FOUND` also proves the action never
|
|
112
|
+
* ran, so re-pointing cannot double-click; every other failure is propagated
|
|
113
|
+
* untouched. A step that pinned an explicit `ref` is never retried: it asked
|
|
114
|
+
* for that exact node, and a fresh snapshot can only hand back new ones.
|
|
115
|
+
*/
|
|
116
|
+
const actOnTarget = async (index, target, action) => {
|
|
117
|
+
const ref = await resolveTarget(index, target);
|
|
118
|
+
try {
|
|
119
|
+
return { snapshot: await action(ref), healed: false };
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (target.ref !== undefined || signal?.aborted === true)
|
|
123
|
+
throw error;
|
|
124
|
+
if (!(error instanceof BrowserError) || error.code !== 'REF_NOT_FOUND')
|
|
125
|
+
throw error;
|
|
126
|
+
snapshot = await session.snapshot({ interactiveOnly: false });
|
|
127
|
+
const fresh = resolveRef(snapshot, index, target);
|
|
128
|
+
return { snapshot: await action(fresh), healed: true };
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const fail = (index, step, error, fallbackCode) => {
|
|
132
|
+
const browserError = error;
|
|
133
|
+
// The step error already prefixes "step N: "; the render adds the index
|
|
134
|
+
// again, so strip it here instead of printing "step 3" twice.
|
|
135
|
+
const raw = String(browserError.message ?? error);
|
|
136
|
+
const message = raw.replace(/^step \d+: /, '');
|
|
137
|
+
const base = {
|
|
138
|
+
ran: outcomes.length + 1,
|
|
139
|
+
total: steps.length,
|
|
140
|
+
failedAt: index,
|
|
141
|
+
failedAction: step.action,
|
|
142
|
+
failure: message,
|
|
143
|
+
failureCode: typeof browserError.code === 'string' ? browserError.code : fallbackCode,
|
|
144
|
+
outcomes: [...outcomes, { index, step: describeStep(step), ok: false, note: message }],
|
|
145
|
+
};
|
|
146
|
+
return snapshot === undefined ? base : { ...base, snapshot };
|
|
147
|
+
};
|
|
148
|
+
for (let i = 0; i < steps.length; i += 1) {
|
|
149
|
+
const index = i + 1;
|
|
150
|
+
const step = steps[i];
|
|
151
|
+
if (step === undefined)
|
|
152
|
+
break;
|
|
153
|
+
if (signal?.aborted === true)
|
|
154
|
+
break;
|
|
155
|
+
try {
|
|
156
|
+
switch (step.action) {
|
|
157
|
+
case 'navigate': {
|
|
158
|
+
snapshot = await session.navigate(step.url, step.waitFor ?? 'domcontentloaded', signal);
|
|
159
|
+
outcomes.push({ index, step: describeStep(step), ok: true, note: snapshot.title });
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case 'snapshot': {
|
|
163
|
+
snapshot = await session.snapshot({ interactiveOnly: false });
|
|
164
|
+
outcomes.push({ index, step: describeStep(step), ok: true, note: String(snapshot.totalRefs) + ' refs' });
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case 'click': {
|
|
168
|
+
const result = await actOnTarget(index, step.target, (ref) => session.click(ref, signal));
|
|
169
|
+
snapshot = result.snapshot;
|
|
170
|
+
outcomes.push({
|
|
171
|
+
index,
|
|
172
|
+
step: describeStep(step),
|
|
173
|
+
ok: true,
|
|
174
|
+
...(result.healed ? { note: 're-pointed after the page re-rendered' } : {}),
|
|
175
|
+
});
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case 'fill': {
|
|
179
|
+
const result = await actOnTarget(index, step.target, (ref) => session.fill(ref, step.value, signal));
|
|
180
|
+
snapshot = result.snapshot;
|
|
181
|
+
outcomes.push({
|
|
182
|
+
index,
|
|
183
|
+
step: describeStep(step),
|
|
184
|
+
ok: true,
|
|
185
|
+
...(result.healed ? { note: 're-pointed after the page re-rendered' } : {}),
|
|
186
|
+
});
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case 'press': {
|
|
190
|
+
const result = await actOnTarget(index, step.target, (ref) => session.press(ref, step.key, signal));
|
|
191
|
+
snapshot = result.snapshot;
|
|
192
|
+
outcomes.push({
|
|
193
|
+
index,
|
|
194
|
+
step: describeStep(step),
|
|
195
|
+
ok: true,
|
|
196
|
+
...(result.healed ? { note: 're-pointed after the page re-rendered' } : {}),
|
|
197
|
+
});
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case 'hover': {
|
|
201
|
+
const result = await actOnTarget(index, step.target, (ref) => session.hover(ref, signal));
|
|
202
|
+
snapshot = result.snapshot;
|
|
203
|
+
outcomes.push({
|
|
204
|
+
index,
|
|
205
|
+
step: describeStep(step),
|
|
206
|
+
ok: true,
|
|
207
|
+
...(result.healed ? { note: 're-pointed after the page re-rendered' } : {}),
|
|
208
|
+
});
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case 'scroll': {
|
|
212
|
+
// `target` is optional: without it the whole page scrolls, which is
|
|
213
|
+
// what "load more" flows need and what costs no element lookup.
|
|
214
|
+
const ref = step.target === undefined ? undefined : await resolveTarget(index, step.target);
|
|
215
|
+
snapshot = await session.scroll(step.direction, step.amount, ref, signal);
|
|
216
|
+
outcomes.push({ index, step: describeStep(step), ok: true });
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case 'back': {
|
|
220
|
+
snapshot = await session.back(signal);
|
|
221
|
+
outcomes.push({ index, step: describeStep(step), ok: true, note: snapshot.url });
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case 'forward': {
|
|
225
|
+
snapshot = await session.forward(signal);
|
|
226
|
+
outcomes.push({ index, step: describeStep(step), ok: true, note: snapshot.url });
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case 'wait': {
|
|
230
|
+
if (step.urlContains !== undefined) {
|
|
231
|
+
// Polling the snapshot's URL is the only honest "the click
|
|
232
|
+
// navigated" check available here: a text wait can be satisfied by
|
|
233
|
+
// the text the same script just typed (seen on npmjs.com,
|
|
234
|
+
// 2026-11-14 — the run reported the old page as the final one).
|
|
235
|
+
const needle = step.urlContains;
|
|
236
|
+
const deadline = Date.now() + URL_WAIT_TIMEOUT_MS;
|
|
237
|
+
let hit = false;
|
|
238
|
+
while (Date.now() < deadline) {
|
|
239
|
+
const probe = await session.snapshot({ interactiveOnly: true });
|
|
240
|
+
if (probe.url.includes(needle)) {
|
|
241
|
+
hit = true;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
await session.wait(URL_WAIT_INTERVAL_MS, signal);
|
|
245
|
+
}
|
|
246
|
+
if (!hit) {
|
|
247
|
+
throw new BrowserError('ACTION_TIMEOUT', 'the URL never contained "' + needle + '" within ' + String(URL_WAIT_TIMEOUT_MS / 1000) + 's');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
else if (step.ms !== undefined) {
|
|
251
|
+
await session.wait(step.ms, signal);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
const condition = step.text !== undefined
|
|
255
|
+
? { text: step.text }
|
|
256
|
+
: step.selector !== undefined
|
|
257
|
+
? { selector: step.selector }
|
|
258
|
+
: { networkIdle: true };
|
|
259
|
+
await session.waitForCondition(condition, signal);
|
|
260
|
+
}
|
|
261
|
+
snapshot = await session.snapshot({ interactiveOnly: false });
|
|
262
|
+
outcomes.push({ index, step: describeStep(step), ok: true });
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
options.onSnapshot?.(await current());
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
// A target lookup already names the step; a browser failure is reported
|
|
270
|
+
// with the index so the two read the same way to the model.
|
|
271
|
+
const stepIndex = failedStepOf(error) ?? index;
|
|
272
|
+
if (error instanceof BrowserError)
|
|
273
|
+
return fail(stepIndex, step, error, error.code);
|
|
274
|
+
return fail(stepIndex, step, error, 'SCRIPT_STEP_FAILED');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return snapshot === undefined
|
|
278
|
+
? { ran: outcomes.length, total: steps.length, outcomes }
|
|
279
|
+
: { ran: outcomes.length, total: steps.length, snapshot, outcomes };
|
|
280
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One saved browser script: a short, deterministic list of steps that a first
|
|
3
|
+
* successful run left behind, so a repeat run costs one tool call instead of a
|
|
4
|
+
* snapshot round-trip per action.
|
|
5
|
+
*
|
|
6
|
+
* Why a step list and not generated code: every mature runner in this space
|
|
7
|
+
* separates "explore once" from "replay deterministically" — Stagehand caches
|
|
8
|
+
* the resolved action and replays it with self-healing off, browser-use's
|
|
9
|
+
* workflow-use records a workflow with variables, and Playwright MCP ships its
|
|
10
|
+
* only batch entry point as `browser_run_code_unsafe`, behind an explicit
|
|
11
|
+
* "unsafe" name. A step list keeps the validation the individual tools already
|
|
12
|
+
* have (URL policy, ref freshness, credential refusal) that generated code
|
|
13
|
+
* would walk around.
|
|
14
|
+
*
|
|
15
|
+
* Pure: no I/O, no browser, no clock. The runner and the store stay separate so
|
|
16
|
+
* this module can be tested on its own.
|
|
17
|
+
* @module dsh-browser-playwright-codex/script-steps
|
|
18
|
+
*/
|
|
19
|
+
import type { LoadState } from './types.ts';
|
|
20
|
+
/** How a step names the element it acts on. */
|
|
21
|
+
export interface ScriptTarget {
|
|
22
|
+
/** Ref from the snapshot the previous step returned. Stale after a navigation. */
|
|
23
|
+
ref?: string;
|
|
24
|
+
/** Visible label, matched against the current snapshot's accessible names. */
|
|
25
|
+
text?: string;
|
|
26
|
+
}
|
|
27
|
+
/** One deterministic step. */
|
|
28
|
+
export type ScriptStep = {
|
|
29
|
+
action: 'navigate';
|
|
30
|
+
url: string;
|
|
31
|
+
waitFor?: LoadState;
|
|
32
|
+
} | {
|
|
33
|
+
action: 'snapshot';
|
|
34
|
+
} | {
|
|
35
|
+
action: 'click';
|
|
36
|
+
target: ScriptTarget;
|
|
37
|
+
} | {
|
|
38
|
+
action: 'fill';
|
|
39
|
+
target: ScriptTarget;
|
|
40
|
+
value: string;
|
|
41
|
+
} | {
|
|
42
|
+
action: 'press';
|
|
43
|
+
target: ScriptTarget;
|
|
44
|
+
key: string;
|
|
45
|
+
} | {
|
|
46
|
+
action: 'hover';
|
|
47
|
+
target: ScriptTarget;
|
|
48
|
+
} | {
|
|
49
|
+
action: 'scroll';
|
|
50
|
+
direction: 'up' | 'down';
|
|
51
|
+
amount: number;
|
|
52
|
+
target?: ScriptTarget;
|
|
53
|
+
} | {
|
|
54
|
+
action: 'back';
|
|
55
|
+
} | {
|
|
56
|
+
action: 'forward';
|
|
57
|
+
} | {
|
|
58
|
+
action: 'wait';
|
|
59
|
+
ms?: number;
|
|
60
|
+
text?: string;
|
|
61
|
+
selector?: string;
|
|
62
|
+
networkIdle?: boolean;
|
|
63
|
+
/** Wait until the page URL contains this substring — the only condition a “did the click navigate” check can trust. */
|
|
64
|
+
urlContains?: string;
|
|
65
|
+
};
|
|
66
|
+
/** Upper bound on the steps one script may carry. A longer list is a loop, not a script. */
|
|
67
|
+
export declare const MAX_SCRIPT_STEPS = 40;
|
|
68
|
+
/** Step actions this version understands. */
|
|
69
|
+
export declare const SCRIPT_ACTIONS: readonly ["navigate", "snapshot", "click", "fill", "press", "hover", "scroll", "back", "forward", "wait"];
|
|
70
|
+
/** Default scroll distance when a scroll step omits `amount`. */
|
|
71
|
+
export declare const DEFAULT_SCROLL_AMOUNT = 600;
|
|
72
|
+
/** Upper bound on one scroll step's distance, so a typo cannot jump to the page end. */
|
|
73
|
+
export declare const MAX_SCROLL_AMOUNT = 20000;
|
|
74
|
+
/** One step action name. */
|
|
75
|
+
export type ScriptAction = (typeof SCRIPT_ACTIONS)[number];
|
|
76
|
+
/** Raised for a malformed script; the message names the offending step. */
|
|
77
|
+
export declare class ScriptStepError extends Error {
|
|
78
|
+
/** Index of the offending step, 1-based as the model sees them. */
|
|
79
|
+
readonly step: number;
|
|
80
|
+
constructor(step: number, message: string);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Validate and normalise a script's steps.
|
|
84
|
+
*
|
|
85
|
+
* Throws {@link ScriptStepError} on the first bad step rather than collecting
|
|
86
|
+
* everything: a script runs as a unit, so the first error is the one that
|
|
87
|
+
* matters, and a partial list would read as if the rest were fine.
|
|
88
|
+
*
|
|
89
|
+
* @param raw - Steps exactly as the model sent them.
|
|
90
|
+
* @param maxSteps - Upper bound; a long list is a sign the model meant a loop.
|
|
91
|
+
* @returns The normalised steps.
|
|
92
|
+
*/
|
|
93
|
+
export declare function parseScriptSteps(raw: unknown, maxSteps: number): ScriptStep[];
|
|
94
|
+
/** One line per step, for logs and for the model's summary. */
|
|
95
|
+
export declare function describeStep(step: ScriptStep): string;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/** Upper bound on the steps one script may carry. A longer list is a loop, not a script. */
|
|
2
|
+
export const MAX_SCRIPT_STEPS = 40;
|
|
3
|
+
/** Step actions this version understands. */
|
|
4
|
+
export const SCRIPT_ACTIONS = [
|
|
5
|
+
'navigate',
|
|
6
|
+
'snapshot',
|
|
7
|
+
'click',
|
|
8
|
+
'fill',
|
|
9
|
+
'press',
|
|
10
|
+
'hover',
|
|
11
|
+
'scroll',
|
|
12
|
+
'back',
|
|
13
|
+
'forward',
|
|
14
|
+
'wait',
|
|
15
|
+
];
|
|
16
|
+
/** Default scroll distance when a scroll step omits `amount`. */
|
|
17
|
+
export const DEFAULT_SCROLL_AMOUNT = 600;
|
|
18
|
+
/** Upper bound on one scroll step's distance, so a typo cannot jump to the page end. */
|
|
19
|
+
export const MAX_SCROLL_AMOUNT = 20000;
|
|
20
|
+
/** Raised for a malformed script; the message names the offending step. */
|
|
21
|
+
export class ScriptStepError extends Error {
|
|
22
|
+
/** Index of the offending step, 1-based as the model sees them. */
|
|
23
|
+
step;
|
|
24
|
+
constructor(step, message) {
|
|
25
|
+
super('step ' + String(step) + ': ' + message);
|
|
26
|
+
this.name = 'ScriptStepError';
|
|
27
|
+
this.step = step;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Normalise a target given as a plain string or an object. */
|
|
31
|
+
function parseTarget(raw, step) {
|
|
32
|
+
if (typeof raw === 'string') {
|
|
33
|
+
const text = raw.trim();
|
|
34
|
+
if (text === '')
|
|
35
|
+
throw new ScriptStepError(step, 'target is an empty label');
|
|
36
|
+
return { text };
|
|
37
|
+
}
|
|
38
|
+
if (raw === null || typeof raw !== 'object') {
|
|
39
|
+
throw new ScriptStepError(step, 'target must be a ref, a label, or { ref } / { text }');
|
|
40
|
+
}
|
|
41
|
+
const record = raw;
|
|
42
|
+
const ref = typeof record.ref === 'string' ? record.ref.trim() : '';
|
|
43
|
+
const text = typeof record.text === 'string' ? record.text.trim() : '';
|
|
44
|
+
if (ref === '' && text === '')
|
|
45
|
+
throw new ScriptStepError(step, 'target needs a ref or a text label');
|
|
46
|
+
if (ref !== '' && text !== '')
|
|
47
|
+
throw new ScriptStepError(step, 'target takes a ref OR a text label, not both');
|
|
48
|
+
return ref !== '' ? { ref } : { text };
|
|
49
|
+
}
|
|
50
|
+
/** Resolve a step's target from either `target` or the `ref` / `text` shorthand. */
|
|
51
|
+
function targetOf(record, step) {
|
|
52
|
+
if (record.target !== undefined)
|
|
53
|
+
return parseTarget(record.target, step);
|
|
54
|
+
if (typeof record.ref === 'string')
|
|
55
|
+
return parseTarget({ ref: record.ref }, step);
|
|
56
|
+
if (typeof record.text === 'string')
|
|
57
|
+
return parseTarget({ text: record.text }, step);
|
|
58
|
+
return parseTarget(undefined, step);
|
|
59
|
+
}
|
|
60
|
+
/** How many conditions a wait step may carry: exactly one, like browser_wait. */
|
|
61
|
+
function countWaitConditions(step) {
|
|
62
|
+
let n = 0;
|
|
63
|
+
if (step.ms !== undefined)
|
|
64
|
+
n += 1;
|
|
65
|
+
if (step.text !== undefined)
|
|
66
|
+
n += 1;
|
|
67
|
+
if (step.selector !== undefined)
|
|
68
|
+
n += 1;
|
|
69
|
+
if (step.networkIdle === true)
|
|
70
|
+
n += 1;
|
|
71
|
+
if (step.urlContains !== undefined)
|
|
72
|
+
n += 1;
|
|
73
|
+
return n;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Validate and normalise a script's steps.
|
|
77
|
+
*
|
|
78
|
+
* Throws {@link ScriptStepError} on the first bad step rather than collecting
|
|
79
|
+
* everything: a script runs as a unit, so the first error is the one that
|
|
80
|
+
* matters, and a partial list would read as if the rest were fine.
|
|
81
|
+
*
|
|
82
|
+
* @param raw - Steps exactly as the model sent them.
|
|
83
|
+
* @param maxSteps - Upper bound; a long list is a sign the model meant a loop.
|
|
84
|
+
* @returns The normalised steps.
|
|
85
|
+
*/
|
|
86
|
+
export function parseScriptSteps(raw, maxSteps) {
|
|
87
|
+
if (!Array.isArray(raw))
|
|
88
|
+
throw new ScriptStepError(1, 'steps must be an array');
|
|
89
|
+
if (raw.length === 0)
|
|
90
|
+
throw new ScriptStepError(1, 'steps is empty');
|
|
91
|
+
if (raw.length > maxSteps) {
|
|
92
|
+
throw new ScriptStepError(1, 'steps has ' + String(raw.length) + ' entries; the cap is ' + String(maxSteps));
|
|
93
|
+
}
|
|
94
|
+
const out = [];
|
|
95
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
96
|
+
const step = 1 + i;
|
|
97
|
+
const item = raw[i];
|
|
98
|
+
if (item === null || typeof item !== 'object')
|
|
99
|
+
throw new ScriptStepError(step, 'each step must be an object');
|
|
100
|
+
const record = item;
|
|
101
|
+
const action = typeof record.action === 'string' ? record.action.trim() : '';
|
|
102
|
+
switch (action) {
|
|
103
|
+
case 'navigate': {
|
|
104
|
+
const url = typeof record.url === 'string' ? record.url.trim() : '';
|
|
105
|
+
if (url === '')
|
|
106
|
+
throw new ScriptStepError(step, 'navigate needs a url');
|
|
107
|
+
const waitFor = record.waitFor;
|
|
108
|
+
if (waitFor !== undefined &&
|
|
109
|
+
waitFor !== 'load' &&
|
|
110
|
+
waitFor !== 'domcontentloaded' &&
|
|
111
|
+
waitFor !== 'networkidle') {
|
|
112
|
+
throw new ScriptStepError(step, 'waitFor must be load, domcontentloaded or networkidle');
|
|
113
|
+
}
|
|
114
|
+
out.push(waitFor === undefined ? { action, url } : { action, url, waitFor: waitFor });
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case 'snapshot':
|
|
118
|
+
out.push({ action });
|
|
119
|
+
break;
|
|
120
|
+
case 'click':
|
|
121
|
+
out.push({ action, target: targetOf(record, step) });
|
|
122
|
+
break;
|
|
123
|
+
case 'fill': {
|
|
124
|
+
const value = typeof record.value === 'string' ? record.value : undefined;
|
|
125
|
+
if (value === undefined)
|
|
126
|
+
throw new ScriptStepError(step, 'fill needs a value');
|
|
127
|
+
out.push({ action, target: targetOf(record, step), value });
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case 'press': {
|
|
131
|
+
const key = typeof record.key === 'string' ? record.key.trim() : '';
|
|
132
|
+
if (key === '')
|
|
133
|
+
throw new ScriptStepError(step, 'press needs a key (e.g. Enter, Escape, Tab)');
|
|
134
|
+
out.push({ action, target: targetOf(record, step), key });
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case 'hover':
|
|
138
|
+
out.push({ action, target: targetOf(record, step) });
|
|
139
|
+
break;
|
|
140
|
+
case 'scroll': {
|
|
141
|
+
const direction = record.direction;
|
|
142
|
+
if (direction !== 'up' && direction !== 'down') {
|
|
143
|
+
throw new ScriptStepError(step, 'scroll needs direction "up" or "down"');
|
|
144
|
+
}
|
|
145
|
+
const rawAmount = record.amount === undefined ? DEFAULT_SCROLL_AMOUNT : record.amount;
|
|
146
|
+
if (typeof rawAmount !== 'number' || !Number.isFinite(rawAmount) || rawAmount <= 0) {
|
|
147
|
+
throw new ScriptStepError(step, 'scroll amount must be a positive number of pixels');
|
|
148
|
+
}
|
|
149
|
+
if (rawAmount > MAX_SCROLL_AMOUNT) {
|
|
150
|
+
throw new ScriptStepError(step, 'scroll amount is capped at ' + String(MAX_SCROLL_AMOUNT) + 'px');
|
|
151
|
+
}
|
|
152
|
+
const amount = Math.round(rawAmount);
|
|
153
|
+
// `target` is optional here: without it the whole page scrolls. Parse it
|
|
154
|
+
// only when the step actually carries one, so a plain page scroll does
|
|
155
|
+
// not require a ref the page may not have.
|
|
156
|
+
const hasTarget = record.target !== undefined || record.ref !== undefined || record.text !== undefined;
|
|
157
|
+
const parsed = { action, direction, amount };
|
|
158
|
+
out.push(hasTarget ? { ...parsed, target: targetOf(record, step) } : parsed);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case 'back':
|
|
162
|
+
case 'forward':
|
|
163
|
+
out.push({ action });
|
|
164
|
+
break;
|
|
165
|
+
case 'wait': {
|
|
166
|
+
const conditions = countWaitConditions(record);
|
|
167
|
+
if (conditions === 0) {
|
|
168
|
+
throw new ScriptStepError(step, 'wait needs exactly one of ms, text, selector, networkIdle, urlContains');
|
|
169
|
+
}
|
|
170
|
+
if (conditions > 1)
|
|
171
|
+
throw new ScriptStepError(step, 'wait takes exactly one condition, got ' + String(conditions));
|
|
172
|
+
const stepOut = { action };
|
|
173
|
+
if (typeof record.ms === 'number')
|
|
174
|
+
stepOut.ms = record.ms;
|
|
175
|
+
if (typeof record.text === 'string')
|
|
176
|
+
stepOut.text = record.text;
|
|
177
|
+
if (typeof record.selector === 'string')
|
|
178
|
+
stepOut.selector = record.selector;
|
|
179
|
+
if (record.networkIdle === true)
|
|
180
|
+
stepOut.networkIdle = true;
|
|
181
|
+
if (typeof record.urlContains === 'string' && record.urlContains.trim() !== '') {
|
|
182
|
+
stepOut.urlContains = record.urlContains.trim();
|
|
183
|
+
}
|
|
184
|
+
out.push(stepOut);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
default:
|
|
188
|
+
throw new ScriptStepError(step, 'unknown action ' +
|
|
189
|
+
(action === '' ? '(missing)' : '"' + action + '"') +
|
|
190
|
+
'; supported: ' +
|
|
191
|
+
SCRIPT_ACTIONS.join(', '));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
/** One line per step, for logs and for the model's summary. */
|
|
197
|
+
export function describeStep(step) {
|
|
198
|
+
const targetLabel = (target) => target.ref === undefined ? '"' + String(target.text) + '"' : target.ref;
|
|
199
|
+
switch (step.action) {
|
|
200
|
+
case 'navigate':
|
|
201
|
+
return 'navigate ' + step.url;
|
|
202
|
+
case 'snapshot':
|
|
203
|
+
return 'snapshot';
|
|
204
|
+
case 'click':
|
|
205
|
+
return 'click ' + targetLabel(step.target);
|
|
206
|
+
case 'fill':
|
|
207
|
+
return 'fill ' + targetLabel(step.target);
|
|
208
|
+
case 'press':
|
|
209
|
+
return 'press ' + step.key + ' on ' + targetLabel(step.target);
|
|
210
|
+
case 'hover':
|
|
211
|
+
return 'hover ' + targetLabel(step.target);
|
|
212
|
+
case 'scroll':
|
|
213
|
+
return ('scroll ' +
|
|
214
|
+
step.direction +
|
|
215
|
+
' ' +
|
|
216
|
+
String(step.amount) +
|
|
217
|
+
'px' +
|
|
218
|
+
(step.target === undefined ? '' : ' in ' + targetLabel(step.target)));
|
|
219
|
+
case 'back':
|
|
220
|
+
return 'back';
|
|
221
|
+
case 'forward':
|
|
222
|
+
return 'forward';
|
|
223
|
+
case 'wait':
|
|
224
|
+
if (step.ms !== undefined)
|
|
225
|
+
return 'wait ' + String(step.ms) + 'ms';
|
|
226
|
+
if (step.text !== undefined)
|
|
227
|
+
return 'wait for text "' + step.text + '"';
|
|
228
|
+
if (step.selector !== undefined)
|
|
229
|
+
return 'wait for ' + step.selector;
|
|
230
|
+
if (step.urlContains !== undefined)
|
|
231
|
+
return 'wait for url containing ' + step.urlContains;
|
|
232
|
+
return 'wait for network idle';
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type ScriptStep } from './script-steps.ts';
|
|
2
|
+
/** Directory holding the saved scripts. `DSH_BROWSER_SCRIPTS_DIR` overrides it (tests). */
|
|
3
|
+
export declare function scriptsDir(): string;
|
|
4
|
+
/** Validate a script name, returning the trimmed value. */
|
|
5
|
+
export declare function validName(raw: unknown): string;
|
|
6
|
+
/** One saved script, as stored. */
|
|
7
|
+
export interface StoredScript {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly savedAt: string;
|
|
10
|
+
readonly steps: readonly ScriptStep[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Write a script, replacing any file of the same name.
|
|
14
|
+
*
|
|
15
|
+
* @param name - Validated script name.
|
|
16
|
+
* @param steps - Steps already validated by `parseScriptSteps`.
|
|
17
|
+
* @returns The file path and how many steps were stored.
|
|
18
|
+
*/
|
|
19
|
+
export declare function saveScript(name: string, steps: readonly ScriptStep[]): {
|
|
20
|
+
path: string;
|
|
21
|
+
steps: number;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Read one script.
|
|
25
|
+
*
|
|
26
|
+
* @param name - Validated script name.
|
|
27
|
+
* @param maxSteps - Cap applied to the file's steps, so an edited file cannot
|
|
28
|
+
* smuggle in a longer list than the tool accepts.
|
|
29
|
+
*/
|
|
30
|
+
export declare function loadScript(name: string, maxSteps: number): StoredScript;
|
|
31
|
+
/** Names of the saved scripts, sorted. Missing directory means none yet. */
|
|
32
|
+
export declare function listScripts(): string[];
|