craftdriver 1.6.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.
Files changed (229) hide show
  1. package/CHANGELOG.md +35 -6
  2. package/README.md +17 -6
  3. package/dist/cli/agentSession.d.ts +62 -0
  4. package/dist/cli/agentSession.d.ts.map +1 -0
  5. package/dist/cli/agentSession.js +100 -0
  6. package/dist/cli/agentSession.js.map +1 -0
  7. package/dist/cli/artifactPaths.d.ts +42 -0
  8. package/dist/cli/artifactPaths.d.ts.map +1 -0
  9. package/dist/cli/artifactPaths.js +144 -0
  10. package/dist/cli/artifactPaths.js.map +1 -0
  11. package/dist/cli/bounds.d.ts +88 -0
  12. package/dist/cli/bounds.d.ts.map +1 -0
  13. package/dist/cli/bounds.js +128 -0
  14. package/dist/cli/bounds.js.map +1 -0
  15. package/dist/cli/client.d.ts +3 -0
  16. package/dist/cli/client.d.ts.map +1 -1
  17. package/dist/cli/client.js +24 -8
  18. package/dist/cli/client.js.map +1 -1
  19. package/dist/cli/daemon.d.ts +3 -0
  20. package/dist/cli/daemon.d.ts.map +1 -1
  21. package/dist/cli/daemon.js +152 -39
  22. package/dist/cli/daemon.js.map +1 -1
  23. package/dist/cli/defaults.d.ts +23 -3
  24. package/dist/cli/defaults.d.ts.map +1 -1
  25. package/dist/cli/defaults.js +50 -5
  26. package/dist/cli/defaults.js.map +1 -1
  27. package/dist/cli/dispatcher.d.ts +54 -11
  28. package/dist/cli/dispatcher.d.ts.map +1 -1
  29. package/dist/cli/dispatcher.js +851 -54
  30. package/dist/cli/dispatcher.js.map +1 -1
  31. package/dist/cli/index.d.ts.map +1 -1
  32. package/dist/cli/index.js +305 -56
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/init.d.ts +14 -14
  35. package/dist/cli/init.d.ts.map +1 -1
  36. package/dist/cli/init.js +328 -180
  37. package/dist/cli/init.js.map +1 -1
  38. package/dist/cli/journal.d.ts +153 -0
  39. package/dist/cli/journal.d.ts.map +1 -0
  40. package/dist/cli/journal.js +252 -0
  41. package/dist/cli/journal.js.map +1 -0
  42. package/dist/cli/lineReader.d.ts +33 -0
  43. package/dist/cli/lineReader.d.ts.map +1 -0
  44. package/dist/cli/lineReader.js +66 -0
  45. package/dist/cli/lineReader.js.map +1 -0
  46. package/dist/cli/locatorCandidates.d.ts +44 -0
  47. package/dist/cli/locatorCandidates.d.ts.map +1 -0
  48. package/dist/cli/locatorCandidates.js +190 -0
  49. package/dist/cli/locatorCandidates.js.map +1 -0
  50. package/dist/cli/mcp/artifacts.d.ts +39 -1
  51. package/dist/cli/mcp/artifacts.d.ts.map +1 -1
  52. package/dist/cli/mcp/artifacts.js +104 -9
  53. package/dist/cli/mcp/artifacts.js.map +1 -1
  54. package/dist/cli/mcp/bounds.d.ts +65 -0
  55. package/dist/cli/mcp/bounds.d.ts.map +1 -0
  56. package/dist/cli/mcp/bounds.js +124 -0
  57. package/dist/cli/mcp/bounds.js.map +1 -0
  58. package/dist/cli/mcp/params.d.ts +38 -0
  59. package/dist/cli/mcp/params.d.ts.map +1 -0
  60. package/dist/cli/mcp/params.js +174 -0
  61. package/dist/cli/mcp/params.js.map +1 -0
  62. package/dist/cli/mcp/server.d.ts +94 -16
  63. package/dist/cli/mcp/server.d.ts.map +1 -1
  64. package/dist/cli/mcp/server.js +304 -150
  65. package/dist/cli/mcp/server.js.map +1 -1
  66. package/dist/cli/mcp/tools.d.ts +62 -17
  67. package/dist/cli/mcp/tools.d.ts.map +1 -1
  68. package/dist/cli/mcp/tools.js +373 -158
  69. package/dist/cli/mcp/tools.js.map +1 -1
  70. package/dist/cli/pageSemantics.d.ts +42 -0
  71. package/dist/cli/pageSemantics.d.ts.map +1 -0
  72. package/dist/cli/pageSemantics.js +194 -0
  73. package/dist/cli/pageSemantics.js.map +1 -0
  74. package/dist/cli/parseArgs.d.ts +6 -18
  75. package/dist/cli/parseArgs.d.ts.map +1 -1
  76. package/dist/cli/parseArgs.js +689 -45
  77. package/dist/cli/parseArgs.js.map +1 -1
  78. package/dist/cli/protocol.d.ts +8 -0
  79. package/dist/cli/protocol.d.ts.map +1 -1
  80. package/dist/cli/sessionRegistry.d.ts +42 -0
  81. package/dist/cli/sessionRegistry.d.ts.map +1 -0
  82. package/dist/cli/sessionRegistry.js +99 -0
  83. package/dist/cli/sessionRegistry.js.map +1 -0
  84. package/dist/cli/snapshot.d.ts +100 -18
  85. package/dist/cli/snapshot.d.ts.map +1 -1
  86. package/dist/cli/snapshot.js +248 -102
  87. package/dist/cli/snapshot.js.map +1 -1
  88. package/dist/cli/stateStore.d.ts +50 -0
  89. package/dist/cli/stateStore.d.ts.map +1 -0
  90. package/dist/cli/stateStore.js +122 -0
  91. package/dist/cli/stateStore.js.map +1 -0
  92. package/dist/index.d.ts +2 -1
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +2 -0
  95. package/dist/index.js.map +1 -1
  96. package/dist/lib/bidi/index.d.ts +1 -1
  97. package/dist/lib/bidi/index.d.ts.map +1 -1
  98. package/dist/lib/bidi/index.js +2 -2
  99. package/dist/lib/bidi/index.js.map +1 -1
  100. package/dist/lib/bidi/logs.d.ts +3 -0
  101. package/dist/lib/bidi/logs.d.ts.map +1 -1
  102. package/dist/lib/bidi/logs.js +7 -0
  103. package/dist/lib/bidi/logs.js.map +1 -1
  104. package/dist/lib/bidi/network.d.ts +14 -1
  105. package/dist/lib/bidi/network.d.ts.map +1 -1
  106. package/dist/lib/bidi/network.js +45 -7
  107. package/dist/lib/bidi/network.js.map +1 -1
  108. package/dist/lib/bidi/storage.d.ts +30 -6
  109. package/dist/lib/bidi/storage.d.ts.map +1 -1
  110. package/dist/lib/bidi/storage.js +190 -45
  111. package/dist/lib/bidi/storage.js.map +1 -1
  112. package/dist/lib/bidi/types.d.ts +7 -2
  113. package/dist/lib/bidi/types.d.ts.map +1 -1
  114. package/dist/lib/browser.d.ts +38 -3
  115. package/dist/lib/browser.d.ts.map +1 -1
  116. package/dist/lib/browser.js +196 -30
  117. package/dist/lib/browser.js.map +1 -1
  118. package/dist/lib/browserContext.d.ts +54 -18
  119. package/dist/lib/browserContext.d.ts.map +1 -1
  120. package/dist/lib/browserContext.js +295 -92
  121. package/dist/lib/browserContext.js.map +1 -1
  122. package/dist/lib/by.d.ts +3 -3
  123. package/dist/lib/by.d.ts.map +1 -1
  124. package/dist/lib/by.js +7 -3
  125. package/dist/lib/by.js.map +1 -1
  126. package/dist/lib/driver.d.ts +7 -0
  127. package/dist/lib/driver.d.ts.map +1 -1
  128. package/dist/lib/driver.js +12 -0
  129. package/dist/lib/driver.js.map +1 -1
  130. package/dist/lib/errors.d.ts +15 -0
  131. package/dist/lib/errors.d.ts.map +1 -1
  132. package/dist/lib/errors.js +15 -0
  133. package/dist/lib/errors.js.map +1 -1
  134. package/dist/lib/initScript.d.ts +9 -0
  135. package/dist/lib/initScript.d.ts.map +1 -0
  136. package/dist/lib/initScript.js +14 -0
  137. package/dist/lib/initScript.js.map +1 -0
  138. package/dist/lib/page.d.ts +22 -0
  139. package/dist/lib/page.d.ts.map +1 -1
  140. package/dist/lib/page.js +48 -0
  141. package/dist/lib/page.js.map +1 -1
  142. package/dist/lib/secureFile.d.ts +8 -0
  143. package/dist/lib/secureFile.d.ts.map +1 -0
  144. package/dist/lib/secureFile.js +67 -0
  145. package/dist/lib/secureFile.js.map +1 -0
  146. package/dist/lib/sessionStateValidation.d.ts +35 -0
  147. package/dist/lib/sessionStateValidation.d.ts.map +1 -0
  148. package/dist/lib/sessionStateValidation.js +223 -0
  149. package/dist/lib/sessionStateValidation.js.map +1 -0
  150. package/dist/lib/timing.d.ts +8 -0
  151. package/dist/lib/timing.d.ts.map +1 -1
  152. package/dist/lib/timing.js +8 -0
  153. package/dist/lib/timing.js.map +1 -1
  154. package/dist/lib/tracing.d.ts +2 -1
  155. package/dist/lib/tracing.d.ts.map +1 -1
  156. package/dist/lib/tracing.js +12 -1
  157. package/dist/lib/tracing.js.map +1 -1
  158. package/dist/lib/visual/antialias.d.ts +41 -0
  159. package/dist/lib/visual/antialias.d.ts.map +1 -0
  160. package/dist/lib/visual/antialias.js +126 -0
  161. package/dist/lib/visual/antialias.js.map +1 -0
  162. package/dist/lib/visual/codec.d.ts +24 -0
  163. package/dist/lib/visual/codec.d.ts.map +1 -0
  164. package/dist/lib/visual/codec.js +11 -0
  165. package/dist/lib/visual/codec.js.map +1 -0
  166. package/dist/lib/visual/compare.d.ts +38 -0
  167. package/dist/lib/visual/compare.d.ts.map +1 -0
  168. package/dist/lib/visual/compare.js +209 -0
  169. package/dist/lib/visual/compare.js.map +1 -0
  170. package/dist/lib/visual/diff.d.ts +18 -0
  171. package/dist/lib/visual/diff.d.ts.map +1 -0
  172. package/dist/lib/visual/diff.js +23 -0
  173. package/dist/lib/visual/diff.js.map +1 -0
  174. package/dist/lib/visual/expectScreenshot.d.ts +65 -0
  175. package/dist/lib/visual/expectScreenshot.d.ts.map +1 -0
  176. package/dist/lib/visual/expectScreenshot.js +287 -0
  177. package/dist/lib/visual/expectScreenshot.js.map +1 -0
  178. package/dist/lib/visual/index.d.ts +10 -0
  179. package/dist/lib/visual/index.d.ts.map +1 -0
  180. package/dist/lib/visual/index.js +9 -0
  181. package/dist/lib/visual/index.js.map +1 -0
  182. package/dist/lib/visual/pixelMath.d.ts +32 -0
  183. package/dist/lib/visual/pixelMath.d.ts.map +1 -0
  184. package/dist/lib/visual/pixelMath.js +70 -0
  185. package/dist/lib/visual/pixelMath.js.map +1 -0
  186. package/dist/lib/visual/pngHeader.d.ts +27 -0
  187. package/dist/lib/visual/pngHeader.d.ts.map +1 -0
  188. package/dist/lib/visual/pngHeader.js +63 -0
  189. package/dist/lib/visual/pngHeader.js.map +1 -0
  190. package/dist/lib/visual/pngjsCodec.d.ts +22 -0
  191. package/dist/lib/visual/pngjsCodec.d.ts.map +1 -0
  192. package/dist/lib/visual/pngjsCodec.js +70 -0
  193. package/dist/lib/visual/pngjsCodec.js.map +1 -0
  194. package/dist/lib/visual/types.d.ts +89 -0
  195. package/dist/lib/visual/types.d.ts.map +1 -0
  196. package/dist/lib/visual/types.js +20 -0
  197. package/dist/lib/visual/types.js.map +1 -0
  198. package/dist/lib/visual/visualError.d.ts +32 -0
  199. package/dist/lib/visual/visualError.d.ts.map +1 -0
  200. package/dist/lib/visual/visualError.js +59 -0
  201. package/dist/lib/visual/visualError.js.map +1 -0
  202. package/dist/lib/wait.d.ts.map +1 -1
  203. package/dist/lib/wait.js +7 -1
  204. package/dist/lib/wait.js.map +1 -1
  205. package/docs/agents.md +56 -61
  206. package/docs/api-reference.md +16 -1
  207. package/docs/browser-api.md +24 -4
  208. package/docs/browser-context.md +18 -12
  209. package/docs/cli.md +346 -26
  210. package/docs/error-codes.md +6 -4
  211. package/docs/getting-started.md +6 -1
  212. package/docs/index.md +1 -1
  213. package/docs/mcp.md +129 -62
  214. package/docs/public/examples/agent-actions.html +48 -0
  215. package/docs/public/examples/agent-debug.html +86 -0
  216. package/docs/public/examples/agent-late-mutation.html +18 -0
  217. package/docs/public/examples/agent-ref-shift.html +33 -0
  218. package/docs/recipes/login-once-reuse-session.md +32 -4
  219. package/docs/screenshots.md +15 -6
  220. package/docs/session-management.md +64 -3
  221. package/docs/visual-testing.md +254 -0
  222. package/docs/why-craftdriver.md +4 -3
  223. package/package.json +5 -1
  224. package/skills/craftdriver/SKILL.md +59 -75
  225. package/skills/craftdriver/agents/openai.yaml +4 -0
  226. package/skills/craftdriver/cheatsheet.md +18 -4
  227. package/skills/craftdriver/cli.md +194 -68
  228. package/skills/craftdriver/patterns.md +14 -8
  229. package/skills/craftdriver/workflow.md +149 -0
@@ -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
- if (!pending)
13
- pending = launch().then((b) => { inst = b; pending = null; return b; });
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
- await b.quit().catch(() => { });
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 = parseSelector(str(args, 'selector'));
388
+ const by = await selectorOf(ctx, args);
128
389
  const all = bool(args, 'all');
129
- const limit = int(args, 'limit', AGENT_DEFAULT_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 = parseSelector(str(args, 'selector'));
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 = parseSelector(str(args, 'selector'));
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 = parseSelector(str(args, 'selector'));
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
- const by = parseSelector(sel);
194
- const els = await b.findAll(by);
195
- if (els.length === 0) {
196
- throw new CraftdriverError(ErrorCode.NO_MATCH, `press: no element matches ${describeSelector(by)}`, { detail: { selector: describeSelector(by) } });
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 els = await b.findAll(by);
207
- if (els.length === 0) {
208
- throw new CraftdriverError(ErrorCode.NO_MATCH, `hover: no element matches ${describeSelector(by)}`, { detail: { selector: describeSelector(by) } });
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
- await els[0].hover({ timeout: ms(args) });
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
- const limit = int(args, 'limit', 2000);
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
- return { text: text.length > limit ? text.slice(0, limit) + '…' : text, truncated: text.length > limit, total: text.length };
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 by = parseSelector(sel);
225
- const els = await b.findAll(by);
226
- if (els.length === 0) {
227
- throw new CraftdriverError(ErrorCode.NO_MATCH, `text: no element matches ${describeSelector(by)}`, { detail: { selector: describeSelector(by) } });
228
- }
229
- return { text: await els[0].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 = parseSelector(str(args, 'selector'));
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
- return { name, value };
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 = parseSelector(str(args, 'selector'));
679
+ const by = await selectorOf(ctx, args);
240
680
  const b = await ctx.handle.get();
241
- return { value: await b.getValue(by, { timeout: ms(args) }) };
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 = parseSelector(str(args, 'selector'));
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(parseSelector(target), { state, timeout: ms(args) });
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
- const path = optStr(args, 'path');
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
- ...(path ? { path } : {}),
297
- ...(sel ? { selector: parseSelector(sel) } : {}),
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: path ?? null, bytes: buf.length };
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
- const snap = await takeSnapshot(b);
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 { url: snap.url, title: snap.title, lines: snap.lines };
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
- return { result };
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' });