craftdriver 1.7.0 → 1.8.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 +23 -11
- package/README.md +13 -4
- package/dist/cli/agentSession.d.ts +62 -0
- package/dist/cli/agentSession.d.ts.map +1 -0
- package/dist/cli/agentSession.js +100 -0
- package/dist/cli/agentSession.js.map +1 -0
- package/dist/cli/artifactPaths.d.ts +42 -0
- package/dist/cli/artifactPaths.d.ts.map +1 -0
- package/dist/cli/artifactPaths.js +144 -0
- package/dist/cli/artifactPaths.js.map +1 -0
- package/dist/cli/bounds.d.ts +88 -0
- package/dist/cli/bounds.d.ts.map +1 -0
- package/dist/cli/bounds.js +128 -0
- package/dist/cli/bounds.js.map +1 -0
- package/dist/cli/client.d.ts +3 -0
- package/dist/cli/client.d.ts.map +1 -1
- package/dist/cli/client.js +24 -8
- package/dist/cli/client.js.map +1 -1
- package/dist/cli/daemon.d.ts +3 -0
- package/dist/cli/daemon.d.ts.map +1 -1
- package/dist/cli/daemon.js +152 -39
- package/dist/cli/daemon.js.map +1 -1
- package/dist/cli/defaults.d.ts +23 -3
- package/dist/cli/defaults.d.ts.map +1 -1
- package/dist/cli/defaults.js +50 -5
- package/dist/cli/defaults.js.map +1 -1
- package/dist/cli/dispatcher.d.ts +54 -11
- package/dist/cli/dispatcher.d.ts.map +1 -1
- package/dist/cli/dispatcher.js +851 -54
- package/dist/cli/dispatcher.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +305 -56
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.d.ts +14 -14
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +328 -180
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/journal.d.ts +153 -0
- package/dist/cli/journal.d.ts.map +1 -0
- package/dist/cli/journal.js +252 -0
- package/dist/cli/journal.js.map +1 -0
- package/dist/cli/lineReader.d.ts +33 -0
- package/dist/cli/lineReader.d.ts.map +1 -0
- package/dist/cli/lineReader.js +66 -0
- package/dist/cli/lineReader.js.map +1 -0
- package/dist/cli/locatorCandidates.d.ts +44 -0
- package/dist/cli/locatorCandidates.d.ts.map +1 -0
- package/dist/cli/locatorCandidates.js +190 -0
- package/dist/cli/locatorCandidates.js.map +1 -0
- package/dist/cli/mcp/artifacts.d.ts +39 -1
- package/dist/cli/mcp/artifacts.d.ts.map +1 -1
- package/dist/cli/mcp/artifacts.js +104 -9
- package/dist/cli/mcp/artifacts.js.map +1 -1
- package/dist/cli/mcp/bounds.d.ts +65 -0
- package/dist/cli/mcp/bounds.d.ts.map +1 -0
- package/dist/cli/mcp/bounds.js +124 -0
- package/dist/cli/mcp/bounds.js.map +1 -0
- package/dist/cli/mcp/params.d.ts +38 -0
- package/dist/cli/mcp/params.d.ts.map +1 -0
- package/dist/cli/mcp/params.js +174 -0
- package/dist/cli/mcp/params.js.map +1 -0
- package/dist/cli/mcp/server.d.ts +94 -16
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/server.js +304 -150
- package/dist/cli/mcp/server.js.map +1 -1
- package/dist/cli/mcp/tools.d.ts +62 -17
- package/dist/cli/mcp/tools.d.ts.map +1 -1
- package/dist/cli/mcp/tools.js +373 -158
- package/dist/cli/mcp/tools.js.map +1 -1
- package/dist/cli/pageSemantics.d.ts +42 -0
- package/dist/cli/pageSemantics.d.ts.map +1 -0
- package/dist/cli/pageSemantics.js +194 -0
- package/dist/cli/pageSemantics.js.map +1 -0
- package/dist/cli/parseArgs.d.ts +6 -18
- package/dist/cli/parseArgs.d.ts.map +1 -1
- package/dist/cli/parseArgs.js +689 -45
- package/dist/cli/parseArgs.js.map +1 -1
- package/dist/cli/protocol.d.ts +8 -0
- package/dist/cli/protocol.d.ts.map +1 -1
- package/dist/cli/sessionRegistry.d.ts +42 -0
- package/dist/cli/sessionRegistry.d.ts.map +1 -0
- package/dist/cli/sessionRegistry.js +99 -0
- package/dist/cli/sessionRegistry.js.map +1 -0
- package/dist/cli/snapshot.d.ts +100 -18
- package/dist/cli/snapshot.d.ts.map +1 -1
- package/dist/cli/snapshot.js +248 -102
- package/dist/cli/snapshot.js.map +1 -1
- package/dist/cli/stateStore.d.ts +50 -0
- package/dist/cli/stateStore.d.ts.map +1 -0
- package/dist/cli/stateStore.js +122 -0
- package/dist/cli/stateStore.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/bidi/index.d.ts +1 -1
- package/dist/lib/bidi/index.d.ts.map +1 -1
- package/dist/lib/bidi/index.js +2 -2
- package/dist/lib/bidi/index.js.map +1 -1
- package/dist/lib/bidi/logs.d.ts +3 -0
- package/dist/lib/bidi/logs.d.ts.map +1 -1
- package/dist/lib/bidi/logs.js +7 -0
- package/dist/lib/bidi/logs.js.map +1 -1
- package/dist/lib/bidi/network.d.ts +14 -1
- package/dist/lib/bidi/network.d.ts.map +1 -1
- package/dist/lib/bidi/network.js +45 -7
- package/dist/lib/bidi/network.js.map +1 -1
- package/dist/lib/bidi/storage.d.ts +30 -6
- package/dist/lib/bidi/storage.d.ts.map +1 -1
- package/dist/lib/bidi/storage.js +190 -45
- package/dist/lib/bidi/storage.js.map +1 -1
- package/dist/lib/bidi/types.d.ts +7 -2
- package/dist/lib/bidi/types.d.ts.map +1 -1
- package/dist/lib/browser.d.ts +6 -3
- package/dist/lib/browser.d.ts.map +1 -1
- package/dist/lib/browser.js +140 -30
- package/dist/lib/browser.js.map +1 -1
- package/dist/lib/browserContext.d.ts +54 -18
- package/dist/lib/browserContext.d.ts.map +1 -1
- package/dist/lib/browserContext.js +295 -92
- package/dist/lib/browserContext.js.map +1 -1
- package/dist/lib/by.d.ts +3 -3
- package/dist/lib/by.d.ts.map +1 -1
- package/dist/lib/by.js +7 -3
- package/dist/lib/by.js.map +1 -1
- package/dist/lib/driver.d.ts +7 -0
- package/dist/lib/driver.d.ts.map +1 -1
- package/dist/lib/driver.js +12 -0
- package/dist/lib/driver.js.map +1 -1
- package/dist/lib/errors.d.ts +8 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +8 -0
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/initScript.d.ts +9 -0
- package/dist/lib/initScript.d.ts.map +1 -0
- package/dist/lib/initScript.js +14 -0
- package/dist/lib/initScript.js.map +1 -0
- package/dist/lib/page.d.ts +22 -0
- package/dist/lib/page.d.ts.map +1 -1
- package/dist/lib/page.js +48 -0
- package/dist/lib/page.js.map +1 -1
- package/dist/lib/secureFile.d.ts +8 -0
- package/dist/lib/secureFile.d.ts.map +1 -0
- package/dist/lib/secureFile.js +67 -0
- package/dist/lib/secureFile.js.map +1 -0
- package/dist/lib/sessionStateValidation.d.ts +35 -0
- package/dist/lib/sessionStateValidation.d.ts.map +1 -0
- package/dist/lib/sessionStateValidation.js +223 -0
- package/dist/lib/sessionStateValidation.js.map +1 -0
- package/dist/lib/tracing.d.ts +2 -1
- package/dist/lib/tracing.d.ts.map +1 -1
- package/dist/lib/tracing.js +12 -1
- package/dist/lib/tracing.js.map +1 -1
- package/docs/agents.md +56 -61
- package/docs/api-reference.md +2 -1
- package/docs/browser-api.md +24 -4
- package/docs/browser-context.md +18 -12
- package/docs/cli.md +346 -26
- package/docs/error-codes.md +5 -4
- package/docs/getting-started.md +6 -1
- package/docs/index.md +1 -1
- package/docs/mcp.md +129 -62
- package/docs/public/examples/agent-actions.html +48 -0
- package/docs/public/examples/agent-debug.html +86 -0
- package/docs/public/examples/agent-late-mutation.html +18 -0
- package/docs/public/examples/agent-ref-shift.html +33 -0
- package/docs/recipes/login-once-reuse-session.md +32 -4
- package/docs/session-management.md +64 -3
- package/docs/why-craftdriver.md +1 -1
- package/package.json +3 -1
- package/skills/craftdriver/SKILL.md +59 -75
- package/skills/craftdriver/agents/openai.yaml +4 -0
- package/skills/craftdriver/cheatsheet.md +18 -4
- package/skills/craftdriver/cli.md +194 -68
- package/skills/craftdriver/patterns.md +14 -8
- package/skills/craftdriver/workflow.md +149 -0
package/dist/cli/dispatcher.js
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command dispatcher — single source of truth for "what each CLI
|
|
3
|
+
* command does to a Browser instance".
|
|
4
|
+
*
|
|
5
|
+
* Pure logic: takes a `cmd` name + `args` object, returns either a
|
|
6
|
+
* JSON-serializable result or throws `CraftdriverError`. AgentSession
|
|
7
|
+
* serializes calls from daemon, ephemeral CLI, and MCP transports over
|
|
8
|
+
* this shared dispatch table.
|
|
9
|
+
*
|
|
10
|
+
* Browser launch and shutdown live in AgentSession; command-specific
|
|
11
|
+
* browser behavior lives here.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { resolve as resolvePath } from 'node:path';
|
|
1
15
|
import { CraftdriverError, ErrorCode } from '../lib/errors.js';
|
|
2
16
|
import { parseSelector, describeSelector } from './selector.js';
|
|
3
|
-
import { takeSnapshot } from './snapshot.js';
|
|
17
|
+
import { takeSnapshot, peekDialog, probeRef, describeRefStatus, } from './snapshot.js';
|
|
18
|
+
import { locatorCandidates } from './locatorCandidates.js';
|
|
19
|
+
import { artifactRoot, resolveArtifactPath } from './artifactPaths.js';
|
|
20
|
+
import { stateRoot, resolveStatePath, prepareTempStatePath, commitStateFile, discardTempStateFile, readStateFile, summarizeState, listStateNames, } from './stateStore.js';
|
|
4
21
|
import { AGENT_DEFAULT_TIMEOUT_MS, AGENT_DEFAULT_LIMIT } from './defaults.js';
|
|
22
|
+
import { boundValue, boundString, clampLimit, MAX_TEXT_CHARS } from './bounds.js';
|
|
5
23
|
export function createBrowserHandle(launch) {
|
|
6
24
|
let inst = null;
|
|
7
25
|
let pending = null;
|
|
@@ -9,16 +27,27 @@ export function createBrowserHandle(launch) {
|
|
|
9
27
|
async get() {
|
|
10
28
|
if (inst)
|
|
11
29
|
return inst;
|
|
12
|
-
|
|
13
|
-
|
|
30
|
+
// Clear `pending` on failure as well as success. Leaving the rejected
|
|
31
|
+
// promise cached meant one bad launch — a missing driver, a port in
|
|
32
|
+
// use — poisoned the session: every later command replayed the same
|
|
33
|
+
// stale error and the only recovery was closing the session.
|
|
34
|
+
if (!pending) {
|
|
35
|
+
pending = launch().then((b) => { inst = b; pending = null; return b; }, (err) => { pending = null; throw err; });
|
|
36
|
+
}
|
|
14
37
|
return pending;
|
|
15
38
|
},
|
|
16
39
|
peek() { return inst; },
|
|
17
40
|
async close() {
|
|
18
41
|
const b = inst;
|
|
19
42
|
inst = null;
|
|
20
|
-
if (b)
|
|
21
|
-
|
|
43
|
+
if (b) {
|
|
44
|
+
try {
|
|
45
|
+
await b.quit();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* ignore browser cleanup errors */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
22
51
|
},
|
|
23
52
|
};
|
|
24
53
|
}
|
|
@@ -51,6 +80,16 @@ function optStr(args, key) {
|
|
|
51
80
|
function bool(args, key) {
|
|
52
81
|
return args?.[key] === true || args?.[key] === 'true';
|
|
53
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* A caller-supplied list limit, pulled into range.
|
|
85
|
+
*
|
|
86
|
+
* `--limit -5` silently returned nothing and `--limit 1e9` defeated the paging
|
|
87
|
+
* that keeps list output readable; neither was rejected. Clamping keeps the
|
|
88
|
+
* command answerable, which is what the agent asked for.
|
|
89
|
+
*/
|
|
90
|
+
function limitOf(args, dflt) {
|
|
91
|
+
return clampLimit(int(args, 'limit', dflt), dflt);
|
|
92
|
+
}
|
|
54
93
|
function int(args, key, dflt) {
|
|
55
94
|
const v = args?.[key];
|
|
56
95
|
if (typeof v === 'number' && Number.isInteger(v))
|
|
@@ -64,6 +103,239 @@ function snippet(s, max = 60) {
|
|
|
64
103
|
const trimmed = s.replace(/\s+/g, ' ').trim();
|
|
65
104
|
return trimmed.length > max ? trimmed.slice(0, max - 1) + '…' : trimmed;
|
|
66
105
|
}
|
|
106
|
+
/** Dialog prompts are attacker-influenced page content — keep them bounded. */
|
|
107
|
+
const MAX_DIALOG_TEXT = 500;
|
|
108
|
+
const MAX_UPLOAD_FILES = 10;
|
|
109
|
+
/**
|
|
110
|
+
* Read an explicit list of local paths to upload. Bounded in count, and
|
|
111
|
+
* every path must exist — a silent no-op upload is worse than an error
|
|
112
|
+
* because the agent then asserts against a form that never received a file.
|
|
113
|
+
*/
|
|
114
|
+
function fileList(args) {
|
|
115
|
+
const raw = args?.files ?? args?.file ?? args?.path;
|
|
116
|
+
const list = Array.isArray(raw)
|
|
117
|
+
? raw
|
|
118
|
+
: typeof raw === 'string' && raw.length > 0
|
|
119
|
+
? raw.split(',')
|
|
120
|
+
: [];
|
|
121
|
+
const files = list
|
|
122
|
+
.map((f) => (typeof f === 'string' ? f.trim() : ''))
|
|
123
|
+
.filter((f) => f.length > 0);
|
|
124
|
+
if (files.length === 0) {
|
|
125
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, 'upload: missing required argument "files"', { hint: 'pass one or more local paths, e.g. --files ./a.png,./b.png' });
|
|
126
|
+
}
|
|
127
|
+
if (files.length > MAX_UPLOAD_FILES) {
|
|
128
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `upload: too many files (${files.length}); the limit is ${MAX_UPLOAD_FILES}`);
|
|
129
|
+
}
|
|
130
|
+
for (const f of files) {
|
|
131
|
+
const resolved = resolvePath(f);
|
|
132
|
+
if (!existsSync(resolved)) {
|
|
133
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `upload: file not found: ${f}`, { detail: { file: f } });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return files.map((f) => resolvePath(f));
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Caps on the mocking surface. Bounded because a mock is installed from
|
|
140
|
+
* untrusted socket input and lives until removed.
|
|
141
|
+
*/
|
|
142
|
+
const MAX_MOCKS = 20;
|
|
143
|
+
const MAX_MOCK_PATTERN = 500;
|
|
144
|
+
const MAX_MOCK_BODY = 64 * 1024;
|
|
145
|
+
/** Owned root for trace output, overridable like the state root. */
|
|
146
|
+
function traceRoot() {
|
|
147
|
+
return artifactRoot('traces', 'CRAFTDRIVER_TRACE_DIR');
|
|
148
|
+
}
|
|
149
|
+
const JOURNAL_KINDS = ['console', 'error', 'request', 'response'];
|
|
150
|
+
/**
|
|
151
|
+
* Read a `--kind` filter, rejecting unknown values rather than silently
|
|
152
|
+
* matching nothing — a typo'd kind that returns an empty list reads exactly
|
|
153
|
+
* like a page that logged nothing.
|
|
154
|
+
*/
|
|
155
|
+
function parseKinds(args) {
|
|
156
|
+
const raw = optStr(args, 'kind');
|
|
157
|
+
if (!raw)
|
|
158
|
+
return undefined;
|
|
159
|
+
const kinds = raw
|
|
160
|
+
.split(',')
|
|
161
|
+
.map((k) => k.trim().toLowerCase())
|
|
162
|
+
.filter((k) => k.length > 0);
|
|
163
|
+
const unknown = kinds.filter((k) => !JOURNAL_KINDS.includes(k));
|
|
164
|
+
if (unknown.length > 0) {
|
|
165
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `logs: unknown kind "${unknown[0]}"`, {
|
|
166
|
+
detail: { known: JOURNAL_KINDS },
|
|
167
|
+
hint: `expected one or more of: ${JOURNAL_KINDS.join(', ')}`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return kinds;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Drop everything that described the browser that just went away.
|
|
174
|
+
*
|
|
175
|
+
* All of this state is browser-owned: the ref registry lives in the page, the
|
|
176
|
+
* trace writer and the network intercepts die with the process, and the
|
|
177
|
+
* journal's listeners are bound to the old session. Keeping any of it across
|
|
178
|
+
* a `quit` made the session describe a browser that no longer existed —
|
|
179
|
+
* `trace status` reporting an aborted trace as running, `mock list` reporting
|
|
180
|
+
* intercepts that were gone, and the previous browser's console output
|
|
181
|
+
* appearing in the replacement's logs.
|
|
182
|
+
*
|
|
183
|
+
* Two counters deliberately survive, for the same reason: the ref high-water
|
|
184
|
+
* mark (numbers are never reissued) and the journal sequence (a cursor an
|
|
185
|
+
* agent still holds must not start matching new entries).
|
|
186
|
+
*/
|
|
187
|
+
export function resetBrowserOwnedState(ctx) {
|
|
188
|
+
ctx.tracker.reset();
|
|
189
|
+
ctx.activeTrace = null;
|
|
190
|
+
ctx.mocks = [];
|
|
191
|
+
ctx.journal?.detach();
|
|
192
|
+
ctx.journal?.clear();
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Commands that can change the page, and therefore earn one bounded
|
|
196
|
+
* post-action snapshot. Kept here rather than in a transport so CLI and
|
|
197
|
+
* MCP cannot disagree about what "mutating" means.
|
|
198
|
+
*/
|
|
199
|
+
const MUTATING = new Set([
|
|
200
|
+
'go', 'back', 'forward', 'reload',
|
|
201
|
+
'click', 'dblclick', 'fill', 'clear', 'type', 'press', 'hover',
|
|
202
|
+
'check', 'uncheck', 'select', 'focus', 'scroll',
|
|
203
|
+
'key', 'mouse', 'dialog', 'upload',
|
|
204
|
+
// `eval` runs arbitrary page script, so it is the *most* likely thing to
|
|
205
|
+
// change the page. Leaving it out meant its changes went unreported and
|
|
206
|
+
// were then blamed on whatever ran next.
|
|
207
|
+
'eval',
|
|
208
|
+
]);
|
|
209
|
+
export function isMutating(cmd) {
|
|
210
|
+
return MUTATING.has(cmd);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Origin of the active page, or null when it has none (`about:blank`, a
|
|
214
|
+
* `data:` URL, or a driver that cannot report one).
|
|
215
|
+
*
|
|
216
|
+
* Read from the page URL rather than by evaluating `location.origin`: this is
|
|
217
|
+
* used on the storage path, where a modal dialog would block script execution
|
|
218
|
+
* and turn a cheap check into a script-timeout wait.
|
|
219
|
+
*/
|
|
220
|
+
async function activeOrigin(browser) {
|
|
221
|
+
const url = await browser
|
|
222
|
+
.activePage()
|
|
223
|
+
.then((p) => p.url())
|
|
224
|
+
.catch(() => '');
|
|
225
|
+
try {
|
|
226
|
+
const { origin } = new URL(url);
|
|
227
|
+
// `about:` and `data:` URLs parse but have the opaque origin "null".
|
|
228
|
+
return origin && origin !== 'null' ? origin : null;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function staleRef(ref, reason) {
|
|
235
|
+
return new CraftdriverError(ErrorCode.STALE_REF, `ref=${ref} is stale: ${describeRefStatus(reason)}`, {
|
|
236
|
+
detail: { ref, reason },
|
|
237
|
+
hint: 'take a fresh `snapshot` and use the new ref; refs are never reassigned',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Parse a selector argument, validating snapshot refs against the live
|
|
242
|
+
* page first.
|
|
243
|
+
*
|
|
244
|
+
* A ref must still identify exactly one element in the document that
|
|
245
|
+
* issued it. Anything else fails STALE_REF here, before the selector
|
|
246
|
+
* reaches a browser action — that is the whole point: a ref must never
|
|
247
|
+
* resolve to a node the agent has not inspected.
|
|
248
|
+
*/
|
|
249
|
+
async function selectorOf(ctx, args, key = 'selector') {
|
|
250
|
+
return resolveSelector(ctx, str(args, key));
|
|
251
|
+
}
|
|
252
|
+
async function resolveSelector(ctx, raw) {
|
|
253
|
+
const by = parseSelector(raw);
|
|
254
|
+
// Match exactly what parseSelector treats as a ref — it lowercases the
|
|
255
|
+
// prefix and trims the value, so `REF=e5` and `ref= e5` are refs too.
|
|
256
|
+
// A stricter pattern here would let those through unvalidated, silently
|
|
257
|
+
// restoring the unsafe raw-attribute lookup this exists to prevent.
|
|
258
|
+
const match = /^\s*ref\s*=\s*(e\d+)\s*$/i.exec(raw);
|
|
259
|
+
if (!match)
|
|
260
|
+
return by;
|
|
261
|
+
const ref = match[1];
|
|
262
|
+
const expected = ctx.tracker.documentId;
|
|
263
|
+
if (expected === null)
|
|
264
|
+
throw staleRef(ref, 'no-snapshot');
|
|
265
|
+
const browser = await ctx.handle.get();
|
|
266
|
+
// A modal blocks script execution, and probeRef runs script — without
|
|
267
|
+
// this it would sit on the driver's script timeout before failing.
|
|
268
|
+
const blocking = await peekDialog(browser);
|
|
269
|
+
if (blocking !== null) {
|
|
270
|
+
throw new CraftdriverError(ErrorCode.STATE_INVALID, `cannot resolve ref=${ref} while a dialog is open: ${snippet(blocking, MAX_DIALOG_TEXT)}`, { hint: 'accept or dismiss it first with `dialog accept` / `dialog dismiss`' });
|
|
271
|
+
}
|
|
272
|
+
const probe = await probeRef(browser, ref, expected);
|
|
273
|
+
if (probe.status !== 'ok')
|
|
274
|
+
throw staleRef(ref, probe.status);
|
|
275
|
+
return by;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Give an element keyboard focus without clicking it.
|
|
279
|
+
*
|
|
280
|
+
* Typing into a field should not fire that field's click handler, and a
|
|
281
|
+
* click also drops the caret wherever the pointer happened to land. Focus
|
|
282
|
+
* is both quieter and more predictable for keyboard commands.
|
|
283
|
+
*
|
|
284
|
+
* `focus()` alone leaves the caret at offset 0 in a pre-filled field, so
|
|
285
|
+
* typing would silently *prepend*. `caretToEnd` puts it after the existing
|
|
286
|
+
* text, which is what "append" has to mean to be useful.
|
|
287
|
+
*/
|
|
288
|
+
async function focusElement(el, caretToEnd = false) {
|
|
289
|
+
await el.evaluate((node, toEnd) => {
|
|
290
|
+
const n = node;
|
|
291
|
+
n.focus();
|
|
292
|
+
if (!toEnd)
|
|
293
|
+
return;
|
|
294
|
+
if (typeof n.value !== 'string' || typeof n.setSelectionRange !== 'function')
|
|
295
|
+
return;
|
|
296
|
+
try {
|
|
297
|
+
n.setSelectionRange(n.value.length, n.value.length);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
/* input types like email/number forbid selection; focus is enough */
|
|
301
|
+
}
|
|
302
|
+
}, caretToEnd);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Pick the page a `page select`/`page close` refers to.
|
|
306
|
+
*
|
|
307
|
+
* Accepts an index or an id, and fails closed: acting on the wrong tab is
|
|
308
|
+
* exactly the confusion this command exists to remove, so an out-of-range
|
|
309
|
+
* index or unknown id is an error rather than a fallback to the active page.
|
|
310
|
+
*/
|
|
311
|
+
function selectPage(pages, args, action) {
|
|
312
|
+
const raw = args?.target ?? args?.index ?? args?.id;
|
|
313
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
314
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `page ${action}: missing target`, { hint: 'pass an index from `page list`, or a page id' });
|
|
315
|
+
}
|
|
316
|
+
const value = String(raw).trim();
|
|
317
|
+
if (/^\d+$/.test(value)) {
|
|
318
|
+
const index = Number(value);
|
|
319
|
+
if (index >= pages.length) {
|
|
320
|
+
throw new CraftdriverError(ErrorCode.NO_MATCH, `page ${action}: no page at index ${index} (${pages.length} open)`, { detail: { index, count: pages.length } });
|
|
321
|
+
}
|
|
322
|
+
return pages[index];
|
|
323
|
+
}
|
|
324
|
+
const byId = pages.find((p) => p.id() === value);
|
|
325
|
+
if (!byId) {
|
|
326
|
+
throw new CraftdriverError(ErrorCode.NO_MATCH, `page ${action}: no page with id ${value}`, { detail: { id: value } });
|
|
327
|
+
}
|
|
328
|
+
return byId;
|
|
329
|
+
}
|
|
330
|
+
/** Resolve a selector to its first matching element, or throw NO_MATCH. */
|
|
331
|
+
async function firstElement(ctx, browser, args, cmd, key = 'selector') {
|
|
332
|
+
const by = await selectorOf(ctx, args, key);
|
|
333
|
+
const els = await browser.findAll(by);
|
|
334
|
+
if (els.length === 0) {
|
|
335
|
+
throw new CraftdriverError(ErrorCode.NO_MATCH, `${cmd}: no element matches ${describeSelector(by)}`, { detail: { selector: describeSelector(by) } });
|
|
336
|
+
}
|
|
337
|
+
return { by, el: els[0] };
|
|
338
|
+
}
|
|
67
339
|
export async function dispatch(ctx, cmd, args = {}) {
|
|
68
340
|
switch (cmd) {
|
|
69
341
|
// ---- session ---------------------------------------------------------
|
|
@@ -82,20 +354,9 @@ export async function dispatch(ctx, cmd, args = {}) {
|
|
|
82
354
|
}
|
|
83
355
|
case 'quit': {
|
|
84
356
|
await ctx.handle.close();
|
|
357
|
+
resetBrowserOwnedState(ctx);
|
|
85
358
|
return { ok: true };
|
|
86
359
|
}
|
|
87
|
-
case 'trace-start': {
|
|
88
|
-
const outDir = str(args, 'outDir');
|
|
89
|
-
const b = await ctx.handle.get();
|
|
90
|
-
await b.startTrace({ outDir, title: optStr(args, 'title') });
|
|
91
|
-
return { ok: true, outDir };
|
|
92
|
-
}
|
|
93
|
-
case 'trace-stop': {
|
|
94
|
-
const b = await ctx.handle.get();
|
|
95
|
-
const path = optStr(args, 'path');
|
|
96
|
-
await b.stopTrace(path ? { path } : undefined);
|
|
97
|
-
return { ok: true, path: path ?? null };
|
|
98
|
-
}
|
|
99
360
|
// ---- navigation ------------------------------------------------------
|
|
100
361
|
case 'go': {
|
|
101
362
|
const url = str(args, 'url');
|
|
@@ -124,9 +385,9 @@ export async function dispatch(ctx, cmd, args = {}) {
|
|
|
124
385
|
}
|
|
125
386
|
// ---- find ------------------------------------------------------------
|
|
126
387
|
case 'find': {
|
|
127
|
-
const by =
|
|
388
|
+
const by = await selectorOf(ctx, args);
|
|
128
389
|
const all = bool(args, 'all');
|
|
129
|
-
const limit =
|
|
390
|
+
const limit = limitOf(args, AGENT_DEFAULT_LIMIT);
|
|
130
391
|
const offset = int(args, 'offset', 0);
|
|
131
392
|
const b = await ctx.handle.get();
|
|
132
393
|
const els = await b.findAll(by);
|
|
@@ -166,50 +427,219 @@ export async function dispatch(ctx, cmd, args = {}) {
|
|
|
166
427
|
};
|
|
167
428
|
}
|
|
168
429
|
case 'exists': {
|
|
169
|
-
const by =
|
|
430
|
+
const by = await selectorOf(ctx, args);
|
|
170
431
|
const b = await ctx.handle.get();
|
|
171
432
|
const els = await b.findAll(by);
|
|
172
433
|
return { exists: els.length > 0, count: els.length };
|
|
173
434
|
}
|
|
435
|
+
// ---- locator candidates ----------------------------------------------
|
|
436
|
+
// Durable selectors for an element the agent found by ref. Every
|
|
437
|
+
// candidate is re-resolved against the live document before it is
|
|
438
|
+
// offered, so a test written from one starts out actually matching.
|
|
439
|
+
case 'locators': {
|
|
440
|
+
const by = await selectorOf(ctx, args);
|
|
441
|
+
const b = await ctx.handle.get();
|
|
442
|
+
return locatorCandidates(b, by, limitOf(args, 5));
|
|
443
|
+
}
|
|
174
444
|
// ---- actions ---------------------------------------------------------
|
|
175
445
|
case 'click': {
|
|
176
|
-
const by =
|
|
446
|
+
const by = await selectorOf(ctx, args);
|
|
177
447
|
const b = await ctx.handle.get();
|
|
178
448
|
await b.click(by, { timeout: ms(args) });
|
|
179
449
|
return { ok: true, selector: describeSelector(by) };
|
|
180
450
|
}
|
|
451
|
+
case 'dblclick': {
|
|
452
|
+
const by = await selectorOf(ctx, args);
|
|
453
|
+
const b = await ctx.handle.get();
|
|
454
|
+
await b.mouse.dblclick(by);
|
|
455
|
+
return { ok: true, selector: describeSelector(by) };
|
|
456
|
+
}
|
|
181
457
|
case 'fill': {
|
|
182
|
-
const by =
|
|
458
|
+
const by = await selectorOf(ctx, args);
|
|
183
459
|
const value = str(args, 'value');
|
|
184
460
|
const b = await ctx.handle.get();
|
|
185
461
|
await b.fill(by, value, { timeout: ms(args) });
|
|
186
462
|
return { ok: true, selector: describeSelector(by) };
|
|
187
463
|
}
|
|
464
|
+
case 'clear': {
|
|
465
|
+
const by = await selectorOf(ctx, args);
|
|
466
|
+
const b = await ctx.handle.get();
|
|
467
|
+
await b.clear(by, { timeout: ms(args) });
|
|
468
|
+
return { ok: true, selector: describeSelector(by) };
|
|
469
|
+
}
|
|
470
|
+
case 'type': {
|
|
471
|
+
// Types into whatever currently holds focus — no selector, matching
|
|
472
|
+
// `playwright-cli type <text>` and mapping 1:1 onto the library's
|
|
473
|
+
// `browser.keyboard.type()`.
|
|
474
|
+
//
|
|
475
|
+
// Use `fill` to put a value in a specific field. Reach for `focus`
|
|
476
|
+
// (or `click`) + `type` only when you need to append to existing
|
|
477
|
+
// text or drive a field's key handlers directly.
|
|
478
|
+
const text = str(args, 'text');
|
|
479
|
+
const b = await ctx.handle.get();
|
|
480
|
+
await b.keyboard.type(text);
|
|
481
|
+
return { ok: true, length: text.length };
|
|
482
|
+
}
|
|
188
483
|
case 'press': {
|
|
189
484
|
const key = str(args, 'key');
|
|
190
485
|
const b = await ctx.handle.get();
|
|
191
486
|
const sel = optStr(args, 'selector');
|
|
192
487
|
if (sel) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
await els[0].click();
|
|
488
|
+
// Focus, don't click: `press Enter #submit` must not also fire the
|
|
489
|
+
// button's click handler and submit twice.
|
|
490
|
+
const { el } = await firstElement(ctx, b, args, 'press');
|
|
491
|
+
await focusElement(el);
|
|
199
492
|
}
|
|
200
493
|
await b.keyboard.press(key);
|
|
201
494
|
return { ok: true, key };
|
|
202
495
|
}
|
|
203
496
|
case 'hover': {
|
|
204
|
-
const by = parseSelector(str(args, 'selector'));
|
|
205
497
|
const b = await ctx.handle.get();
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
498
|
+
const { by, el } = await firstElement(ctx, b, args, 'hover');
|
|
499
|
+
await el.hover({ timeout: ms(args) });
|
|
500
|
+
return { ok: true, selector: describeSelector(by) };
|
|
501
|
+
}
|
|
502
|
+
case 'check':
|
|
503
|
+
case 'uncheck': {
|
|
504
|
+
const want = cmd === 'check';
|
|
505
|
+
const b = await ctx.handle.get();
|
|
506
|
+
const { by, el } = await firstElement(ctx, b, args, cmd);
|
|
507
|
+
// Idempotent by design: agents retry, and a blind click would toggle
|
|
508
|
+
// the box back off. But the read must not be swallowed — treating an
|
|
509
|
+
// unreadable element as "unchecked" made `uncheck` skip the click and
|
|
510
|
+
// then report success for an element it never touched.
|
|
511
|
+
const already = await el.isChecked();
|
|
512
|
+
if (already !== want)
|
|
513
|
+
await el.click();
|
|
514
|
+
// Report what the page says, not what was asked for: a click eaten by
|
|
515
|
+
// an overlay or a disabled control must not come back as success.
|
|
516
|
+
const checked = await el.isChecked();
|
|
517
|
+
if (checked !== want) {
|
|
518
|
+
throw new CraftdriverError(ErrorCode.EXPECT_MISMATCH, `${cmd}: ${describeSelector(by)} is still ${checked ? 'checked' : 'unchecked'}`, {
|
|
519
|
+
detail: { selector: describeSelector(by), expected: want, actual: checked },
|
|
520
|
+
hint: 'the click may have been intercepted, or the control is disabled',
|
|
521
|
+
});
|
|
209
522
|
}
|
|
210
|
-
|
|
523
|
+
return { ok: true, selector: describeSelector(by), checked };
|
|
524
|
+
}
|
|
525
|
+
case 'select': {
|
|
526
|
+
const value = str(args, 'value');
|
|
527
|
+
const b = await ctx.handle.get();
|
|
528
|
+
const { by, el } = await firstElement(ctx, b, args, 'select');
|
|
529
|
+
await el.select(value);
|
|
530
|
+
return { ok: true, selector: describeSelector(by), value };
|
|
531
|
+
}
|
|
532
|
+
case 'focus': {
|
|
533
|
+
const b = await ctx.handle.get();
|
|
534
|
+
const { by, el } = await firstElement(ctx, b, args, cmd);
|
|
535
|
+
// Caret to the end of any existing text. `focus` exists so you can
|
|
536
|
+
// then `type`, and a bare focus() leaves the caret at offset 0 —
|
|
537
|
+
// which would silently prepend. Use `clear` to overwrite instead.
|
|
538
|
+
await focusElement(el, true);
|
|
211
539
|
return { ok: true, selector: describeSelector(by) };
|
|
212
540
|
}
|
|
541
|
+
case 'scroll': {
|
|
542
|
+
const b = await ctx.handle.get();
|
|
543
|
+
const { by, el } = await firstElement(ctx, b, args, cmd);
|
|
544
|
+
// One-line DOM call with no public primitive; run it against the
|
|
545
|
+
// resolved handle rather than re-querying in the page.
|
|
546
|
+
await el.evaluate((node) => {
|
|
547
|
+
node
|
|
548
|
+
.scrollIntoView({ block: 'center', inline: 'center' });
|
|
549
|
+
});
|
|
550
|
+
return { ok: true, selector: describeSelector(by) };
|
|
551
|
+
}
|
|
552
|
+
case 'upload': {
|
|
553
|
+
const files = fileList(args);
|
|
554
|
+
const b = await ctx.handle.get();
|
|
555
|
+
const { by, el } = await firstElement(ctx, b, args, 'upload');
|
|
556
|
+
await el.setInputFiles(files);
|
|
557
|
+
// Count, not paths. File contents never enter agent-visible output, and
|
|
558
|
+
// neither do absolute local paths — the MCP tool promises exactly that,
|
|
559
|
+
// and echoing them back leaked the caller's directory layout into a
|
|
560
|
+
// transcript for no benefit: the caller already knows what it sent.
|
|
561
|
+
return { ok: true, selector: describeSelector(by), count: files.length };
|
|
562
|
+
}
|
|
563
|
+
// ---- keyboard / mouse -------------------------------------------------
|
|
564
|
+
case 'key': {
|
|
565
|
+
const action = optStr(args, 'action') ?? 'press';
|
|
566
|
+
const b = await ctx.handle.get();
|
|
567
|
+
// An optional selector focuses a field first. Prefer the `type`
|
|
568
|
+
// command for text; this exists for keys aimed at a specific element.
|
|
569
|
+
if (optStr(args, 'selector')) {
|
|
570
|
+
const { el } = await firstElement(ctx, b, args, 'key');
|
|
571
|
+
await focusElement(el);
|
|
572
|
+
}
|
|
573
|
+
// Validate the action before reading `key`, so an unusable action
|
|
574
|
+
// reports itself rather than a missing argument the caller never
|
|
575
|
+
// knew about.
|
|
576
|
+
if (action !== 'press' && action !== 'down' && action !== 'up') {
|
|
577
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `key: unknown action "${action}". Expected: press | down | up`, { hint: 'to type text, use `type <text>` (types into the focused element)' });
|
|
578
|
+
}
|
|
579
|
+
const key = str(args, 'key');
|
|
580
|
+
if (action === 'press')
|
|
581
|
+
await b.keyboard.press(key);
|
|
582
|
+
else if (action === 'down')
|
|
583
|
+
await b.keyboard.down(key);
|
|
584
|
+
else
|
|
585
|
+
await b.keyboard.up(key);
|
|
586
|
+
return { ok: true, action, key };
|
|
587
|
+
}
|
|
588
|
+
case 'mouse': {
|
|
589
|
+
const action = optStr(args, 'action') ?? 'move';
|
|
590
|
+
const b = await ctx.handle.get();
|
|
591
|
+
const button = (optStr(args, 'button') ?? 'left');
|
|
592
|
+
if (button !== 'left' && button !== 'middle' && button !== 'right') {
|
|
593
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `mouse: unknown button "${button}". Expected: left | middle | right`);
|
|
594
|
+
}
|
|
595
|
+
// A target is either a selector or an x/y point.
|
|
596
|
+
const sel = optStr(args, 'selector');
|
|
597
|
+
const target = sel
|
|
598
|
+
? await resolveSelector(ctx, sel)
|
|
599
|
+
: { x: int(args, 'x', 0), y: int(args, 'y', 0) };
|
|
600
|
+
switch (action) {
|
|
601
|
+
case 'move':
|
|
602
|
+
await b.mouse.move(target);
|
|
603
|
+
return { ok: true, action };
|
|
604
|
+
case 'down':
|
|
605
|
+
await b.mouse.down(button);
|
|
606
|
+
return { ok: true, action, button };
|
|
607
|
+
case 'up':
|
|
608
|
+
await b.mouse.up(button);
|
|
609
|
+
return { ok: true, action, button };
|
|
610
|
+
case 'click':
|
|
611
|
+
await b.mouse.click(target, { button });
|
|
612
|
+
return { ok: true, action, button };
|
|
613
|
+
case 'wheel':
|
|
614
|
+
await b.mouse.wheel(int(args, 'deltaX', 0), int(args, 'deltaY', 0), sel ? target : undefined);
|
|
615
|
+
return { ok: true, action };
|
|
616
|
+
default:
|
|
617
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `mouse: unknown action "${action}". Expected: move | down | up | click | wheel`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
// ---- dialogs ---------------------------------------------------------
|
|
621
|
+
case 'dialog': {
|
|
622
|
+
const action = optStr(args, 'action') ?? 'inspect';
|
|
623
|
+
const b = await ctx.handle.get();
|
|
624
|
+
if (action === 'inspect') {
|
|
625
|
+
// The driver throws "no such alert" rather than returning empty,
|
|
626
|
+
// so inspecting a page with no dialog must not surface an error.
|
|
627
|
+
const message = await peekDialog(b);
|
|
628
|
+
return {
|
|
629
|
+
open: message !== null,
|
|
630
|
+
message: message === null ? '' : snippet(message, MAX_DIALOG_TEXT),
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
if (action === 'accept') {
|
|
634
|
+
await b.acceptDialog(optStr(args, 'text'));
|
|
635
|
+
return { ok: true, action };
|
|
636
|
+
}
|
|
637
|
+
if (action === 'dismiss') {
|
|
638
|
+
await b.dismissDialog();
|
|
639
|
+
return { ok: true, action };
|
|
640
|
+
}
|
|
641
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `dialog: unknown action "${action}". Expected: inspect | accept | dismiss`);
|
|
642
|
+
}
|
|
213
643
|
// ---- read ------------------------------------------------------------
|
|
214
644
|
case 'text': {
|
|
215
645
|
const sel = optStr(args, 'selector');
|
|
@@ -217,32 +647,43 @@ export async function dispatch(ctx, cmd, args = {}) {
|
|
|
217
647
|
if (!sel) {
|
|
218
648
|
const page = await b.activePage();
|
|
219
649
|
const body = await page.evaluate('return document.body ? document.body.innerText : "";');
|
|
220
|
-
|
|
650
|
+
// A character budget, not a row count — clamping it to the list cap
|
|
651
|
+
// silently halved the documented 2000-character default.
|
|
652
|
+
const limit = clampLimit(int(args, 'limit', 2000), 2000, MAX_TEXT_CHARS);
|
|
221
653
|
const text = String(body);
|
|
222
|
-
|
|
654
|
+
// The requested character budget does not supersede the global byte
|
|
655
|
+
// ceiling. Without the second bound, `text --limit 100000` produced a
|
|
656
|
+
// response too large for the daemon and the useful result was dropped.
|
|
657
|
+
const bounded = boundString(text, { maxChars: limit });
|
|
658
|
+
return { text: bounded.value, truncated: bounded.truncated, total: bounded.total };
|
|
223
659
|
}
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
return { text:
|
|
660
|
+
const { el } = await firstElement(ctx, b, args, 'text');
|
|
661
|
+
// The selector-less branch above pages via `--limit`; a single element's
|
|
662
|
+
// text has no such bound and can be the whole document body. Same shape
|
|
663
|
+
// as that branch, so `.text` is a string either way.
|
|
664
|
+
const bounded = boundString(await el.text());
|
|
665
|
+
return { text: bounded.value, truncated: bounded.truncated, total: bounded.total };
|
|
230
666
|
}
|
|
231
667
|
case 'attr': {
|
|
232
|
-
const by =
|
|
668
|
+
const by = await selectorOf(ctx, args);
|
|
233
669
|
const name = str(args, 'name');
|
|
234
670
|
const b = await ctx.handle.get();
|
|
235
671
|
const value = await b.getAttribute(by, name, { timeout: ms(args) });
|
|
236
|
-
|
|
672
|
+
// A `src` data URI or a serialized state blob in a data- attribute is
|
|
673
|
+
// routinely larger than everything else the agent has read all session.
|
|
674
|
+
// An absent attribute stays null rather than becoming an empty string.
|
|
675
|
+
const bounded = boundString(value);
|
|
676
|
+
return { name, value: bounded.value, truncated: bounded.truncated, total: bounded.total };
|
|
237
677
|
}
|
|
238
678
|
case 'value': {
|
|
239
|
-
const by =
|
|
679
|
+
const by = await selectorOf(ctx, args);
|
|
240
680
|
const b = await ctx.handle.get();
|
|
241
|
-
|
|
681
|
+
const bounded = boundString(await b.getValue(by, { timeout: ms(args) }));
|
|
682
|
+
return { value: bounded.value, truncated: bounded.truncated, total: bounded.total };
|
|
242
683
|
}
|
|
243
684
|
case 'is': {
|
|
244
685
|
const what = str(args, 'what');
|
|
245
|
-
const by =
|
|
686
|
+
const by = await selectorOf(ctx, args);
|
|
246
687
|
const b = await ctx.handle.get();
|
|
247
688
|
const els = await b.findAll(by);
|
|
248
689
|
if (els.length === 0) {
|
|
@@ -266,7 +707,7 @@ export async function dispatch(ctx, cmd, args = {}) {
|
|
|
266
707
|
const b = await ctx.handle.get();
|
|
267
708
|
if (kind === 'selector') {
|
|
268
709
|
const state = (optStr(args, 'state') ?? 'visible');
|
|
269
|
-
await b.waitFor(
|
|
710
|
+
await b.waitFor(await resolveSelector(ctx, target), { state, timeout: ms(args) });
|
|
270
711
|
return { ok: true, state };
|
|
271
712
|
}
|
|
272
713
|
if (kind === 'load') {
|
|
@@ -287,18 +728,351 @@ export async function dispatch(ctx, cmd, args = {}) {
|
|
|
287
728
|
})));
|
|
288
729
|
return { pages: out, count: out.length };
|
|
289
730
|
}
|
|
731
|
+
// ---- pages / tabs ----------------------------------------------------
|
|
732
|
+
// Selecting or closing a page changes which document later commands act
|
|
733
|
+
// on, so the ref registry and snapshot baseline must not survive it —
|
|
734
|
+
// otherwise a ref issued against the old tab could still resolve.
|
|
735
|
+
case 'page': {
|
|
736
|
+
const action = optStr(args, 'action') ?? 'list';
|
|
737
|
+
const b = await ctx.handle.get();
|
|
738
|
+
const pages = await b.pages();
|
|
739
|
+
if (action === 'list') {
|
|
740
|
+
const out = await Promise.all(pages.map(async (p, i) => ({
|
|
741
|
+
index: i,
|
|
742
|
+
id: p.id(),
|
|
743
|
+
url: await p.url().catch(() => ''),
|
|
744
|
+
title: await p.title().catch(() => ''),
|
|
745
|
+
})));
|
|
746
|
+
const activeId = await b.activePage().then((p) => p.id()).catch(() => null);
|
|
747
|
+
return { pages: out, count: out.length, active: activeId };
|
|
748
|
+
}
|
|
749
|
+
if (action === 'open') {
|
|
750
|
+
const url = optStr(args, 'url');
|
|
751
|
+
const page = await b.openPage(url ? { url } : undefined);
|
|
752
|
+
await page.activate();
|
|
753
|
+
ctx.tracker.reset();
|
|
754
|
+
return { ok: true, action, id: page.id(), url: await page.url().catch(() => '') };
|
|
755
|
+
}
|
|
756
|
+
if (action === 'select' || action === 'close') {
|
|
757
|
+
const target = selectPage(pages, args, action);
|
|
758
|
+
if (action === 'select') {
|
|
759
|
+
await target.activate();
|
|
760
|
+
ctx.tracker.reset();
|
|
761
|
+
return { ok: true, action, id: target.id(), url: await target.url().catch(() => '') };
|
|
762
|
+
}
|
|
763
|
+
const id = target.id();
|
|
764
|
+
const wasLast = pages.length === 1;
|
|
765
|
+
await target.close();
|
|
766
|
+
ctx.tracker.reset();
|
|
767
|
+
// Closing the final top-level window ends the WebDriver session, so
|
|
768
|
+
// asking it for pages afterwards fails with an opaque driver error.
|
|
769
|
+
if (wasLast) {
|
|
770
|
+
await ctx.handle.close();
|
|
771
|
+
return { ok: true, action, id, remaining: 0 };
|
|
772
|
+
}
|
|
773
|
+
const left = await b.pages();
|
|
774
|
+
return { ok: true, action, id, remaining: left.length };
|
|
775
|
+
}
|
|
776
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `page: unknown action "${action}". Expected: list | open | select | close`);
|
|
777
|
+
}
|
|
778
|
+
// ---- console / network journal ---------------------------------------
|
|
779
|
+
// Query what the page reported, rather than re-running the flow with a
|
|
780
|
+
// listener attached. Capture is already live by the time any command
|
|
781
|
+
// runs, so "what did that click log?" is answerable after the fact.
|
|
782
|
+
case 'logs': {
|
|
783
|
+
const journal = ctx.journal;
|
|
784
|
+
if (!journal) {
|
|
785
|
+
throw new CraftdriverError(ErrorCode.STATE_INVALID, 'logs: no journal on this session');
|
|
786
|
+
}
|
|
787
|
+
const action = optStr(args, 'action') ?? 'list';
|
|
788
|
+
// Validate before any effect: a typo'd --kind must not cost a browser
|
|
789
|
+
// launch before it is rejected.
|
|
790
|
+
const kinds = parseKinds(args);
|
|
791
|
+
if (action !== 'list' && action !== 'wait' && action !== 'clear') {
|
|
792
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `logs: unknown action "${action}". Expected: list | wait | clear`);
|
|
793
|
+
}
|
|
794
|
+
// Emptying an in-memory buffer needs no browser.
|
|
795
|
+
if (action === 'clear') {
|
|
796
|
+
journal.clear();
|
|
797
|
+
return { ok: true, action, capturing: journal.isCapturing };
|
|
798
|
+
}
|
|
799
|
+
// Launch on demand: asking for logs before anything else should start
|
|
800
|
+
// the browser and begin capture, not report an empty history.
|
|
801
|
+
await ctx.handle.get();
|
|
802
|
+
const query = {
|
|
803
|
+
since: int(args, 'since', 0),
|
|
804
|
+
...(kinds ? { kinds } : {}),
|
|
805
|
+
...(optStr(args, 'level') ? { level: optStr(args, 'level') } : {}),
|
|
806
|
+
...(optStr(args, 'contains') ? { contains: optStr(args, 'contains') } : {}),
|
|
807
|
+
limit: limitOf(args, AGENT_DEFAULT_LIMIT),
|
|
808
|
+
};
|
|
809
|
+
if (action === 'wait') {
|
|
810
|
+
const found = await journal.waitFor(query, ms(args));
|
|
811
|
+
if (!found) {
|
|
812
|
+
throw new CraftdriverError(ErrorCode.TIMEOUT, `logs wait: nothing matched within ${ms(args)}ms`, {
|
|
813
|
+
detail: { query },
|
|
814
|
+
hint: 'widen --contains or --kind, or check `logs list` for what was captured',
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
return { ok: true, action, entry: found, cursor: journal.query().cursor };
|
|
818
|
+
}
|
|
819
|
+
const page = journal.query(query);
|
|
820
|
+
// Report capture being off rather than letting an empty list read as
|
|
821
|
+
// "the page logged nothing" — those are very different answers.
|
|
822
|
+
const note = journal.isCapturing
|
|
823
|
+
? undefined
|
|
824
|
+
: 'capture is off: console and network history require BiDi';
|
|
825
|
+
return { ...page, capturing: journal.isCapturing, ...(note ? { note } : {}) };
|
|
826
|
+
}
|
|
827
|
+
// ---- network mocking -------------------------------------------------
|
|
828
|
+
// Only what the public library already supports as a *flat* rule: a fixed
|
|
829
|
+
// response, or a block. `network.intercept()` takes a handler function,
|
|
830
|
+
// which a command line cannot express — building a rule language to close
|
|
831
|
+
// that gap is explicitly out of scope, so it is simply not offered.
|
|
832
|
+
//
|
|
833
|
+
// Active mocks are tracked here because the library keeps its intercept
|
|
834
|
+
// map private; without that, `mock list` and `mock clear` could not exist
|
|
835
|
+
// and the cap would be unenforceable.
|
|
836
|
+
case 'mock': {
|
|
837
|
+
const action = optStr(args, 'action') ?? 'list';
|
|
838
|
+
const mocks = (ctx.mocks ??= []);
|
|
839
|
+
if (action === 'list') {
|
|
840
|
+
return { mocks: mocks.map(({ id, pattern, kind, status }) => ({ id, pattern, kind, status })), count: mocks.length };
|
|
841
|
+
}
|
|
842
|
+
// Everything below is validated before the browser is touched. A mock
|
|
843
|
+
// installed with a bad status is worse than one refused — the failure
|
|
844
|
+
// then shows up as an unexplained page error much later — and rejecting
|
|
845
|
+
// it should not cost a launch either.
|
|
846
|
+
if (action !== 'add' && action !== 'block' && action !== 'remove' && action !== 'clear') {
|
|
847
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `mock: unknown action "${action}". Expected: add | block | list | remove | clear`);
|
|
848
|
+
}
|
|
849
|
+
if (action === 'remove' && mocks.findIndex((m) => m.id === str(args, 'id')) === -1) {
|
|
850
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `mock: unknown id ${JSON.stringify(str(args, 'id'))}`, {
|
|
851
|
+
detail: { open: mocks.map((m) => m.id) },
|
|
852
|
+
hint: 'run `mock list` to see active mocks',
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
// Nothing was installed, so there is nothing to remove and no reason to
|
|
856
|
+
// start a browser to discover that.
|
|
857
|
+
if (action === 'clear' && mocks.length === 0) {
|
|
858
|
+
return { ok: true, action, cleared: 0 };
|
|
859
|
+
}
|
|
860
|
+
let pattern = '';
|
|
861
|
+
let status = 200;
|
|
862
|
+
let body = '';
|
|
863
|
+
if (action === 'add' || action === 'block') {
|
|
864
|
+
if (mocks.length >= MAX_MOCKS) {
|
|
865
|
+
throw new CraftdriverError(ErrorCode.STATE_INVALID, `mock: limit reached (${MAX_MOCKS} active)`, { hint: 'remove one with `mock remove <id>`, or `mock clear`' });
|
|
866
|
+
}
|
|
867
|
+
pattern = str(args, 'pattern');
|
|
868
|
+
if (pattern.length > MAX_MOCK_PATTERN) {
|
|
869
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `mock: pattern is too long (${pattern.length} chars; max ${MAX_MOCK_PATTERN})`);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (action === 'add') {
|
|
873
|
+
status = int(args, 'status', 200);
|
|
874
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
|
875
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `mock: status ${status} is not a valid HTTP status (100-599)`);
|
|
876
|
+
}
|
|
877
|
+
body = optStr(args, 'body') ?? '';
|
|
878
|
+
if (body.length > MAX_MOCK_BODY) {
|
|
879
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `mock: body is too large (${body.length} bytes; max ${MAX_MOCK_BODY})`, { hint: 'mock a small fixture; craftdriver is not a fixture server' });
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const b = await ctx.handle.get();
|
|
883
|
+
let network;
|
|
884
|
+
try {
|
|
885
|
+
network = b.network;
|
|
886
|
+
}
|
|
887
|
+
catch {
|
|
888
|
+
throw new CraftdriverError(ErrorCode.UNSUPPORTED, 'mock: network interception requires BiDi', { hint: 'BiDi negotiation failed at launch; Chrome and Firefox negotiate it by default' });
|
|
889
|
+
}
|
|
890
|
+
if (action === 'clear') {
|
|
891
|
+
await network.removeAllIntercepts();
|
|
892
|
+
const cleared = mocks.length;
|
|
893
|
+
ctx.mocks = [];
|
|
894
|
+
return { ok: true, action, cleared };
|
|
895
|
+
}
|
|
896
|
+
if (action === 'remove') {
|
|
897
|
+
const id = str(args, 'id');
|
|
898
|
+
const index = mocks.findIndex((m) => m.id === id);
|
|
899
|
+
await network.removeIntercept(id);
|
|
900
|
+
mocks.splice(index, 1);
|
|
901
|
+
return { ok: true, action, id, remaining: mocks.length };
|
|
902
|
+
}
|
|
903
|
+
if (action === 'block') {
|
|
904
|
+
const id = await network.block(pattern);
|
|
905
|
+
mocks.push({ id, pattern, kind: 'block' });
|
|
906
|
+
return { ok: true, action, id, pattern };
|
|
907
|
+
}
|
|
908
|
+
const contentType = optStr(args, 'contentType');
|
|
909
|
+
const id = await network.mock(pattern, {
|
|
910
|
+
status,
|
|
911
|
+
body,
|
|
912
|
+
...(contentType ? { headers: { 'content-type': contentType } } : {}),
|
|
913
|
+
});
|
|
914
|
+
mocks.push({ id, pattern, kind: 'mock', status });
|
|
915
|
+
return { ok: true, action, id, pattern, status };
|
|
916
|
+
}
|
|
917
|
+
// ---- traces ----------------------------------------------------------
|
|
918
|
+
// Thin wrapper over the existing tracer: this owns where a trace lands and
|
|
919
|
+
// what is reported, never how one is recorded. `browser.quit()` already
|
|
920
|
+
// aborts a running trace, so no cleanup is duplicated here.
|
|
921
|
+
case 'trace': {
|
|
922
|
+
const action = optStr(args, 'action') ?? 'status';
|
|
923
|
+
if (action === 'status') {
|
|
924
|
+
const active = ctx.activeTrace ?? null;
|
|
925
|
+
return active
|
|
926
|
+
? { running: true, ...active }
|
|
927
|
+
: { running: false, root: traceRoot() };
|
|
928
|
+
}
|
|
929
|
+
// Answered without a browser: whether this session started a trace is
|
|
930
|
+
// bookkeeping, and launching one just to report "nothing is recording"
|
|
931
|
+
// both wastes a launch and reports the failure as a driver error.
|
|
932
|
+
if (action === 'stop' && !ctx.activeTrace) {
|
|
933
|
+
throw new CraftdriverError(ErrorCode.STATE_INVALID, 'trace: nothing is recording', {
|
|
934
|
+
hint: 'start one first with `trace start [name]`',
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
if (action !== 'start' && action !== 'stop') {
|
|
938
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `trace: unknown action "${action}". Expected: start | stop | status`);
|
|
939
|
+
}
|
|
940
|
+
// Resolve and validate the destination before launching: a name that
|
|
941
|
+
// cannot be used should cost a usage error, not a browser.
|
|
942
|
+
let name = '';
|
|
943
|
+
let outDir = '';
|
|
944
|
+
if (action === 'start') {
|
|
945
|
+
if (ctx.activeTrace) {
|
|
946
|
+
throw new CraftdriverError(ErrorCode.STATE_INVALID, `trace: "${ctx.activeTrace.name}" is already recording`, {
|
|
947
|
+
detail: { ...ctx.activeTrace },
|
|
948
|
+
hint: 'stop it first with `trace stop`',
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
name = optStr(args, 'name') ?? 'trace';
|
|
952
|
+
outDir = await resolveArtifactPath({ root: traceRoot(), name, kind: 'trace' });
|
|
953
|
+
}
|
|
954
|
+
const b = await ctx.handle.get();
|
|
955
|
+
if (action === 'start') {
|
|
956
|
+
// Screenshots are on by default in the library. They are the bulk of a
|
|
957
|
+
// trace's size, so allow turning them off without inventing options
|
|
958
|
+
// the tracer does not already have.
|
|
959
|
+
await b.startTrace({
|
|
960
|
+
outDir,
|
|
961
|
+
...(bool(args, 'noScreenshots') ? { screenshots: false } : {}),
|
|
962
|
+
});
|
|
963
|
+
ctx.activeTrace = { name, outDir, startedAt: new Date().toISOString() };
|
|
964
|
+
return { ok: true, action, ...ctx.activeTrace };
|
|
965
|
+
}
|
|
966
|
+
if (action === 'stop') {
|
|
967
|
+
// Non-null: the no-trace case is rejected above, before any launch.
|
|
968
|
+
const active = ctx.activeTrace;
|
|
969
|
+
// Export a shareable archive only when asked: writing one doubles the
|
|
970
|
+
// artifact for a caller who just wants to look at the NDJSON.
|
|
971
|
+
const zip = bool(args, 'zip')
|
|
972
|
+
? await resolveArtifactPath({
|
|
973
|
+
root: traceRoot(),
|
|
974
|
+
name: active.name,
|
|
975
|
+
suffix: '.zip',
|
|
976
|
+
kind: 'trace',
|
|
977
|
+
})
|
|
978
|
+
: undefined;
|
|
979
|
+
try {
|
|
980
|
+
await b.stopTrace(zip ? { path: zip } : undefined);
|
|
981
|
+
}
|
|
982
|
+
finally {
|
|
983
|
+
// Clear regardless: a failed export must not leave the session
|
|
984
|
+
// believing a trace is still recording when the tracer has stopped.
|
|
985
|
+
ctx.activeTrace = null;
|
|
986
|
+
}
|
|
987
|
+
return {
|
|
988
|
+
ok: true,
|
|
989
|
+
action,
|
|
990
|
+
name: active.name,
|
|
991
|
+
outDir: active.outDir,
|
|
992
|
+
trace: `${active.outDir}/trace.ndjson`,
|
|
993
|
+
...(zip ? { zip } : {}),
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
// Unreachable: the action was validated before the launch.
|
|
997
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `trace: unhandled action "${action}"`);
|
|
998
|
+
}
|
|
999
|
+
// ---- authentication / storage state ----------------------------------
|
|
1000
|
+
// `stateStore` owns where a state file may live and who may read it; this
|
|
1001
|
+
// case owns *when* saving or restoring one is meaningful.
|
|
1002
|
+
//
|
|
1003
|
+
// Restoration policy lives in the library so CLI, launch, and direct API
|
|
1004
|
+
// calls cannot drift. BiDi can restore cookies + localStorage before the
|
|
1005
|
+
// first public navigation; Classic and sessionStorage use the strict
|
|
1006
|
+
// active-origin path and fail before mutation when the page is unsuitable.
|
|
1007
|
+
case 'state': {
|
|
1008
|
+
const action = optStr(args, 'action') ?? 'list';
|
|
1009
|
+
if (action === 'list') {
|
|
1010
|
+
return { states: await listStateNames(), root: stateRoot() };
|
|
1011
|
+
}
|
|
1012
|
+
if (action !== 'save' && action !== 'load') {
|
|
1013
|
+
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `state: unknown action "${action}". Expected: save | load | list`);
|
|
1014
|
+
}
|
|
1015
|
+
const name = optStr(args, 'name');
|
|
1016
|
+
const target = await resolveStatePath(name);
|
|
1017
|
+
const b = await ctx.handle.get();
|
|
1018
|
+
// Both directions run page script to reach localStorage, so both sit on
|
|
1019
|
+
// the driver's script timeout behind a modal. A login flow is a likely
|
|
1020
|
+
// place to have left one open.
|
|
1021
|
+
const blocking = await peekDialog(b);
|
|
1022
|
+
if (blocking !== null) {
|
|
1023
|
+
throw new CraftdriverError(ErrorCode.STATE_INVALID, `cannot ${action} state while a dialog is open: ${snippet(blocking, MAX_DIALOG_TEXT)}`, { hint: 'accept or dismiss it first with `dialog accept` / `dialog dismiss`' });
|
|
1024
|
+
}
|
|
1025
|
+
const origin = await activeOrigin(b);
|
|
1026
|
+
if (action === 'save') {
|
|
1027
|
+
const includeSessionStorage = bool(args, 'sessionStorage');
|
|
1028
|
+
const tmp = await prepareTempStatePath(target);
|
|
1029
|
+
let state;
|
|
1030
|
+
try {
|
|
1031
|
+
state = await b.saveState(tmp, { includeSessionStorage });
|
|
1032
|
+
await commitStateFile(tmp, target);
|
|
1033
|
+
}
|
|
1034
|
+
catch (err) {
|
|
1035
|
+
await discardTempStateFile(tmp);
|
|
1036
|
+
throw err;
|
|
1037
|
+
}
|
|
1038
|
+
const summary = summarizeState(state);
|
|
1039
|
+
// Saving from a page with no real origin captures cookies only. That
|
|
1040
|
+
// is legitimate, but it is also how an agent ends up with a state file
|
|
1041
|
+
// that silently restores less than it expected, so say it here.
|
|
1042
|
+
const note = origin === null && summary.storageKeys === 0
|
|
1043
|
+
? 'no page origin: cookies were saved, local/session storage was not'
|
|
1044
|
+
: undefined;
|
|
1045
|
+
return { ok: true, action, name, root: stateRoot(), origin, ...summary, ...(note ? { note } : {}) };
|
|
1046
|
+
}
|
|
1047
|
+
const state = await readStateFile(target, name);
|
|
1048
|
+
await b.loadState(target);
|
|
1049
|
+
// Cookies and storage just changed underneath the page: anything the
|
|
1050
|
+
// agent holds a ref to was resolved against the pre-restore document.
|
|
1051
|
+
ctx.tracker.reset();
|
|
1052
|
+
return { ok: true, action, name, root: stateRoot(), origin, ...summarizeState(state) };
|
|
1053
|
+
}
|
|
290
1054
|
case 'screenshot': {
|
|
291
1055
|
const b = await ctx.handle.get();
|
|
292
1056
|
const fullPage = bool(args, 'fullPage');
|
|
293
1057
|
const sel = optStr(args, 'selector');
|
|
294
|
-
|
|
1058
|
+
// Keep one implicit screenshot per named session. Timestamped defaults
|
|
1059
|
+
// made an exploratory loop grow `.craftdriver/screenshots` forever;
|
|
1060
|
+
// overwriting this tool-owned "latest" file is bounded and still leaves
|
|
1061
|
+
// explicit `-o` available when the caller wants history.
|
|
1062
|
+
const path = optStr(args, 'path')
|
|
1063
|
+
?? await resolveArtifactPath({
|
|
1064
|
+
root: artifactRoot('screenshots', 'CRAFTDRIVER_SCREENSHOT_DIR'),
|
|
1065
|
+
name: `screenshot-${ctx.artifactName ?? 'default'}`,
|
|
1066
|
+
suffix: '.png',
|
|
1067
|
+
kind: 'screenshot',
|
|
1068
|
+
});
|
|
295
1069
|
const buf = await b.screenshot({
|
|
296
|
-
|
|
297
|
-
...(sel ? { selector:
|
|
1070
|
+
path,
|
|
1071
|
+
...(sel ? { selector: await resolveSelector(ctx, sel) } : {}),
|
|
298
1072
|
...(fullPage ? { fullPage: true } : {}),
|
|
299
1073
|
timeout: ms(args),
|
|
300
1074
|
});
|
|
301
|
-
return { ok: true, path
|
|
1075
|
+
return { ok: true, path, bytes: buf.length };
|
|
302
1076
|
}
|
|
303
1077
|
// ---- snapshot --------------------------------------------------------
|
|
304
1078
|
// Sanitized accessibility-tree summary of the active page. Each
|
|
@@ -307,11 +1081,31 @@ export async function dispatch(ctx, cmd, args = {}) {
|
|
|
307
1081
|
// invalidate on the next snapshot or on navigation.
|
|
308
1082
|
case 'snapshot': {
|
|
309
1083
|
const b = await ctx.handle.get();
|
|
310
|
-
|
|
1084
|
+
// Same modal hazard as the automatic snapshot: report the dialog
|
|
1085
|
+
// rather than blocking on the script timeout and returning nothing.
|
|
1086
|
+
const blocking = await peekDialog(b);
|
|
1087
|
+
if (blocking !== null) {
|
|
1088
|
+
return {
|
|
1089
|
+
url: '',
|
|
1090
|
+
title: '',
|
|
1091
|
+
lines: [],
|
|
1092
|
+
note: `dialog open: ${snippet(blocking, MAX_DIALOG_TEXT)} — accept or dismiss it first`,
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
// Recording here is what keeps explicit and automatic snapshots on
|
|
1096
|
+
// one baseline: whatever the agent is shown becomes what the next
|
|
1097
|
+
// post-action diff is measured against.
|
|
1098
|
+
const snap = ctx.tracker.record(await takeSnapshot(b, ctx.tracker.minRef));
|
|
311
1099
|
if (!snap) {
|
|
312
1100
|
return { url: '', title: '', lines: [], note: 'no snapshot available' };
|
|
313
1101
|
}
|
|
314
|
-
return {
|
|
1102
|
+
return {
|
|
1103
|
+
url: snap.url,
|
|
1104
|
+
title: snap.title,
|
|
1105
|
+
lines: snap.lines,
|
|
1106
|
+
documentId: snap.documentId,
|
|
1107
|
+
revision: snap.revision,
|
|
1108
|
+
};
|
|
315
1109
|
}
|
|
316
1110
|
// ---- eval (advanced) -------------------------------------------------
|
|
317
1111
|
case 'eval': {
|
|
@@ -322,7 +1116,10 @@ export async function dispatch(ctx, cmd, args = {}) {
|
|
|
322
1116
|
// ("document.title") or a statement list ("return foo + 1;").
|
|
323
1117
|
const body = /\breturn\b/.test(code) ? code : `return (${code});`;
|
|
324
1118
|
const result = await page.evaluate(body);
|
|
325
|
-
|
|
1119
|
+
// `eval` returns whatever the page hands back — an unbounded innerHTML
|
|
1120
|
+
// is one keystroke away — so the bound lands here, where the value is
|
|
1121
|
+
// produced, rather than in one transport's envelope.
|
|
1122
|
+
return { result: boundValue(result) };
|
|
326
1123
|
}
|
|
327
1124
|
default:
|
|
328
1125
|
throw new CraftdriverError(ErrorCode.INVALID_ARGUMENT, `unknown command "${cmd}"`, { hint: 'run `craftdriver --help` for the command list' });
|