@sdxc/spec 0.0.0-pre.1

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 (77) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +924 -0
  3. package/dist/ast.d.ts +193 -0
  4. package/dist/ast.js +9 -0
  5. package/dist/builtins.d.ts +29 -0
  6. package/dist/builtins.js +66 -0
  7. package/dist/cli.d.ts +21 -0
  8. package/dist/cli.js +297 -0
  9. package/dist/diagnostics.d.ts +47 -0
  10. package/dist/diagnostics.js +8 -0
  11. package/dist/errors.d.ts +131 -0
  12. package/dist/errors.js +159 -0
  13. package/dist/executor.d.ts +66 -0
  14. package/dist/executor.js +320 -0
  15. package/dist/expectation.d.ts +61 -0
  16. package/dist/expectation.js +222 -0
  17. package/dist/index.d.ts +51 -0
  18. package/dist/index.js +36 -0
  19. package/dist/lexer.d.ts +22 -0
  20. package/dist/lexer.js +284 -0
  21. package/dist/loader.d.ts +21 -0
  22. package/dist/loader.js +81 -0
  23. package/dist/parser.d.ts +24 -0
  24. package/dist/parser.js +502 -0
  25. package/dist/permissions.d.ts +139 -0
  26. package/dist/permissions.js +325 -0
  27. package/dist/plugin.d.ts +90 -0
  28. package/dist/plugin.js +9 -0
  29. package/dist/plugins/browser.d.ts +24 -0
  30. package/dist/plugins/browser.js +896 -0
  31. package/dist/plugins/cli.d.ts +17 -0
  32. package/dist/plugins/cli.js +134 -0
  33. package/dist/plugins/db-e2e-probe.d.ts +14 -0
  34. package/dist/plugins/db-e2e-probe.js +112 -0
  35. package/dist/plugins/db.d.ts +19 -0
  36. package/dist/plugins/db.js +199 -0
  37. package/dist/plugins/demo.d.ts +17 -0
  38. package/dist/plugins/demo.js +70 -0
  39. package/dist/plugins/env.d.ts +18 -0
  40. package/dist/plugins/env.js +87 -0
  41. package/dist/plugins/fs.d.ts +16 -0
  42. package/dist/plugins/fs.js +415 -0
  43. package/dist/plugins/http.d.ts +19 -0
  44. package/dist/plugins/http.js +505 -0
  45. package/dist/plugins/jwt.d.ts +17 -0
  46. package/dist/plugins/jwt.js +342 -0
  47. package/dist/plugins/sample.d.ts +27 -0
  48. package/dist/plugins/sample.js +400 -0
  49. package/dist/plugins/url.d.ts +18 -0
  50. package/dist/plugins/url.js +126 -0
  51. package/dist/project-config.d.ts +163 -0
  52. package/dist/project-config.js +497 -0
  53. package/dist/registry.d.ts +56 -0
  54. package/dist/registry.js +110 -0
  55. package/dist/reporter.d.ts +30 -0
  56. package/dist/reporter.js +237 -0
  57. package/dist/run.d.ts +74 -0
  58. package/dist/run.js +179 -0
  59. package/dist/runner.d.ts +52 -0
  60. package/dist/runner.js +38 -0
  61. package/dist/source.d.ts +37 -0
  62. package/dist/source.js +31 -0
  63. package/dist/sources.d.ts +45 -0
  64. package/dist/sources.js +54 -0
  65. package/dist/tokens.d.ts +34 -0
  66. package/dist/tokens.js +25 -0
  67. package/dist/transport-stdio.d.ts +34 -0
  68. package/dist/transport-stdio.js +400 -0
  69. package/dist/values.d.ts +48 -0
  70. package/dist/values.js +52 -0
  71. package/dist/workers.d.ts +40 -0
  72. package/dist/workers.js +26 -0
  73. package/dist/workspace-none.d.ts +23 -0
  74. package/dist/workspace-none.js +33 -0
  75. package/dist/workspace.d.ts +47 -0
  76. package/dist/workspace.js +116 -0
  77. package/package.json +28 -0
@@ -0,0 +1,896 @@
1
+ /**
2
+ * The built-in `browser` capability: drive a real browser through the
3
+ * accessibility tree, not DOM internals, via the globally-installed
4
+ * `agent-browser` CLI. Reaching web content is privileged, so the whole
5
+ * family requires `net`; the trusted binary needs no `run` grant (ADR-007 §4).
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ import { accessSync, constants } from "node:fs";
12
+ import { basename, delimiter, join, sep } from "node:path";
13
+ import { failure, isFailure, success } from "@sdxc/result";
14
+ import { ExpectationError, ToolError } from "../errors.js";
15
+ import { formatValue } from "../values.js";
16
+ /** The trusted CLI binary every browser tool shells out to. */
17
+ const BROWSER_BINARY = "agent-browser";
18
+ /** The word `browser.fill` requires between the target and the value. */
19
+ const FILL_WORDS = ["with"];
20
+ /** The word `browser.checkbox` requires as its state assertion. */
21
+ const CHECKBOX_WORDS = ["checked"];
22
+ /** The word `browser.cookie` requires before the URL it scopes a cookie to. */
23
+ const COOKIE_WORDS = ["for"];
24
+ /** The word `browser.heading` requires before a heading level. */
25
+ const LEVEL_WORDS = ["level"];
26
+ /**
27
+ * One line of an accessibility snapshot naming a heading, its accessible
28
+ * name, and level: `- heading "Reports" [level=3, ref=e2]`. The level lives
29
+ * only in this text, not in the `refs` map, so matching by level parses it.
30
+ */
31
+ const HEADING_LINE = /^\s*-\s*heading\s+"((?:[^"\\]|\\.)*)"\s*\[([^\]]*)\]/;
32
+ /** The `level=N` attribute inside a snapshot line's bracketed attribute list. */
33
+ const LEVEL_ATTRIBUTE = /\blevel=(\d+)\b/;
34
+ /** Descriptors of every tool the `browser` namespace exposes. */
35
+ const BROWSER_TOOLS = [
36
+ {
37
+ name: "open",
38
+ summary: "Navigate the browser session to an absolute URL.",
39
+ kind: "action",
40
+ requires: "net",
41
+ params: [
42
+ {
43
+ name: "url",
44
+ kind: "value",
45
+ required: true,
46
+ summary: "Absolute URL to open; v1 has no environments to bind a base URL against.",
47
+ },
48
+ ],
49
+ },
50
+ {
51
+ name: "navigate",
52
+ summary: "Navigate the current browser session to another absolute URL.",
53
+ kind: "action",
54
+ requires: "net",
55
+ params: [
56
+ {
57
+ name: "url",
58
+ kind: "value",
59
+ required: true,
60
+ summary: "Absolute URL to navigate to; must be absolute, like `open`.",
61
+ },
62
+ ],
63
+ },
64
+ {
65
+ name: "cookie",
66
+ summary: 'Set a cookie on the session: `cookie "session" token for "https://app.test"`.',
67
+ kind: "action",
68
+ requires: "net",
69
+ params: [
70
+ { name: "name", kind: "value", required: true, summary: "Name of the cookie to set." },
71
+ {
72
+ name: "value",
73
+ kind: "value",
74
+ required: true,
75
+ summary: "Value of the cookie, typically read from the environment with `env.get`.",
76
+ },
77
+ {
78
+ name: "for",
79
+ kind: "word",
80
+ required: false,
81
+ summary: "The literal word `for`, introducing the URL the cookie belongs to.",
82
+ },
83
+ {
84
+ name: "url",
85
+ kind: "value",
86
+ required: false,
87
+ summary: "Absolute URL the cookie is scoped to; defaults to the page already open.",
88
+ },
89
+ ],
90
+ },
91
+ {
92
+ name: "ua",
93
+ summary: "Send a custom User-Agent header, so the app can recognize the spec run.",
94
+ kind: "action",
95
+ requires: "net",
96
+ params: [
97
+ {
98
+ name: "value",
99
+ kind: "value",
100
+ required: true,
101
+ summary: 'The User-Agent to send, e.g. "spec-runner/1.0".',
102
+ },
103
+ ],
104
+ },
105
+ {
106
+ name: "click",
107
+ summary: "Click the element with the given role and accessible name.",
108
+ kind: "action",
109
+ requires: "net",
110
+ params: [
111
+ {
112
+ name: "role",
113
+ kind: "word",
114
+ required: true,
115
+ summary: "Accessibility role of the element, e.g. `button` or `link`.",
116
+ },
117
+ {
118
+ name: "name",
119
+ kind: "value",
120
+ required: true,
121
+ summary: 'Accessible name the user perceives, e.g. "Sign in".',
122
+ },
123
+ ],
124
+ },
125
+ {
126
+ name: "fill",
127
+ summary: 'Fill a field addressed by role and name: `fill textbox "Email" with "x"`.',
128
+ kind: "action",
129
+ requires: "net",
130
+ params: [
131
+ {
132
+ name: "role",
133
+ kind: "word",
134
+ required: true,
135
+ summary: "Accessibility role of the field, typically `textbox`.",
136
+ },
137
+ {
138
+ name: "name",
139
+ kind: "value",
140
+ required: true,
141
+ summary: "Accessible name (label) of the field.",
142
+ },
143
+ {
144
+ name: "with",
145
+ kind: "word",
146
+ required: true,
147
+ summary: "The literal word `with`, separating the field from its value.",
148
+ },
149
+ {
150
+ name: "value",
151
+ kind: "value",
152
+ required: true,
153
+ summary: "The text to type into the field.",
154
+ },
155
+ ],
156
+ },
157
+ {
158
+ name: "check",
159
+ summary: "Check a checkbox addressed by role and accessible name.",
160
+ kind: "action",
161
+ requires: "net",
162
+ params: [
163
+ {
164
+ name: "role",
165
+ kind: "word",
166
+ required: true,
167
+ summary: "Accessibility role, typically `checkbox`.",
168
+ },
169
+ {
170
+ name: "name",
171
+ kind: "value",
172
+ required: true,
173
+ summary: "Accessible name of the checkbox.",
174
+ },
175
+ ],
176
+ },
177
+ {
178
+ name: "press",
179
+ summary: 'Press a key at the current focus, e.g. `press "Enter"`.',
180
+ kind: "action",
181
+ requires: "net",
182
+ params: [
183
+ {
184
+ name: "key",
185
+ kind: "value",
186
+ required: true,
187
+ summary: 'Key or combination to press, e.g. "Enter" or "Control+a".',
188
+ },
189
+ ],
190
+ },
191
+ {
192
+ name: "click_selector",
193
+ summary: "Escape hatch: click by raw CSS selector when no accessible name exists.",
194
+ kind: "action",
195
+ requires: "net",
196
+ params: [
197
+ {
198
+ name: "selector",
199
+ kind: "value",
200
+ required: true,
201
+ summary: "A raw CSS selector; a marked pocket of implementation coupling (ADR-005 §3).",
202
+ },
203
+ ],
204
+ },
205
+ {
206
+ name: "heading",
207
+ summary: 'Observe a heading by accessible name, optionally at a level: `heading "x" level 3`.',
208
+ kind: "observable",
209
+ requires: "net",
210
+ params: [
211
+ { name: "name", kind: "value", required: true, summary: "Accessible name of the heading." },
212
+ {
213
+ name: "level",
214
+ kind: "word",
215
+ required: false,
216
+ summary: "The literal word `level`, introducing the heading level to demand.",
217
+ },
218
+ {
219
+ name: "number",
220
+ kind: "value",
221
+ required: false,
222
+ summary: "The level: 3 matches an `<h3>` or a `role=heading` with `aria-level=3`.",
223
+ },
224
+ ],
225
+ },
226
+ {
227
+ name: "link",
228
+ summary: "Observe that a link with the given accessible name is present.",
229
+ kind: "observable",
230
+ requires: "net",
231
+ params: [
232
+ { name: "name", kind: "value", required: true, summary: "Accessible name of the link." },
233
+ ],
234
+ },
235
+ {
236
+ name: "button",
237
+ summary: "Observe that a button with the given accessible name is present.",
238
+ kind: "observable",
239
+ requires: "net",
240
+ params: [
241
+ { name: "name", kind: "value", required: true, summary: "Accessible name of the button." },
242
+ ],
243
+ },
244
+ {
245
+ name: "text",
246
+ summary: "Observe that the given text is visible anywhere on the page.",
247
+ kind: "observable",
248
+ requires: "net",
249
+ params: [
250
+ {
251
+ name: "substring",
252
+ kind: "value",
253
+ required: true,
254
+ summary: "Substring to look for in the page's visible text.",
255
+ },
256
+ ],
257
+ },
258
+ {
259
+ name: "checkbox",
260
+ summary: 'Assert a checkbox\'s state: `expect browser.checkbox "Remember me" checked`.',
261
+ kind: "observable",
262
+ requires: "net",
263
+ params: [
264
+ { name: "name", kind: "value", required: true, summary: "Accessible name of the checkbox." },
265
+ {
266
+ name: "state",
267
+ kind: "word",
268
+ required: true,
269
+ summary: "The word `checked`, the state being asserted.",
270
+ },
271
+ ],
272
+ },
273
+ {
274
+ name: "url",
275
+ summary: "Observe the session's current URL, or assert it equals an expected URL.",
276
+ kind: "observable",
277
+ requires: "net",
278
+ params: [
279
+ {
280
+ name: "expected",
281
+ kind: "value",
282
+ required: false,
283
+ summary: "When given, the absolute URL the current location must equal.",
284
+ },
285
+ ],
286
+ },
287
+ {
288
+ name: "title",
289
+ summary: "Observe the page's title, or assert it equals an expected title.",
290
+ kind: "observable",
291
+ requires: "net",
292
+ params: [
293
+ {
294
+ name: "expected",
295
+ kind: "value",
296
+ required: false,
297
+ summary: "When given, the title the document must have.",
298
+ },
299
+ ],
300
+ },
301
+ ];
302
+ /**
303
+ * Create the built-in `browser` plugin: accessibility-first web-interaction
304
+ * tools backed by `agent-browser`. Each call keys a session to the test's
305
+ * workspace, isolating browser state; {@link Plugin.dispose} closes them all.
306
+ */
307
+ export function createBrowserPlugin() {
308
+ /**
309
+ * Sessions this plugin has driven, closed on dispose. The same test
310
+ * workspace yields the same session across its many calls.
311
+ */
312
+ let sessions = new Set();
313
+ return {
314
+ namespace: "browser",
315
+ describe() {
316
+ return BROWSER_TOOLS;
317
+ },
318
+ async call(tool, args, context) {
319
+ let session = sessionFor(context.workspace);
320
+ sessions.add(session);
321
+ switch (tool) {
322
+ case "open":
323
+ case "navigate":
324
+ return await navigate(tool, args, context, session);
325
+ case "cookie":
326
+ return await cookie(args, context, session);
327
+ case "ua":
328
+ return await userAgent(args, session);
329
+ case "click":
330
+ return await click(args, session);
331
+ case "fill":
332
+ return await fill(args, session);
333
+ case "check":
334
+ return await check(args, session);
335
+ case "press":
336
+ return await press(args, session);
337
+ case "click_selector":
338
+ return await clickSelector(args, session);
339
+ case "heading":
340
+ if (args.length > 1)
341
+ return await headingAtLevel(args, session);
342
+ return await roleObservable(tool, args, session);
343
+ case "link":
344
+ case "button":
345
+ return await roleObservable(tool, args, session);
346
+ case "text":
347
+ return await text(args, session);
348
+ case "checkbox":
349
+ return await checkbox(args, session);
350
+ case "url":
351
+ return await url(args, session);
352
+ case "title":
353
+ return await title(args, session);
354
+ default: {
355
+ let names = BROWSER_TOOLS.map((descriptor) => descriptor.name).join(", ");
356
+ return failure(new ToolError(`browser has no tool named "${tool}"; tools: ${names}`));
357
+ }
358
+ }
359
+ },
360
+ /**
361
+ * Best-effort teardown: a failed close must never fail a run, and a
362
+ * missing binary at dispose time means there is nothing left to close.
363
+ */
364
+ async dispose() {
365
+ for (let session of sessions) {
366
+ await runBrowser(["close"], session);
367
+ }
368
+ sessions.clear();
369
+ },
370
+ };
371
+ }
372
+ /**
373
+ * `browser.open`/`browser.navigate url` — require an absolute http(s) URL,
374
+ * pass the scoped `net` check for its host and port, then navigate. Relative
375
+ * URLs are refused since v1 has no environments mechanism to resolve them.
376
+ */
377
+ async function navigate(tool, args, context, session) {
378
+ let target = readUrl(tool, args, 0);
379
+ if (isFailure(target))
380
+ return target;
381
+ let allowed = context.permissions.checkNet(target.data.hostname, portOf(target.data));
382
+ if (isFailure(allowed))
383
+ return allowed;
384
+ let response = await runBrowser(["open", target.data.href], session);
385
+ if (isFailure(response))
386
+ return response;
387
+ return success(null);
388
+ }
389
+ /**
390
+ * `browser.cookie name value [for url]` — seed the session's cookie jar so a
391
+ * spec starts already authenticated. Without `for`, the cookie scopes to the
392
+ * page already open, whose host was already `net`-checked when it was opened.
393
+ */
394
+ async function cookie(args, context, session) {
395
+ if (args.length !== 2 && args.length !== 4) {
396
+ return failure(new ToolError('browser.cookie takes a name and a value, optionally followed by `for "<url>"`'));
397
+ }
398
+ let name = stringArg(args, 0, "cookie", "name");
399
+ if (isFailure(name))
400
+ return name;
401
+ let value = stringArg(args, 1, "cookie", "value");
402
+ if (isFailure(value))
403
+ return value;
404
+ let scope;
405
+ if (args.length === 4) {
406
+ let separator = wordArg(args, 2, "cookie", COOKIE_WORDS);
407
+ if (isFailure(separator))
408
+ return separator;
409
+ let target = readUrl("cookie", args, 3);
410
+ if (isFailure(target))
411
+ return target;
412
+ let allowed = context.permissions.checkNet(target.data.hostname, portOf(target.data));
413
+ if (isFailure(allowed))
414
+ return allowed;
415
+ scope = target.data.href;
416
+ }
417
+ else {
418
+ let current = await currentUrl(session);
419
+ if (isFailure(current))
420
+ return current;
421
+ if (!current.data.startsWith("http:") && !current.data.startsWith("https:")) {
422
+ return failure(new ToolError('browser.cookie has no page to scope the cookie to; open one first, or name the URL: browser.cookie "session" token for "https://app.example.com"'));
423
+ }
424
+ scope = current.data;
425
+ }
426
+ let response = await runBrowser(["cookies", "set", name.data, value.data, "--url", scope], session);
427
+ if (isFailure(response))
428
+ return response;
429
+ return success(null);
430
+ }
431
+ /**
432
+ * `browser.ua value` — send a custom `User-Agent` header on every request, so
433
+ * an app can distinguish a spec run from a real visitor; `navigator.userAgent`
434
+ * in page script still reports the browser's own identity.
435
+ */
436
+ async function userAgent(args, session) {
437
+ let value = stringArg(args, 0, "ua", "value");
438
+ if (isFailure(value))
439
+ return value;
440
+ let headers = JSON.stringify({ "User-Agent": value.data });
441
+ let response = await runBrowser(["set", "headers", headers], session);
442
+ if (isFailure(response))
443
+ return response;
444
+ return success(null);
445
+ }
446
+ /** `browser.click role name` — act on the node found by role and name. */
447
+ async function click(args, session) {
448
+ let target = readTarget("click", args, 0);
449
+ if (isFailure(target))
450
+ return target;
451
+ let ref = await resolveElement(target.data.role, target.data.name, session);
452
+ if (isFailure(ref))
453
+ return ref;
454
+ if (ref.data === null)
455
+ return failure(notFound("click", target.data));
456
+ let response = await runBrowser(["click", `@${ref.data}`], session);
457
+ if (isFailure(response))
458
+ return response;
459
+ return success(null);
460
+ }
461
+ /** `browser.fill role name with value` — type into the field found by role and name. */
462
+ async function fill(args, session) {
463
+ let target = readTarget("fill", args, 0);
464
+ if (isFailure(target))
465
+ return target;
466
+ let separator = wordArg(args, 2, "fill", FILL_WORDS);
467
+ if (isFailure(separator))
468
+ return separator;
469
+ let value = stringArg(args, 3, "fill", "value");
470
+ if (isFailure(value))
471
+ return value;
472
+ let ref = await resolveElement(target.data.role, target.data.name, session);
473
+ if (isFailure(ref))
474
+ return ref;
475
+ if (ref.data === null)
476
+ return failure(notFound("fill", target.data));
477
+ let response = await runBrowser(["fill", `@${ref.data}`, value.data], session);
478
+ if (isFailure(response))
479
+ return response;
480
+ return success(null);
481
+ }
482
+ /** `browser.check role name` — check the checkbox found by role and name. */
483
+ async function check(args, session) {
484
+ let target = readTarget("check", args, 0);
485
+ if (isFailure(target))
486
+ return target;
487
+ let ref = await resolveElement(target.data.role, target.data.name, session);
488
+ if (isFailure(ref))
489
+ return ref;
490
+ if (ref.data === null)
491
+ return failure(notFound("check", target.data));
492
+ let response = await runBrowser(["check", `@${ref.data}`], session);
493
+ if (isFailure(response))
494
+ return response;
495
+ return success(null);
496
+ }
497
+ /** `browser.press key` — press a key at the current focus, no element needed. */
498
+ async function press(args, session) {
499
+ let key = stringArg(args, 0, "press", "key");
500
+ if (isFailure(key))
501
+ return key;
502
+ let response = await runBrowser(["press", key.data], session);
503
+ if (isFailure(response))
504
+ return response;
505
+ return success(null);
506
+ }
507
+ /** `browser.click_selector selector` — the CSS escape hatch (ADR-005 §3). */
508
+ async function clickSelector(args, session) {
509
+ let selector = stringArg(args, 0, "click_selector", "selector");
510
+ if (isFailure(selector))
511
+ return selector;
512
+ let response = await runBrowser(["click", selector.data], session);
513
+ if (isFailure(response))
514
+ return response;
515
+ return success(null);
516
+ }
517
+ /**
518
+ * `browser.heading|link|button name` — assert a node of that role and
519
+ * accessible name is present, yielding `true` or an `ExpectationError`
520
+ * carrying the demanded role and name.
521
+ */
522
+ async function roleObservable(tool, args, session) {
523
+ let name = stringArg(args, 0, tool, "name");
524
+ if (isFailure(name))
525
+ return name;
526
+ let ref = await resolveElement(tool, name.data, session);
527
+ if (isFailure(ref))
528
+ return ref;
529
+ if (ref.data === null) {
530
+ return failure(new ExpectationError(`no ${tool} named ${formatValue(name.data)} is present`, `${tool} ${formatValue(name.data)}`, null));
531
+ }
532
+ return success(true);
533
+ }
534
+ /**
535
+ * `browser.heading name level N` — assert a heading with that accessible name
536
+ * sits at that level; level 3 matches both an `<h3>` and a `role=heading`
537
+ * with `aria-level=3`, since both reach the accessibility tree identically.
538
+ */
539
+ async function headingAtLevel(args, session) {
540
+ if (args.length !== 3) {
541
+ return failure(new ToolError("browser.heading takes an accessible name, optionally followed by `level <n>`"));
542
+ }
543
+ let name = stringArg(args, 0, "heading", "name");
544
+ if (isFailure(name))
545
+ return name;
546
+ let separator = wordArg(args, 1, "heading", LEVEL_WORDS);
547
+ if (isFailure(separator))
548
+ return separator;
549
+ let level = levelArg(args, 2);
550
+ if (isFailure(level))
551
+ return level;
552
+ let response = await runBrowser(["snapshot"], session);
553
+ if (isFailure(response))
554
+ return response;
555
+ let text = typeof response.data.snapshot === "string" ? response.data.snapshot : "";
556
+ let wanted = normalizeName(name.data);
557
+ let found = [];
558
+ for (let line of text.split("\n")) {
559
+ let heading = HEADING_LINE.exec(line);
560
+ if (heading === null)
561
+ continue;
562
+ let [, quoted = "", attributes = ""] = heading;
563
+ if (normalizeName(unescapeName(quoted)) !== wanted)
564
+ continue;
565
+ let attribute = LEVEL_ATTRIBUTE.exec(attributes);
566
+ if (attribute === null)
567
+ continue;
568
+ let observed = Number(attribute[1]);
569
+ if (observed === level.data)
570
+ return success(true);
571
+ found.push(observed);
572
+ }
573
+ let observed = found.length === 0 ? null : found.join(", ");
574
+ return failure(new ExpectationError(found.length === 0
575
+ ? `no heading named ${formatValue(name.data)} is present`
576
+ : `the heading named ${formatValue(name.data)} is not at level ${level.data}`, level.data, observed));
577
+ }
578
+ /** Read a heading level: a positive whole number, nothing else. */
579
+ function levelArg(args, index) {
580
+ let arg = args[index];
581
+ if (arg === undefined ||
582
+ arg.kind !== "value" ||
583
+ typeof arg.value !== "number" ||
584
+ !Number.isInteger(arg.value) ||
585
+ arg.value < 1) {
586
+ return failure(new ToolError(`browser.heading expects a whole heading level of 1 or more for argument ${index + 1}`));
587
+ }
588
+ return success(arg.value);
589
+ }
590
+ /** Undo the escaping an accessibility snapshot applies inside a quoted name. */
591
+ function unescapeName(quoted) {
592
+ return quoted.replace(/\\(.)/g, "$1");
593
+ }
594
+ /** `browser.text substring` — assert the substring is in the page's visible text. */
595
+ async function text(args, session) {
596
+ let substring = stringArg(args, 0, "text", "substring");
597
+ if (isFailure(substring))
598
+ return substring;
599
+ let response = await runBrowser(["get", "text", "body"], session);
600
+ if (isFailure(response))
601
+ return response;
602
+ let visible = typeof response.data.text === "string" ? response.data.text : "";
603
+ if (visible.includes(substring.data))
604
+ return success(true);
605
+ return failure(new ExpectationError(`the text ${formatValue(substring.data)} is not visible on the page`, substring.data, visible));
606
+ }
607
+ /**
608
+ * `browser.checkbox name checked` — assert the named checkbox is checked. An
609
+ * absent checkbox and an unchecked checkbox both fail with an
610
+ * `ExpectationError`, one for the missing element and one for the wrong state.
611
+ */
612
+ async function checkbox(args, session) {
613
+ let name = stringArg(args, 0, "checkbox", "name");
614
+ if (isFailure(name))
615
+ return name;
616
+ let state = wordArg(args, 1, "checkbox", CHECKBOX_WORDS);
617
+ if (isFailure(state))
618
+ return state;
619
+ let ref = await resolveElement("checkbox", name.data, session);
620
+ if (isFailure(ref))
621
+ return ref;
622
+ if (ref.data === null) {
623
+ return failure(new ExpectationError(`no checkbox named ${formatValue(name.data)} is present`, `checkbox ${formatValue(name.data)}`, null));
624
+ }
625
+ let response = await runBrowser(["is", "checked", `@${ref.data}`], session);
626
+ if (isFailure(response))
627
+ return response;
628
+ if (response.data.checked === true)
629
+ return success(true);
630
+ return failure(new ExpectationError(`checkbox ${formatValue(name.data)} is not checked`, true, false));
631
+ }
632
+ /**
633
+ * `browser.url [expected]` — with no argument, observe the current URL; with
634
+ * one, assert it matches exactly. Comparison is against the full absolute
635
+ * URL, since v1 has no environments mechanism to resolve a relative path.
636
+ */
637
+ async function url(args, session) {
638
+ if (args.length > 1) {
639
+ return failure(new ToolError("browser.url takes at most one argument: an expected URL"));
640
+ }
641
+ let current = await currentUrl(session);
642
+ if (isFailure(current))
643
+ return current;
644
+ if (args.length === 0)
645
+ return success(current.data);
646
+ let expected = stringArg(args, 0, "url", "expected");
647
+ if (isFailure(expected))
648
+ return expected;
649
+ if (current.data === expected.data)
650
+ return success(true);
651
+ return failure(new ExpectationError(`the current URL is not ${formatValue(expected.data)}`, expected.data, current.data));
652
+ }
653
+ /**
654
+ * `browser.title [expected]` — with no argument, observe the document's
655
+ * title; with one, assert it equals that title exactly, in full — the tab
656
+ * text a user reads. `browser.text` covers matching a substring on the page.
657
+ */
658
+ async function title(args, session) {
659
+ if (args.length > 1) {
660
+ return failure(new ToolError("browser.title takes at most one argument: an expected title"));
661
+ }
662
+ let response = await runBrowser(["get", "title"], session);
663
+ if (isFailure(response))
664
+ return response;
665
+ let current = typeof response.data.title === "string" ? response.data.title : "";
666
+ if (args.length === 0)
667
+ return success(current);
668
+ let expected = stringArg(args, 0, "title", "expected");
669
+ if (isFailure(expected))
670
+ return expected;
671
+ if (current === expected.data)
672
+ return success(true);
673
+ return failure(new ExpectationError(`the page title is not ${formatValue(expected.data)}`, expected.data, current));
674
+ }
675
+ /** The session's current location, or the empty string when it has none. */
676
+ async function currentUrl(session) {
677
+ let response = await runBrowser(["get", "url"], session);
678
+ if (isFailure(response))
679
+ return response;
680
+ return success(typeof response.data.url === "string" ? response.data.url : "");
681
+ }
682
+ /**
683
+ * Read a `role name` pair starting at `index`: a bare-word role (roles are an
684
+ * open set, so any identifier is accepted) followed by a string name.
685
+ */
686
+ function readTarget(tool, args, index) {
687
+ let role = args[index];
688
+ if (role === undefined || role.kind !== "word") {
689
+ return failure(new ToolError(`browser.${tool} expects an accessibility role as a bare word for argument ${index + 1} (e.g. button, textbox)`));
690
+ }
691
+ let name = stringArg(args, index + 1, tool, "name");
692
+ if (isFailure(name))
693
+ return name;
694
+ return success({ role: role.word, name: name.data });
695
+ }
696
+ /**
697
+ * Resolve one element to its snapshot ref by reading the accessibility tree
698
+ * and matching on role and normalized accessible name. Returns the ref key
699
+ * (e.g. `"e4"`), `null` when nothing matches, or a failure from the snapshot call.
700
+ */
701
+ async function resolveElement(role, name, session) {
702
+ let response = await runBrowser(["snapshot", "-i"], session);
703
+ if (isFailure(response))
704
+ return response;
705
+ let refs = response.data.refs;
706
+ if (typeof refs !== "object" || refs === null || Array.isArray(refs))
707
+ return success(null);
708
+ let wanted = normalizeName(name);
709
+ for (let [key, node] of Object.entries(refs)) {
710
+ if (typeof node !== "object" || node === null)
711
+ continue;
712
+ let entry = node;
713
+ if (entry.role !== role)
714
+ continue;
715
+ if (typeof entry.name !== "string")
716
+ continue;
717
+ if (normalizeName(entry.name) === wanted)
718
+ return success(key);
719
+ }
720
+ return success(null);
721
+ }
722
+ function notFound(tool, target) {
723
+ return new ToolError(`browser.${tool} found no ${target.role} named ${formatValue(target.name)} in the accessibility tree`);
724
+ }
725
+ /**
726
+ * Normalize an accessible name for comparison: trim surrounding whitespace and
727
+ * collapse internal runs to a single space, so a label rendered as
728
+ * " Remember me" matches the spec's "Remember me".
729
+ */
730
+ function normalizeName(name) {
731
+ return name.trim().replace(/\s+/g, " ");
732
+ }
733
+ /**
734
+ * Run one `agent-browser` command for a session and return its `data`
735
+ * payload. A missing binary fails with an install hint (ADR-007 §4); success
736
+ * is read from the envelope's `success` field, since the CLI exits 0 regardless.
737
+ */
738
+ async function runBrowser(args, session) {
739
+ if (browserBinaryPath() === null) {
740
+ return failure(new ToolError(`the browser capability requires the "${BROWSER_BINARY}" CLI, which is not on PATH; install it globally with \`npm install -g agent-browser && agent-browser install\``));
741
+ }
742
+ let stdout;
743
+ let stderr;
744
+ try {
745
+ [stdout, stderr] = await captureBrowser(["--session", session, ...args, "--json"]);
746
+ }
747
+ catch (error) {
748
+ return failure(new ToolError(`browser failed to run "${BROWSER_BINARY} ${args[0]}": ${describeError(error)}`));
749
+ }
750
+ let envelope = parseEnvelope(stdout);
751
+ if (envelope === null) {
752
+ let detail = stderr.trim().length > 0 ? stderr.trim() : stdout.trim();
753
+ return failure(new ToolError(`browser could not parse the "${args[0]}" response from ${BROWSER_BINARY}: ${detail}`));
754
+ }
755
+ if (!envelope.success) {
756
+ return failure(new ToolError(`browser ${args[0]} failed: ${envelope.error ?? "unknown agent-browser error"}`));
757
+ }
758
+ return success(envelope.data ?? {});
759
+ }
760
+ /**
761
+ * Locate the trusted `agent-browser` CLI by scanning PATH for an executable
762
+ * of that name, the way a shell would. The resolved path stays useful for
763
+ * diagnostics; `null` is the single signal every caller treats as "not installed".
764
+ *
765
+ * @returns The absolute path of the binary, or null when it is not installed.
766
+ */
767
+ export function browserBinaryPath() {
768
+ if (BROWSER_BINARY.includes(sep))
769
+ return executable(BROWSER_BINARY);
770
+ for (let directory of (process.env.PATH ?? "").split(delimiter)) {
771
+ if (directory === "")
772
+ continue;
773
+ let found = executable(join(directory, BROWSER_BINARY));
774
+ if (found !== null)
775
+ return found;
776
+ }
777
+ return null;
778
+ }
779
+ /** The path back, when it names a file this process may execute; null otherwise. */
780
+ function executable(path) {
781
+ try {
782
+ accessSync(path, constants.X_OK);
783
+ return path;
784
+ }
785
+ catch {
786
+ return null;
787
+ }
788
+ }
789
+ /**
790
+ * Run the browser CLI to completion and collect both output streams. Success
791
+ * is read from the JSON envelope, since `agent-browser` exits 0 even when a
792
+ * command fails.
793
+ *
794
+ * @param args - Arguments to pass after the binary name.
795
+ * @returns The child's stdout and stderr, decoded as UTF-8.
796
+ * @throws When the binary cannot be started.
797
+ */
798
+ async function captureBrowser(args) {
799
+ let child = spawn(BROWSER_BINARY, args, { stdio: ["ignore", "pipe", "pipe"] });
800
+ let stdout = "";
801
+ let stderr = "";
802
+ child.stdout?.setEncoding("utf8");
803
+ child.stderr?.setEncoding("utf8");
804
+ child.stdout?.on("data", (chunk) => void (stdout += chunk));
805
+ child.stderr?.on("data", (chunk) => void (stderr += chunk));
806
+ await new Promise((settle, reject) => {
807
+ child.once("error", reject);
808
+ child.once("close", () => settle());
809
+ });
810
+ return [stdout, stderr];
811
+ }
812
+ /** Parse one `agent-browser --json` line, returning null when it is not the envelope. */
813
+ function parseEnvelope(stdout) {
814
+ let trimmed = stdout.trim();
815
+ if (trimmed.length === 0)
816
+ return null;
817
+ let parsed;
818
+ try {
819
+ parsed = JSON.parse(trimmed);
820
+ }
821
+ catch {
822
+ return null;
823
+ }
824
+ if (typeof parsed !== "object" || parsed === null)
825
+ return null;
826
+ let candidate = parsed;
827
+ if (typeof candidate.success !== "boolean")
828
+ return null;
829
+ let data = typeof candidate.data === "object" && candidate.data !== null && !Array.isArray(candidate.data)
830
+ ? candidate.data
831
+ : null;
832
+ let error = typeof candidate.error === "string" ? candidate.error : null;
833
+ return { success: candidate.success, data, error };
834
+ }
835
+ /**
836
+ * The `agent-browser` session name for a test: the basename of its isolated
837
+ * workspace directory, unique per test and stable across its phases — so
838
+ * session lifetime follows the workspace (ADR-005's browser-isolation answer).
839
+ */
840
+ function sessionFor(workspace) {
841
+ return basename(workspace.root);
842
+ }
843
+ /** The absolute http(s) URL at `index`, or a tool error explaining why not. */
844
+ function readUrl(tool, args, index) {
845
+ let raw = stringArg(args, index, tool, "url");
846
+ if (isFailure(raw))
847
+ return raw;
848
+ let parsed;
849
+ try {
850
+ parsed = new URL(raw.data);
851
+ }
852
+ catch {
853
+ return failure(new ToolError(`browser.${tool} received the relative URL ${formatValue(raw.data)}; v1 has no environments mechanism to bind a base URL against, so URLs must be absolute (see docs/adr/spec/ADR-008-environments-and-compatibility.md)`));
854
+ }
855
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
856
+ return failure(new ToolError(`browser.${tool} supports absolute http(s) URLs only; got ${formatValue(raw.data)}`));
857
+ }
858
+ return success(parsed);
859
+ }
860
+ /** The port a URL reaches: its own, or the scheme default (80/443). */
861
+ function portOf(target) {
862
+ if (target.port !== "")
863
+ return Number(target.port);
864
+ return target.protocol === "https:" ? 443 : 80;
865
+ }
866
+ /**
867
+ * Extract a required string argument, failing with the tool's usage when the
868
+ * argument is missing, a bare word, or not a string.
869
+ */
870
+ function stringArg(args, index, tool, name) {
871
+ let arg = args[index];
872
+ if (arg === undefined || arg.kind !== "value" || typeof arg.value !== "string") {
873
+ return failure(new ToolError(`browser.${tool} expects a string for its ${name} argument (position ${index + 1})`));
874
+ }
875
+ return success(arg.value);
876
+ }
877
+ /**
878
+ * Extract a bare-word argument and validate it against the tool's accepted
879
+ * words, naming them all on any mismatch — exactly as `fs` validates `exists`.
880
+ */
881
+ function wordArg(args, index, tool, accepted) {
882
+ let arg = args[index];
883
+ if (arg === undefined || arg.kind !== "word") {
884
+ return failure(new ToolError(`browser.${tool} expects a bare word as argument ${index + 1}; accepted words: ${accepted.join(", ")}`));
885
+ }
886
+ if (!accepted.includes(arg.word)) {
887
+ return failure(new ToolError(`browser.${tool} does not understand the word "${arg.word}"; accepted words: ${accepted.join(", ")}`));
888
+ }
889
+ return success(arg.word);
890
+ }
891
+ /** Render an unknown thrown value as a one-line message. */
892
+ function describeError(error) {
893
+ if (error instanceof Error)
894
+ return error.message;
895
+ return String(error);
896
+ }