wb-browser-runtime 0.6.1 → 0.10.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.
@@ -0,0 +1,101 @@
1
+ // Per-name session cache with in-flight create dedup.
2
+ //
3
+ // Today the slice enqueue is global (one chain, all sessions serialized),
4
+ // so concurrent ensures for the same name never happen — the second caller
5
+ // always sees the cache populated. Once slice dispatch moves to per-session
6
+ // chains (see Phase 4), two concurrent slices for "vendor-x" would both
7
+ // race bbCreateSession and burn two Browserbase sessions. Deduping the
8
+ // in-flight promise here fixes that race up-front, so the per-session
9
+ // chain change in Phase 4 is a one-liner in main.js instead of a recursive
10
+ // bug hunt later.
11
+ //
12
+ // The manager is creation-logic-free on purpose: callers hand an async
13
+ // factory to `ensure()`, which is invoked at most once per name. The
14
+ // factory is responsible for its own cleanup on throw — on rejection the
15
+ // in-flight entry is dropped so a subsequent caller can retry.
16
+
17
+ export class SessionManager {
18
+ constructor() {
19
+ this._sessions = new Map();
20
+ this._inFlight = new Map();
21
+ this._chains = new Map();
22
+ }
23
+
24
+ get size() {
25
+ return this._sessions.size;
26
+ }
27
+
28
+ has(name) {
29
+ return this._sessions.has(name);
30
+ }
31
+
32
+ get(name) {
33
+ return this._sessions.get(name);
34
+ }
35
+
36
+ delete(name) {
37
+ return this._sessions.delete(name);
38
+ }
39
+
40
+ entries() {
41
+ return this._sessions.entries();
42
+ }
43
+
44
+ values() {
45
+ return this._sessions.values();
46
+ }
47
+
48
+ // Match Map's default iterator (yields [name, info] pairs) so callers can
49
+ // write `for (const [name, info] of manager)` the same way they would
50
+ // against the underlying Map.
51
+ [Symbol.iterator]() {
52
+ return this._sessions.entries();
53
+ }
54
+
55
+ // Serialize work against a single session name (so two slices against
56
+ // the same Playwright page don't race), but let distinct names run in
57
+ // parallel. Before this, the entry point held a single global promise
58
+ // chain — two slices against "vendor-a" and "vendor-b" serialized even
59
+ // though they touch disjoint browsers. The in-flight-create dedup in
60
+ // `ensure()` is what makes per-session parallelism safe here: two
61
+ // concurrent slices for the same name still share one bbCreateSession.
62
+ //
63
+ // `fn` should return a promise; errors propagate to the returned
64
+ // promise and don't poison the next link in the chain.
65
+ enqueueOn(name, fn) {
66
+ const prev = this._chains.get(name) ?? Promise.resolve();
67
+ const next = prev.catch(() => {}).then(fn);
68
+ this._chains.set(name, next);
69
+ return next;
70
+ }
71
+
72
+ // Resolve once every currently-queued chain has settled. Used by
73
+ // shutdown to wait for in-flight slices before closing browsers and
74
+ // releasing Browserbase sessions. Only observes chains that exist at
75
+ // call time — later enqueues aren't awaited, which is the correct
76
+ // behavior for shutdown (the main loop stops accepting messages first).
77
+ async drainAll() {
78
+ const chains = Array.from(this._chains.values());
79
+ await Promise.allSettled(chains);
80
+ }
81
+
82
+ async ensure(name, createFn) {
83
+ if (this._sessions.has(name)) return this._sessions.get(name);
84
+ const inFlight = this._inFlight.get(name);
85
+ if (inFlight) return inFlight;
86
+ // Only set the cached entry after createFn returns successfully — a
87
+ // failure inside createFn (e.g. startRecording throws) must not leave
88
+ // a half-constructed SessionInfo visible to iterators like shutdown().
89
+ const p = (async () => {
90
+ const info = await createFn();
91
+ this._sessions.set(name, info);
92
+ return info;
93
+ })();
94
+ this._inFlight.set(name, p);
95
+ try {
96
+ return await p;
97
+ } finally {
98
+ this._inFlight.delete(name);
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,112 @@
1
+ // Minimal in-memory fake of the Playwright `Page` API that the verbs
2
+ // exercise. Every method the verb registry touches is stubbed here; tests
3
+ // configure return values via the factory's options and assert against the
4
+ // recorded `calls` log.
5
+ //
6
+ // Intentionally does NOT simulate real browser behavior — a verb that
7
+ // would throw against a real page (e.g. `fill` on a non-existent selector)
8
+ // resolves cleanly here. Tests asserting error paths use options like
9
+ // `handles: { "#nope": null }` to wire explicit misses.
10
+
11
+ export function createStubPage(opts = {}) {
12
+ const calls = [];
13
+ let currentUrl = opts.initialUrl ?? "about:blank";
14
+
15
+ const screenshotBuf =
16
+ opts.screenshotBuf ?? Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
17
+ const extractResult = opts.extractResult ?? [];
18
+ const evalResult = opts.evalResult ?? null;
19
+ // handles: selector -> { textContent } | null (null = selector not found).
20
+ // Missing keys (undefined) also count as "not found" so tests only need
21
+ // to wire what matters.
22
+ const handles = opts.handles ?? {};
23
+
24
+ const record = (call) => {
25
+ calls.push(call);
26
+ return call;
27
+ };
28
+
29
+ return {
30
+ calls,
31
+ _setUrl(u) {
32
+ currentUrl = u;
33
+ },
34
+ url() {
35
+ return currentUrl;
36
+ },
37
+
38
+ async goto(url, options) {
39
+ record({ verb: "goto", url, options });
40
+ currentUrl = url;
41
+ },
42
+ async fill(selector, value, options) {
43
+ record({ verb: "fill", selector, value, options });
44
+ },
45
+ async click(selector, options) {
46
+ record({ verb: "click", selector, options });
47
+ },
48
+ async press(selector, key, options) {
49
+ record({ verb: "press", selector, key, options });
50
+ },
51
+ async waitForSelector(selector, options) {
52
+ record({ verb: "waitForSelector", selector, options });
53
+ },
54
+ async screenshot(options) {
55
+ record({ verb: "screenshot", options });
56
+ return screenshotBuf;
57
+ },
58
+ async $$eval(selector, fn, fieldSpec) {
59
+ record({ verb: "$$eval", selector, fieldSpec });
60
+ return extractResult;
61
+ },
62
+ async $(selector) {
63
+ record({ verb: "$", selector });
64
+ const h = handles[selector];
65
+ if (h == null) return null;
66
+ return {
67
+ async textContent() {
68
+ return h.textContent ?? null;
69
+ },
70
+ };
71
+ },
72
+ async evaluate(script) {
73
+ record({ verb: "evaluate", script });
74
+ return evalResult;
75
+ },
76
+ };
77
+ }
78
+
79
+ // Capture JSON frames written via lib/io.js `send` during a test. Returns
80
+ // a disposer that restores `process.stdout.write`. Non-JSON writes pass
81
+ // through, so node:test's own reporter output (spec/tap/text) is still
82
+ // visible. Use inside a test with `t.after(disposer)`.
83
+ export function captureSendFrames() {
84
+ const real = process.stdout.write.bind(process.stdout);
85
+ const frames = [];
86
+ process.stdout.write = (chunk, encoding, cb) => {
87
+ const str = typeof chunk === "string" ? chunk : chunk?.toString?.();
88
+ // Single JSON line ending in \n matches the shape of lib/io.js send()
89
+ // writes. Non-JSON or multi-line writes fall through to the real stdout
90
+ // so node:test's reporter output isn't swallowed.
91
+ if (
92
+ str &&
93
+ str.startsWith("{") &&
94
+ str.endsWith("\n") &&
95
+ str.indexOf("\n") === str.length - 1
96
+ ) {
97
+ try {
98
+ frames.push(JSON.parse(str.trim()));
99
+ if (typeof encoding === "function") encoding();
100
+ else if (typeof cb === "function") cb();
101
+ return true;
102
+ } catch {
103
+ // Fall through to real write — wasn't actually a send() frame.
104
+ }
105
+ }
106
+ return real(chunk, encoding, cb);
107
+ };
108
+ const dispose = () => {
109
+ process.stdout.write = real;
110
+ };
111
+ return { frames, dispose };
112
+ }
package/lib/util.js ADDED
@@ -0,0 +1,33 @@
1
+ import path from "node:path";
2
+ import { randomUUID } from "node:crypto";
3
+
4
+ // Resolve `candidate` inside `dir`, rejecting traversal and absolute paths.
5
+ // Returns null when the resolved path escapes `dir` (or is `dir` itself).
6
+ // Used by the screenshot verb and substitution artifact reads — anywhere
7
+ // runbook-author-controlled strings could compose with a trusted directory
8
+ // into an arbitrary filesystem write.
9
+ export function resolveInside(dir, candidate) {
10
+ const resolvedDir = path.resolve(dir);
11
+ const resolved = path.resolve(resolvedDir, candidate);
12
+ const rel = path.relative(resolvedDir, resolved);
13
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return null;
14
+ return resolved;
15
+ }
16
+
17
+ export function sanitizeArtifactName(s) {
18
+ // Keep author-chosen names readable but safe as filenames. Drop anything
19
+ // that could escape the artifacts dir (slashes, NULs, etc.).
20
+ return String(s).replace(/[^A-Za-z0-9_.-]+/g, "_").slice(0, 200);
21
+ }
22
+
23
+ export function autoArtifactName(blockIndex) {
24
+ const rand = randomUUID().replace(/-/g, "").slice(0, 8);
25
+ const n = Number.isFinite(blockIndex) ? blockIndex : 0;
26
+ return `cell-${n}-${rand}`;
27
+ }
28
+
29
+ export function redact(value) {
30
+ if (typeof value !== "string") return "";
31
+ if (value.length <= 4) return "***";
32
+ return `${value.slice(0, 2)}***`;
33
+ }
package/package.json CHANGED
@@ -1,20 +1,42 @@
1
1
  {
2
2
  "name": "wb-browser-runtime",
3
- "version": "0.6.1",
4
- "description": "Browser sidecar runtime for wb — Browserbase + Playwright over the wb-sidecar/1 line-framed JSON protocol.",
3
+ "version": "0.10.0",
4
+ "description": "Browser sidecar runtime for wb — Playwright over CDP (Browserbase, browser-use) via the wb-sidecar/1 line-framed JSON protocol.",
5
5
  "bin": {
6
6
  "wb-browser-runtime": "bin/wb-browser-runtime.js"
7
7
  },
8
8
  "type": "module",
9
9
  "engines": {
10
- "node": ">=18"
10
+ "node": ">=24"
11
+ },
12
+ "scripts": {
13
+ "test": "node --test"
11
14
  },
12
15
  "dependencies": {
13
16
  "playwright-core": "^1.49.0"
14
17
  },
15
18
  "files": [
16
19
  "bin",
20
+ "lib",
21
+ "verbs",
17
22
  "vendor",
18
23
  "README.md"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/workbooks-dev/wb-browser-runtime.git"
28
+ },
29
+ "homepage": "https://github.com/workbooks-dev/wb-browser-runtime#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/workbooks-dev/wb-browser-runtime/issues"
32
+ },
33
+ "license": "MIT",
34
+ "keywords": [
35
+ "wb",
36
+ "workbooks",
37
+ "playwright",
38
+ "browser-automation",
39
+ "browserbase",
40
+ "cdp"
19
41
  ]
20
42
  }
@@ -0,0 +1,74 @@
1
+ import path from "node:path";
2
+ import { promises as fsPromises } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
4
+ import { resolveInside } from "../lib/util.js";
5
+
6
+ // Ergonomic shorthand for attaching a human-readable label to an artifact
7
+ // already in (or about-to-be-in) $WB_ARTIFACTS_DIR. Writes a sidecar JSON
8
+ // at `<path>.meta.json`; the next Artifacts::sync() pass picks it up and
9
+ // attaches the label to the step.artifact_saved callback event. No upload,
10
+ // no network — just filesystem metadata.
11
+ //
12
+ // Usage:
13
+ // - save:
14
+ // path: statement.csv
15
+ // - announce_artifact:
16
+ // path: statement.csv
17
+ // label: "April HSBC statement"
18
+ // description: "Reconciled balance export"
19
+ //
20
+ // The target artifact need not exist yet — callers may pre-write the
21
+ // sidecar in an earlier block and drop the artifact later. The sidecar
22
+ // only takes effect once the artifact is visible to sync().
23
+ export default {
24
+ name: "announce_artifact",
25
+ primaryKey: "path",
26
+ async execute(_page, args) {
27
+ const rawPath = typeof args.path === "string" ? args.path.trim() : "";
28
+ if (!rawPath) {
29
+ throw new Error("announce_artifact: `path` is required");
30
+ }
31
+ const label = typeof args.label === "string" ? args.label : "";
32
+ if (!label) {
33
+ throw new Error("announce_artifact: `label` is required");
34
+ }
35
+ const description =
36
+ typeof args.description === "string" ? args.description : undefined;
37
+
38
+ const artifactsDir = (process.env.WB_ARTIFACTS_DIR || "").trim();
39
+ if (!artifactsDir) {
40
+ throw new Error(
41
+ "announce_artifact: $WB_ARTIFACTS_DIR is not set — run this workbook via `wb run` (wb exports the dir for you)",
42
+ );
43
+ }
44
+ if (path.isAbsolute(rawPath)) {
45
+ throw new Error(
46
+ `announce_artifact: absolute paths are not allowed (got ${rawPath})`,
47
+ );
48
+ }
49
+ const full = resolveInside(artifactsDir, rawPath);
50
+ if (!full) {
51
+ throw new Error(
52
+ `announce_artifact: path escapes artifacts dir (got ${rawPath})`,
53
+ );
54
+ }
55
+
56
+ const payload = { label };
57
+ if (description !== undefined) payload.description = description;
58
+ const serialized = JSON.stringify(payload, null, 2);
59
+
60
+ const sidecar = `${full}.meta.json`;
61
+ await fsPromises.mkdir(path.dirname(sidecar), { recursive: true });
62
+ const tmp = `${sidecar}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
63
+ try {
64
+ await fsPromises.writeFile(tmp, serialized, "utf8");
65
+ await fsPromises.rename(tmp, sidecar);
66
+ } catch (e) {
67
+ try {
68
+ await fsPromises.unlink(tmp);
69
+ } catch {}
70
+ throw e;
71
+ }
72
+ return `→ ${rawPath} (labelled)`;
73
+ },
74
+ };
@@ -0,0 +1,23 @@
1
+ export default {
2
+ name: "assert",
3
+ primaryKey: "selector",
4
+ async execute(page, args) {
5
+ const sel = args.selector;
6
+ const handle = await page.$(sel);
7
+ if (!handle) throw new Error(`assert: selector not found: ${sel}`);
8
+ if (args.text_contains) {
9
+ const txt = (await handle.textContent()) ?? "";
10
+ if (!txt.includes(args.text_contains)) {
11
+ throw new Error(
12
+ `assert: text "${args.text_contains}" not in ${sel} (got "${txt.slice(0, 80)}")`,
13
+ );
14
+ }
15
+ }
16
+ if (args.url_contains && !page.url().includes(args.url_contains)) {
17
+ throw new Error(
18
+ `assert: url does not contain "${args.url_contains}" (got ${page.url()})`,
19
+ );
20
+ }
21
+ return `${sel}`;
22
+ },
23
+ };
package/verbs/click.js ADDED
@@ -0,0 +1,8 @@
1
+ export default {
2
+ name: "click",
3
+ primaryKey: "selector",
4
+ async execute(page, args) {
5
+ await page.click(args.selector, { timeout: args.timeout ?? 10_000 });
6
+ return `${args.selector}`;
7
+ },
8
+ };
package/verbs/eval.js ADDED
@@ -0,0 +1,20 @@
1
+ export default {
2
+ name: "eval",
3
+ primaryKey: "script",
4
+ async execute(page, args, ctx) {
5
+ // Wrap the script in an async IIFE so authors can write function-body
6
+ // style: top-level `return X` works, top-level `await X` works, and
7
+ // multi-statement scripts read like the `(async () => { ... })()`
8
+ // pattern people already write into runbooks. Trade-off: bare-expression
9
+ // scripts (`script: "1 + 1"`) no longer return their value — authors
10
+ // must say `return 1 + 1` explicitly. That migration is intentional —
11
+ // multi-line scripts are the common case and "must add `return`" is a
12
+ // clearer rule than "single expressions vs. statement bodies behave
13
+ // differently."
14
+ const wrapped = `(async () => { ${args.script} })()`;
15
+ const result = await page.evaluate(wrapped);
16
+ console.log(JSON.stringify(result, null, 2));
17
+ if (ctx) ctx.lastResult = result;
18
+ return `script ran`;
19
+ },
20
+ };
@@ -0,0 +1,38 @@
1
+ export default {
2
+ name: "extract",
3
+ primaryKey: "selector",
4
+ async execute(page, args, ctx) {
5
+ // Pull structured rows out of the page. Each `field` entry is either:
6
+ // string — CSS selector relative to row, take textContent
7
+ // { selector, attr } — CSS selector relative to row, take attribute
8
+ // { selector, text: true } — explicit textContent (default)
9
+ const rowSelector = args.selector;
10
+ const fields = args.fields ?? {};
11
+ const items = await page.$$eval(
12
+ rowSelector,
13
+ (rows, fieldSpec) =>
14
+ rows.map((row) => {
15
+ const out = {};
16
+ for (const [name, spec] of Object.entries(fieldSpec)) {
17
+ const sel = typeof spec === "string" ? spec : spec.selector;
18
+ const attr = typeof spec === "string" ? null : spec.attr ?? null;
19
+ const el = sel ? row.querySelector(sel) : row;
20
+ if (!el) {
21
+ out[name] = null;
22
+ continue;
23
+ }
24
+ out[name] = attr
25
+ ? el.getAttribute(attr)
26
+ : (el.textContent || "").trim();
27
+ }
28
+ return out;
29
+ }),
30
+ fields,
31
+ );
32
+ // Emit as JSON to stdout so wb captures it in step.complete.stdout.
33
+ // Pretty-printed for readability when a runbook surfaces the output.
34
+ console.log(JSON.stringify(items, null, 2));
35
+ if (ctx) ctx.lastResult = items;
36
+ return `${rowSelector} → ${items.length} rows`;
37
+ },
38
+ };
package/verbs/fill.js ADDED
@@ -0,0 +1,13 @@
1
+ import { redact } from "../lib/util.js";
2
+
3
+ export default {
4
+ name: "fill",
5
+ primaryKey: "selector",
6
+ async execute(page, args) {
7
+ // Don't echo the value into the summary — could be a credential.
8
+ await page.fill(args.selector, String(args.value ?? ""), {
9
+ timeout: args.timeout ?? 10_000,
10
+ });
11
+ return `${args.selector} = «${redact(args.value)}»`;
12
+ },
13
+ };
package/verbs/goto.js ADDED
@@ -0,0 +1,10 @@
1
+ export default {
2
+ name: "goto",
3
+ primaryKey: "url",
4
+ async execute(page, args) {
5
+ const url = args.url ?? "";
6
+ const waitUntil = args.wait_until ?? "domcontentloaded";
7
+ await page.goto(url, { waitUntil, timeout: args.timeout ?? 30_000 });
8
+ return `→ ${page.url()}`;
9
+ },
10
+ };
package/verbs/index.js ADDED
@@ -0,0 +1,81 @@
1
+ // Verb registry. Each verb module exports a default { name, primaryKey,
2
+ // execute(page, args, ctx) } object. The registry is the single source of
3
+ // truth for the SUPPORTS list (shipped in the ready frame), the default-key
4
+ // lookup used by the bare-string arg form, and the dispatch table consumed
5
+ // by runVerb.
6
+ //
7
+ // Adding a verb: drop a new file next to these, import it here, append to
8
+ // VERBS. SUPPORTS/DEFAULT_KEYS/VERB_REGISTRY all derive automatically — no
9
+ // third list to keep in sync.
10
+
11
+ import gotoVerb from "./goto.js";
12
+ import fillVerb from "./fill.js";
13
+ import clickVerb from "./click.js";
14
+ import pressVerb from "./press.js";
15
+ import waitForVerb from "./wait_for.js";
16
+ import screenshotVerb from "./screenshot.js";
17
+ import extractVerb from "./extract.js";
18
+ import assertVerb from "./assert.js";
19
+ import evalVerb from "./eval.js";
20
+ import saveVerb from "./save.js";
21
+ import pauseForHumanVerb from "./pause_for_human.js";
22
+ import waitForDropVerb from "./wait_for_drop.js";
23
+ import announceArtifactVerb from "./announce_artifact.js";
24
+
25
+ const VERBS = [
26
+ gotoVerb,
27
+ fillVerb,
28
+ clickVerb,
29
+ pressVerb,
30
+ waitForVerb,
31
+ screenshotVerb,
32
+ extractVerb,
33
+ assertVerb,
34
+ evalVerb,
35
+ saveVerb,
36
+ pauseForHumanVerb,
37
+ waitForDropVerb,
38
+ announceArtifactVerb,
39
+ ];
40
+
41
+ export const VERB_REGISTRY = Object.fromEntries(VERBS.map((v) => [v.name, v]));
42
+ export const SUPPORTS = VERBS.map((v) => v.name);
43
+
44
+ export function verbName(verb) {
45
+ if (!verb || typeof verb !== "object") return String(verb);
46
+ return Object.keys(verb)[0] || "verb";
47
+ }
48
+
49
+ export function defaultKey(name) {
50
+ return VERB_REGISTRY[name]?.primaryKey ?? "value";
51
+ }
52
+
53
+ // Most verbs accept either a bare string ("goto: https://...") or a
54
+ // structured object ("goto: { url: ..., wait_until: ... }"). This pulls the
55
+ // canonical field out of either shape.
56
+ export function arg(value, primaryKey) {
57
+ if (typeof value === "string") return { [primaryKey]: value };
58
+ if (value && typeof value === "object") return value;
59
+ return {};
60
+ }
61
+
62
+ // Dispatch a single verb. `expand` is injected by the caller so the
63
+ // substitution/secrets machinery stays in the entry point (where env policy
64
+ // and the artifact cache live) instead of leaking into this module.
65
+ export async function runVerb(page, verb, index, ctx, expand) {
66
+ const name = verbName(verb);
67
+ const handler = VERB_REGISTRY[name];
68
+ if (!handler) throw new Error(`unsupported verb: ${name}`);
69
+ const raw = verb[name];
70
+ const args = expand(
71
+ arg(raw, handler.primaryKey),
72
+ ctx?.secrets,
73
+ ctx?.artifactCache,
74
+ );
75
+ // Mutate-in-place so writes a verb makes to ctx (e.g. eval setting
76
+ // ctx.lastResult for a later save to pick up) survive to the next verb.
77
+ // The spread-copy pattern that used to live here silently dropped those
78
+ // writes, which broke the documented eval → save pattern.
79
+ ctx.index = index;
80
+ return handler.execute(page, args, ctx);
81
+ }
@@ -0,0 +1,60 @@
1
+ // pause_for_human — generalized operator-handoff pause.
2
+ //
3
+ // Unlike side-effecting verbs (click, fill, goto) which return a summary
4
+ // and let the slice loop continue, pause_for_human returns a sentinel
5
+ // `{ __pause: {...} }` shape. The dispatcher in ../bin/wb-browser-runtime.js
6
+ // inspects that shape and emits a `slice.paused` frame instead of a
7
+ // `verb.complete`, which flips the Rust side into its pause-and-exit-42 path
8
+ // (pending descriptor written, checkpoint marked paused, process exits).
9
+ //
10
+ // On `wb resume`, the slice re-enters at verb_index + 1 (the verb that
11
+ // paused is skipped — it has no post-resume work). If the pause carried
12
+ // `actions`, the operator's choice is written to
13
+ // `$WB_ARTIFACTS_DIR/pause_result.json` by the Rust side before the sidecar
14
+ // boots again, so downstream bash/python cells can branch on it via a
15
+ // plain file read.
16
+
17
+ const VALID_RESUME_MODES = ["operator_click", "poll", "timeout"];
18
+
19
+ export default {
20
+ name: "pause_for_human",
21
+ primaryKey: "message",
22
+ async execute(_page, args, ctx) {
23
+ const resumeOn = args.resume_on || "operator_click";
24
+ if (!VALID_RESUME_MODES.includes(resumeOn)) {
25
+ throw new Error(
26
+ `pause_for_human: resume_on must be one of ${VALID_RESUME_MODES.join("|")}, got "${resumeOn}"`,
27
+ );
28
+ }
29
+ // Default action: a single "Resume" button. Authors who want
30
+ // branching on operator choice provide their own `actions` list.
31
+ const actions =
32
+ Array.isArray(args.actions) && args.actions.length > 0
33
+ ? args.actions
34
+ : [{ label: "Resume", value: null }];
35
+
36
+ // Validate action entries early so malformed YAML doesn't reach the
37
+ // run page as a broken button set.
38
+ for (const a of actions) {
39
+ if (!a || typeof a !== "object" || typeof a.label !== "string") {
40
+ throw new Error(
41
+ `pause_for_human: each action must be { label: string, value?: any }; got ${JSON.stringify(a)}`,
42
+ );
43
+ }
44
+ }
45
+
46
+ return {
47
+ __pause: {
48
+ reason: "pause_for_human",
49
+ message: args.message || "",
50
+ context_url: args.context_url || null,
51
+ resume_on: resumeOn,
52
+ timeout: args.timeout || null,
53
+ actions,
54
+ // Dispatcher wraps this with verb_index at emit time so `wb resume`
55
+ // knows where to re-enter.
56
+ sidecar_state: {},
57
+ },
58
+ };
59
+ },
60
+ };
package/verbs/press.js ADDED
@@ -0,0 +1,9 @@
1
+ export default {
2
+ name: "press",
3
+ primaryKey: "key",
4
+ async execute(page, args) {
5
+ const target = args.selector ?? "body";
6
+ await page.press(target, args.key, { timeout: args.timeout ?? 5_000 });
7
+ return `${target} ⌨ ${args.key}`;
8
+ },
9
+ };