@rolepod/uiproof 0.4.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,2530 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/rolepod-uiproof.ts
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // src/cli/doctor.ts
7
+ import { existsSync } from "fs";
8
+ import { resolve, join } from "path";
9
+ import { homedir, platform as osPlatform } from "os";
10
+ async function runDoctor() {
11
+ const checks = [];
12
+ const major = Number(process.versions.node.split(".")[0]);
13
+ checks.push({
14
+ name: "Node \u226520",
15
+ status: major >= 20 ? "ok" : "fail",
16
+ detail: process.versions.node
17
+ });
18
+ checks.push(checkPlaywrightChromium());
19
+ checks.push(await checkWebdriverIO());
20
+ checks.push(await checkAppiumServer());
21
+ if (osPlatform() === "darwin") {
22
+ checks.push(checkXcode());
23
+ }
24
+ checks.push(checkAndroidSdk());
25
+ checks.push({
26
+ name: "SeleniumEngine (roadmap v0.4)",
27
+ status: "warn",
28
+ detail: "Not implemented \u2014 deferred to v0.4 (legacy Selenium grid support, opt-in via ROLEPOD_MCP_WEB_ENGINE=selenium)."
29
+ });
30
+ checks.push(checkArtifactDir());
31
+ print(checks);
32
+ const failed = checks.some((c) => c.status === "fail");
33
+ return failed ? 1 : 0;
34
+ }
35
+ function checkPlaywrightChromium() {
36
+ const candidates = [
37
+ join(homedir(), "Library", "Caches", "ms-playwright"),
38
+ join(homedir(), ".cache", "ms-playwright"),
39
+ process.env.PLAYWRIGHT_BROWSERS_PATH
40
+ ].filter((x) => typeof x === "string");
41
+ for (const base of candidates) {
42
+ if (existsSync(base)) {
43
+ return {
44
+ name: "Playwright Chromium installed",
45
+ status: "ok",
46
+ detail: base
47
+ };
48
+ }
49
+ }
50
+ return {
51
+ name: "Playwright Chromium installed",
52
+ status: "fail",
53
+ detail: "Run: npx playwright install chromium"
54
+ };
55
+ }
56
+ async function checkWebdriverIO() {
57
+ try {
58
+ const url = await import.meta.resolve?.("webdriverio");
59
+ return {
60
+ name: "webdriverio (mobile client, v0.3)",
61
+ status: "ok",
62
+ detail: url ?? "resolved"
63
+ };
64
+ } catch {
65
+ return {
66
+ name: "webdriverio (mobile client, v0.3)",
67
+ status: "warn",
68
+ detail: "Not installed \u2014 web works fine without it. Mobile is roadmap v0.3. For mobile: npm i webdriverio"
69
+ };
70
+ }
71
+ }
72
+ async function checkAppiumServer() {
73
+ const host = process.env.APPIUM_HOST ?? "127.0.0.1";
74
+ const port = Number(process.env.APPIUM_PORT ?? 4723);
75
+ const path = process.env.APPIUM_BASE_PATH ?? "/";
76
+ const url = `http://${host}:${port}${path.endsWith("/") ? path : path + "/"}status`;
77
+ try {
78
+ const ctrl = new AbortController();
79
+ const timeout = setTimeout(() => ctrl.abort(), 1500);
80
+ const res = await fetch(url, { signal: ctrl.signal });
81
+ clearTimeout(timeout);
82
+ return {
83
+ name: "Appium server (roadmap v0.3)",
84
+ status: res.ok ? "ok" : "warn",
85
+ detail: `${url} \u2192 HTTP ${res.status}`
86
+ };
87
+ } catch {
88
+ return {
89
+ name: "Appium server (roadmap v0.3)",
90
+ status: "warn",
91
+ detail: `Not reachable at ${url} \u2014 mobile sessions need a running Appium daemon. Web sessions are unaffected.`
92
+ };
93
+ }
94
+ }
95
+ function checkXcode() {
96
+ const path = "/Applications/Xcode.app";
97
+ if (existsSync(path)) {
98
+ return { name: "Xcode (iOS, roadmap v0.3)", status: "ok", detail: path };
99
+ }
100
+ return {
101
+ name: "Xcode (iOS, roadmap v0.3)",
102
+ status: "warn",
103
+ detail: "Install Xcode via the App Store; required for iOS simulators. Not needed for web targets."
104
+ };
105
+ }
106
+ function checkAndroidSdk() {
107
+ const candidates = [
108
+ process.env.ANDROID_HOME,
109
+ process.env.ANDROID_SDK_ROOT,
110
+ join(homedir(), "Library", "Android", "sdk"),
111
+ join(homedir(), "Android", "Sdk")
112
+ ].filter((x) => typeof x === "string");
113
+ for (const path of candidates) {
114
+ if (existsSync(path)) {
115
+ return { name: "Android SDK (roadmap v0.3)", status: "ok", detail: path };
116
+ }
117
+ }
118
+ return {
119
+ name: "Android SDK (roadmap v0.3)",
120
+ status: "warn",
121
+ detail: "Set ANDROID_HOME \u2014 needed only for Android testing. Not needed for web or iOS targets."
122
+ };
123
+ }
124
+ function checkArtifactDir() {
125
+ const dir = resolve(process.cwd(), ".rolepod-uiproof");
126
+ return {
127
+ name: "Artifact root writable",
128
+ status: "ok",
129
+ detail: `Will be created at: ${dir}/artifacts/{run_id}/`
130
+ };
131
+ }
132
+ function print(checks) {
133
+ const icon = (s) => s === "ok" ? "\u2713" : s === "warn" ? "\u2022" : "\u2717";
134
+ for (const c of checks) {
135
+ process.stdout.write(` ${icon(c.status)} ${c.name.padEnd(30)} ${c.detail}
136
+ `);
137
+ }
138
+ }
139
+
140
+ // src/cli/install_mobile.ts
141
+ import { platform as osPlatform2 } from "os";
142
+ function runInstallMobile() {
143
+ const os = osPlatform2();
144
+ const lines = [];
145
+ lines.push("rolepod-uiproof install:mobile \u2014 setup checklist\n");
146
+ lines.push("Mobile support is OPTIONAL. Skip if you only target the web.\n");
147
+ lines.push("1. Install the Node client:");
148
+ lines.push(" npm install webdriverio\n");
149
+ lines.push("2. Install the Appium server (2.x):");
150
+ lines.push(" npm install -g appium");
151
+ lines.push(" appium driver install xcuitest # iOS");
152
+ lines.push(" appium driver install uiautomator2 # Android");
153
+ lines.push(" appium # leave running\n");
154
+ if (os === "darwin") {
155
+ lines.push("3. iOS \u2014 macOS host required:");
156
+ lines.push(" \u2022 Install Xcode + Command Line Tools");
157
+ lines.push(" \u2022 Open Xcode \u2192 Settings \u2192 Platforms \u2192 install an iOS Simulator");
158
+ lines.push(" \u2022 `xcrun simctl list devices` confirms a device is available\n");
159
+ } else {
160
+ lines.push("3. iOS: not supported on this OS (macOS host required).\n");
161
+ }
162
+ lines.push("4. Android \u2014 any host:");
163
+ lines.push(" \u2022 Install Android Studio OR command-line tools");
164
+ lines.push(" \u2022 Set ANDROID_HOME to the SDK location");
165
+ lines.push(" \u2022 `adb devices` confirms an emulator or device is reachable\n");
166
+ lines.push("5. Verify:");
167
+ lines.push(" npx rolepod-uiproof doctor\n");
168
+ lines.push("Environment overrides (optional):");
169
+ lines.push(" APPIUM_HOST default: 127.0.0.1");
170
+ lines.push(" APPIUM_PORT default: 4723");
171
+ lines.push(" APPIUM_BASE_PATH default: /");
172
+ for (const l of lines) process.stdout.write(l + "\n");
173
+ return 0;
174
+ }
175
+
176
+ // src/cli/replay.ts
177
+ import { readFile } from "fs/promises";
178
+ import { resolve as resolve3 } from "path";
179
+
180
+ // src/artifact/ArtifactStore.ts
181
+ import { randomUUID } from "crypto";
182
+ import { mkdir, writeFile } from "fs/promises";
183
+ import { resolve as resolve2 } from "path";
184
+
185
+ // src/util/log.ts
186
+ function emit(level, msg, extra) {
187
+ const line = JSON.stringify({
188
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
189
+ level,
190
+ msg,
191
+ ...extra !== void 0 ? { extra } : {}
192
+ });
193
+ process.stderr.write(line + "\n");
194
+ }
195
+ var log = {
196
+ info: (msg, extra) => emit("info", msg, extra),
197
+ warn: (msg, extra) => emit("warn", msg, extra),
198
+ error: (msg, extra) => emit("error", msg, extra),
199
+ debug: (msg, extra) => {
200
+ if (process.env.ROLEPOD_MCP_DEBUG) emit("debug", msg, extra);
201
+ }
202
+ };
203
+
204
+ // src/artifact/ArtifactStore.ts
205
+ var ArtifactStore = class {
206
+ rootDir;
207
+ constructor(opts = {}) {
208
+ this.rootDir = opts.rootDir ?? resolve2(process.cwd(), ".rolepod-uiproof", "artifacts");
209
+ }
210
+ /** Allocate a fresh run id and ensure its directory exists. */
211
+ async startRun(prefix = "run") {
212
+ const runId = `${prefix}_${this.timestampSlug()}_${randomUUID().slice(0, 8)}`;
213
+ const runDir = resolve2(this.rootDir, runId);
214
+ await mkdir(runDir, { recursive: true });
215
+ log.debug("artifact run started", { run_id: runId, dir: runDir });
216
+ return { runId, runDir };
217
+ }
218
+ async writeScreenshot(runDir, buf, name) {
219
+ const path = resolve2(runDir, `${name}.png`);
220
+ await writeFile(path, buf);
221
+ return path;
222
+ }
223
+ async writeReplayBundle(runDir, bundle, name = "replay.json") {
224
+ const path = resolve2(runDir, name);
225
+ await writeFile(path, JSON.stringify(bundle, null, 2), "utf8");
226
+ return path;
227
+ }
228
+ async writeReport(runDir, name, body) {
229
+ const path = resolve2(runDir, name);
230
+ await writeFile(path, body, "utf8");
231
+ return path;
232
+ }
233
+ async writeBytes(runDir, name, buf) {
234
+ const path = resolve2(runDir, name);
235
+ await writeFile(path, buf);
236
+ return path;
237
+ }
238
+ async ensureDir(absDir) {
239
+ await mkdir(absDir, { recursive: true });
240
+ return absDir;
241
+ }
242
+ /** Root for stored visual baselines: `./.rolepod-uiproof/baselines/`. */
243
+ get baselineDir() {
244
+ return resolve2(this.rootDir, "..", "baselines");
245
+ }
246
+ timestampSlug() {
247
+ const d = /* @__PURE__ */ new Date();
248
+ const pad = (n) => String(n).padStart(2, "0");
249
+ return `${d.getUTCFullYear()}` + pad(d.getUTCMonth() + 1) + pad(d.getUTCDate()) + "T" + pad(d.getUTCHours()) + pad(d.getUTCMinutes()) + pad(d.getUTCSeconds());
250
+ }
251
+ };
252
+
253
+ // src/util/errors.ts
254
+ var RolepodMcpError = class extends Error {
255
+ constructor(code, message, detail) {
256
+ super(message);
257
+ this.code = code;
258
+ this.detail = detail;
259
+ }
260
+ code;
261
+ detail;
262
+ name = "RolepodMcpError";
263
+ toJSON() {
264
+ return {
265
+ code: this.code,
266
+ message: this.message,
267
+ ...this.detail ? { detail: this.detail } : {}
268
+ };
269
+ }
270
+ };
271
+ var UnknownRefError = class extends RolepodMcpError {
272
+ constructor(sessionId, ref) {
273
+ super("unknown_ref", `Ref "${ref}" was not found in the current snapshot.`, {
274
+ session_id: sessionId,
275
+ ref
276
+ });
277
+ }
278
+ };
279
+ var UnknownSessionError = class extends RolepodMcpError {
280
+ constructor(sessionId) {
281
+ super("unknown_session", `No open session with id "${sessionId}".`, {
282
+ session_id: sessionId
283
+ });
284
+ }
285
+ };
286
+ var UnsupportedPlatformError = class extends RolepodMcpError {
287
+ constructor(platform) {
288
+ super(
289
+ "unsupported_platform",
290
+ `Platform "${platform}" is not supported in v0.1 \u2014 only "web" is implemented. Mobile (ios/android) ships in v0.3.`,
291
+ { platform }
292
+ );
293
+ }
294
+ };
295
+
296
+ // src/engine/AppiumEngine.ts
297
+ import { randomUUID as randomUUID2 } from "crypto";
298
+
299
+ // src/engine/a11y/uiautomator2.ts
300
+ import { XMLParser } from "fast-xml-parser";
301
+ var parser = new XMLParser({
302
+ ignoreAttributes: false,
303
+ attributeNamePrefix: "@",
304
+ alwaysCreateTextNode: false,
305
+ preserveOrder: true
306
+ });
307
+ function parseUiAutomator2Tree(xmlString) {
308
+ const refIndex = /* @__PURE__ */ new Map();
309
+ let counter = 0;
310
+ const nextRef = () => `e${++counter}`;
311
+ let raw = [];
312
+ try {
313
+ raw = parser.parse(xmlString);
314
+ } catch {
315
+ raw = [];
316
+ }
317
+ const visit = (node, siblingOcc) => {
318
+ const tagName = firstTagName(node);
319
+ if (!tagName) return null;
320
+ const attrs = node[":@"] ?? {};
321
+ const childrenRaw = node[tagName] ?? [];
322
+ const idx = (siblingOcc.get(tagName) ?? 0) + 1;
323
+ siblingOcc.set(tagName, idx);
324
+ const ref = nextRef();
325
+ refIndex.set(ref, {
326
+ kind: "android",
327
+ resourceId: attrs["@resource-id"],
328
+ contentDesc: attrs["@content-desc"],
329
+ text: attrs["@text"],
330
+ androidClass: tagName,
331
+ classIndex: idx
332
+ });
333
+ const role = simplifyAndroidClass(tagName);
334
+ const a11y = { ref, role };
335
+ const name = attrs["@content-desc"] ?? attrs["@text"];
336
+ if (name) a11y.name = name;
337
+ if (attrs["@text"] && attrs["@text"] !== name) a11y.value = attrs["@text"];
338
+ const state = {};
339
+ if (attrs["@enabled"] === "false") state.disabled = true;
340
+ if (attrs["@focused"] === "true") state.focused = true;
341
+ if (attrs["@selected"] === "true") state.selected = true;
342
+ if (Object.keys(state).length > 0) a11y.state = state;
343
+ if (childrenRaw.length > 0) {
344
+ const siblings = /* @__PURE__ */ new Map();
345
+ const children = [];
346
+ for (const child of childrenRaw) {
347
+ const built = visit(child, siblings);
348
+ if (built) children.push(built);
349
+ }
350
+ if (children.length > 0) a11y.children = children;
351
+ }
352
+ return a11y;
353
+ };
354
+ const hierarchy = raw.find((n) => Object.keys(n).some((k) => k === "hierarchy")) ?? raw[0];
355
+ if (!hierarchy) {
356
+ return {
357
+ tree: { ref: "e0", role: "RootWebArea" },
358
+ refIndex
359
+ };
360
+ }
361
+ const top = visit(hierarchy, /* @__PURE__ */ new Map());
362
+ if (top) return { tree: top, refIndex };
363
+ return { tree: { ref: "e0", role: "RootWebArea" }, refIndex };
364
+ }
365
+ function firstTagName(node) {
366
+ for (const key of Object.keys(node)) {
367
+ if (key === ":@") continue;
368
+ if (key.startsWith("?")) continue;
369
+ return key;
370
+ }
371
+ return null;
372
+ }
373
+ function simplifyAndroidClass(cls) {
374
+ const last = cls.split(".").pop();
375
+ return last ?? cls;
376
+ }
377
+
378
+ // src/engine/a11y/xcuitest.ts
379
+ import { XMLParser as XMLParser2 } from "fast-xml-parser";
380
+ var parser2 = new XMLParser2({
381
+ ignoreAttributes: false,
382
+ attributeNamePrefix: "@",
383
+ alwaysCreateTextNode: false,
384
+ preserveOrder: true
385
+ });
386
+ function parseXcuiTestTree(xmlString) {
387
+ const refIndex = /* @__PURE__ */ new Map();
388
+ let counter = 0;
389
+ const nextRef = () => `e${++counter}`;
390
+ let raw = [];
391
+ try {
392
+ raw = parser2.parse(xmlString);
393
+ } catch {
394
+ raw = [];
395
+ }
396
+ const visit = (node, classChainOcc) => {
397
+ const tagName = firstTagName2(node);
398
+ if (!tagName) return null;
399
+ const attrs = node[":@"] ?? {};
400
+ const childrenRaw = node[tagName] ?? [];
401
+ const idx = (classChainOcc.get(tagName) ?? 0) + 1;
402
+ classChainOcc.set(tagName, idx);
403
+ const ref = nextRef();
404
+ refIndex.set(ref, {
405
+ kind: "ios",
406
+ accessibilityId: attrs["@name"],
407
+ name: attrs["@name"],
408
+ label: attrs["@label"],
409
+ type: tagName,
410
+ classChainIndex: idx
411
+ });
412
+ const role = tagName.startsWith("XCUIElementType") ? tagName.slice("XCUIElementType".length).toLowerCase() : tagName;
413
+ const a11y = { ref, role };
414
+ const name = attrs["@label"] ?? attrs["@name"];
415
+ if (name) a11y.name = name;
416
+ if (attrs["@value"]) a11y.value = attrs["@value"];
417
+ const state = {};
418
+ if (attrs["@enabled"] === "false") state.disabled = true;
419
+ if (attrs["@selected"] === "true") state.selected = true;
420
+ if (Object.keys(state).length > 0) a11y.state = state;
421
+ if (childrenRaw.length > 0) {
422
+ const siblings = /* @__PURE__ */ new Map();
423
+ const children = [];
424
+ for (const child of childrenRaw) {
425
+ const built = visit(child, siblings);
426
+ if (built) children.push(built);
427
+ }
428
+ if (children.length > 0) a11y.children = children;
429
+ }
430
+ return a11y;
431
+ };
432
+ const topLevel = [];
433
+ const rootOcc = /* @__PURE__ */ new Map();
434
+ for (const node of raw) {
435
+ const built = visit(node, rootOcc);
436
+ if (built) topLevel.push(built);
437
+ }
438
+ if (topLevel.length === 1) {
439
+ return { tree: topLevel[0], refIndex };
440
+ }
441
+ const rootRef = nextRef();
442
+ refIndex.set(rootRef, {
443
+ kind: "ios",
444
+ type: "XCUIElementTypeApplication",
445
+ classChainIndex: 1
446
+ });
447
+ const root = { ref: rootRef, role: "application" };
448
+ if (topLevel.length > 0) root.children = topLevel;
449
+ return { tree: root, refIndex };
450
+ }
451
+ function firstTagName2(node) {
452
+ for (const key of Object.keys(node)) {
453
+ if (key === ":@") continue;
454
+ if (key.startsWith("?")) continue;
455
+ return key;
456
+ }
457
+ return null;
458
+ }
459
+
460
+ // src/engine/AppiumEngine.ts
461
+ var AppiumEngine = class {
462
+ id = "appium";
463
+ sessions = /* @__PURE__ */ new Map();
464
+ wdioCache = null;
465
+ async open(opts) {
466
+ if (opts.platform !== "ios" && opts.platform !== "android") {
467
+ throw new UnsupportedPlatformError(opts.platform);
468
+ }
469
+ const remote = await this.loadWdio();
470
+ const caps = this.buildCapabilities(opts);
471
+ const driver = await remote({
472
+ hostname: process.env.APPIUM_HOST ?? "127.0.0.1",
473
+ port: Number(process.env.APPIUM_PORT ?? 4723),
474
+ path: process.env.APPIUM_BASE_PATH ?? "/",
475
+ capabilities: caps
476
+ });
477
+ const sessionId = randomUUID2();
478
+ const session = { id: sessionId, platform: opts.platform, driver };
479
+ this.sessions.set(sessionId, {
480
+ session,
481
+ refIndex: /* @__PURE__ */ new Map(),
482
+ snapshotGeneration: 0,
483
+ refGeneration: -1,
484
+ lastSnapshotAt: null
485
+ });
486
+ log.info("mobile session opened", {
487
+ session_id: sessionId,
488
+ platform: opts.platform,
489
+ remote_session: driver.sessionId
490
+ });
491
+ return { id: sessionId, platform: opts.platform };
492
+ }
493
+ async close(session) {
494
+ const s = this.requireSession(session.id);
495
+ await s.session.driver.deleteSession().catch(
496
+ (err) => log.warn("appium deleteSession failed", { session_id: session.id, err: String(err) })
497
+ );
498
+ this.sessions.delete(session.id);
499
+ log.info("mobile session closed", { session_id: session.id });
500
+ }
501
+ async snapshot(session, _mode) {
502
+ void _mode;
503
+ const s = this.requireSession(session.id);
504
+ const xml = await s.session.driver.getPageSource();
505
+ const normalized = s.session.platform === "ios" ? parseXcuiTestTree(xml) : parseUiAutomator2Tree(xml);
506
+ s.snapshotGeneration += 1;
507
+ s.refGeneration = s.snapshotGeneration;
508
+ s.refIndex = normalized.refIndex;
509
+ s.lastSnapshotAt = (/* @__PURE__ */ new Date()).toISOString();
510
+ return {
511
+ session_id: session.id,
512
+ platform: s.session.platform,
513
+ url_or_screen: this.screenIdentifier(s),
514
+ taken_at: s.lastSnapshotAt,
515
+ tree: normalized.tree
516
+ };
517
+ }
518
+ async click(session, ref) {
519
+ const s = this.requireSession(session.id);
520
+ const el = await this.resolveElement(s, ref);
521
+ await el.click();
522
+ this.invalidateRefs(s);
523
+ }
524
+ async type(session, ref, text, opts) {
525
+ const s = this.requireSession(session.id);
526
+ const el = await this.resolveElement(s, ref);
527
+ if (opts?.clearFirst) await el.clearValue();
528
+ await el.setValue(text);
529
+ this.invalidateRefs(s);
530
+ }
531
+ async key(session, key) {
532
+ const s = this.requireSession(session.id);
533
+ if (s.session.platform === "android" && s.session.driver.pressKeyCode) {
534
+ const code = ANDROID_KEY_CODES[key];
535
+ if (code !== void 0) {
536
+ await s.session.driver.pressKeyCode(code);
537
+ this.invalidateRefs(s);
538
+ return;
539
+ }
540
+ }
541
+ throw new RolepodMcpError(
542
+ "not_implemented_in_v02",
543
+ `Mobile key("${key}") is partially supported in v0.3 \u2014 only well-known Android keycodes are mapped. iOS hardware keys land later.`,
544
+ { platform: s.session.platform, key }
545
+ );
546
+ }
547
+ async scroll(session, dir, amount = 400, _ref) {
548
+ void _ref;
549
+ const s = this.requireSession(session.id);
550
+ const action = s.session.platform === "ios" ? "mobile: swipe" : "mobile: scrollGesture";
551
+ const params = s.session.platform === "ios" ? { direction: dir } : { left: 100, top: 200, width: 400, height: 600, direction: dir, percent: amount / 1e3 };
552
+ await s.session.driver.execute(action, params).catch((err) => log.warn("scroll gesture failed", { err: String(err) }));
553
+ this.invalidateRefs(s);
554
+ }
555
+ async waitFor(session, cond, timeoutMs = 1e4) {
556
+ const s = this.requireSession(session.id);
557
+ const deadline = Date.now() + timeoutMs;
558
+ while (Date.now() < deadline) {
559
+ if (cond.kind === "idle") {
560
+ await s.session.driver.pause(cond.ms);
561
+ this.invalidateRefs(s);
562
+ return;
563
+ }
564
+ const snap = await this.snapshot(session);
565
+ const matched = cond.kind === "text_visible" ? treeIncludesText(snap.tree, cond.text) : cond.kind === "ref_exists" ? treeIncludesText(snap.tree, cond.query) : false;
566
+ if (matched) return;
567
+ await s.session.driver.pause(250);
568
+ }
569
+ throw new RolepodMcpError(
570
+ "engine_error",
571
+ `wait_for ${cond.kind} timed out after ${timeoutMs}ms`,
572
+ { condition: cond, timeout_ms: timeoutMs }
573
+ );
574
+ }
575
+ async screenshot(session, _fullPage) {
576
+ void _fullPage;
577
+ const s = this.requireSession(session.id);
578
+ const b64 = await s.session.driver.takeScreenshot();
579
+ return Buffer.from(b64, "base64");
580
+ }
581
+ async navigate(_session, _url) {
582
+ throw new UnsupportedPlatformError(_session.platform);
583
+ }
584
+ // -------------------------------------------------------------------------
585
+ // Internals
586
+ // -------------------------------------------------------------------------
587
+ async loadWdio() {
588
+ if (this.wdioCache) return this.wdioCache;
589
+ try {
590
+ const mod = await import(
591
+ /* @vite-ignore */
592
+ "webdriverio"
593
+ );
594
+ this.wdioCache = mod.remote;
595
+ return mod.remote;
596
+ } catch {
597
+ throw new RolepodMcpError(
598
+ "engine_error",
599
+ "Mobile support needs webdriverio (and a running Appium server). Run `npx rolepod-uiproof install:mobile` for the setup checklist."
600
+ );
601
+ }
602
+ }
603
+ buildCapabilities(opts) {
604
+ const caps = {};
605
+ if (opts.platform === "ios") {
606
+ caps.platformName = "iOS";
607
+ caps["appium:automationName"] = "XCUITest";
608
+ if (opts.device) caps["appium:deviceName"] = opts.device;
609
+ if (opts.bundle_id) caps["appium:bundleId"] = opts.bundle_id;
610
+ } else {
611
+ caps.platformName = "Android";
612
+ caps["appium:automationName"] = "UiAutomator2";
613
+ if (opts.emulator) caps["appium:avd"] = opts.emulator;
614
+ if (opts.app_package) caps["appium:appPackage"] = opts.app_package;
615
+ if (opts.app_activity) caps["appium:appActivity"] = opts.app_activity;
616
+ }
617
+ if (opts.locale) caps["appium:language"] = opts.locale;
618
+ return caps;
619
+ }
620
+ screenIdentifier(s) {
621
+ const caps = s.session.driver.capabilities;
622
+ return String(
623
+ caps["appium:bundleId"] ?? caps["appium:appPackage"] ?? caps.platformName ?? s.session.platform
624
+ );
625
+ }
626
+ requireSession(sessionId) {
627
+ const s = this.sessions.get(sessionId);
628
+ if (!s) throw new UnknownSessionError(sessionId);
629
+ return s;
630
+ }
631
+ async resolveElement(s, ref) {
632
+ if (s.refGeneration !== s.snapshotGeneration) {
633
+ throw new RolepodMcpError(
634
+ "stale_ref",
635
+ `Ref "${ref}" is stale \u2014 re-snapshot before retrying.`,
636
+ {
637
+ session_id: s.session.id,
638
+ ref,
639
+ last_valid_snapshot_at: s.lastSnapshotAt
640
+ }
641
+ );
642
+ }
643
+ const meta = s.refIndex.get(ref);
644
+ if (!meta) throw new UnknownRefError(s.session.id, ref);
645
+ const selector = this.toSelector(meta);
646
+ return s.session.driver.$(selector);
647
+ }
648
+ toSelector(meta) {
649
+ if (meta.kind === "ios") {
650
+ if (meta.accessibilityId) return `~${meta.accessibilityId}`;
651
+ const chain = `**/${meta.type}[${meta.classChainIndex}]`;
652
+ return `-ios class chain:${chain}`;
653
+ }
654
+ if (meta.resourceId) {
655
+ return `-android uiautomator:new UiSelector().resourceId("${escape(meta.resourceId)}")`;
656
+ }
657
+ if (meta.contentDesc) return `~${meta.contentDesc}`;
658
+ if (meta.text) {
659
+ return `-android uiautomator:new UiSelector().text("${escape(meta.text)}")`;
660
+ }
661
+ return `-android uiautomator:new UiSelector().className("${meta.androidClass}").instance(${meta.classIndex - 1})`;
662
+ }
663
+ invalidateRefs(s) {
664
+ s.snapshotGeneration += 1;
665
+ }
666
+ };
667
+ var ANDROID_KEY_CODES = {
668
+ Enter: 66,
669
+ Tab: 61,
670
+ Escape: 111,
671
+ Back: 4,
672
+ Home: 3,
673
+ Menu: 82,
674
+ Search: 84,
675
+ Backspace: 67,
676
+ ArrowUp: 19,
677
+ ArrowDown: 20,
678
+ ArrowLeft: 21,
679
+ ArrowRight: 22
680
+ };
681
+ function escape(s) {
682
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
683
+ }
684
+ function treeIncludesText(node, text) {
685
+ const target = text.toLowerCase();
686
+ const visit = (n) => {
687
+ if (n.name && n.name.toLowerCase().includes(target) || n.value && n.value.toLowerCase().includes(target)) return true;
688
+ if (!n.children) return false;
689
+ for (const c of n.children) {
690
+ if (visit(c)) return true;
691
+ }
692
+ return false;
693
+ };
694
+ return visit(node);
695
+ }
696
+
697
+ // src/engine/PlaywrightEngine.ts
698
+ import { randomUUID as randomUUID3 } from "crypto";
699
+ import {
700
+ chromium,
701
+ firefox,
702
+ webkit
703
+ } from "playwright";
704
+
705
+ // src/engine/a11y/normalize.ts
706
+ import yaml from "js-yaml";
707
+ var KEY_RE = /^(?<role>\S+?)(?:\s+"(?<name>(?:[^"\\]|\\.)*)")?(?<rest>(?:\s+\[[^\]]+\])*)\s*$/;
708
+ var ATTR_RE = /\[([^=\]]+)(?:=([^\]]+))?\]/g;
709
+ function parseKey(key) {
710
+ const trimmed = key.trim();
711
+ const m = KEY_RE.exec(trimmed);
712
+ if (!m) {
713
+ return { role: trimmed, attrs: {} };
714
+ }
715
+ const groups = m.groups;
716
+ const out = { role: groups.role, attrs: {} };
717
+ if (groups.name !== void 0) out.name = unescapeQuoted(groups.name);
718
+ const rest = groups.rest ?? "";
719
+ for (const attrMatch of rest.matchAll(ATTR_RE)) {
720
+ const [, k, v] = attrMatch;
721
+ if (k) out.attrs[k] = v ?? "true";
722
+ }
723
+ return out;
724
+ }
725
+ function unescapeQuoted(s) {
726
+ return s.replace(/\\(.)/g, "$1");
727
+ }
728
+ function parseAriaSnapshot(snapshotYaml) {
729
+ const refIndex = /* @__PURE__ */ new Map();
730
+ let synthCounter = 0;
731
+ const nextSynthRef = () => `s${++synthCounter}`;
732
+ const consumeNode = (value) => {
733
+ if (value && typeof value === "object" && !Array.isArray(value)) {
734
+ const keys = Object.keys(value);
735
+ if (keys.length !== 1) return null;
736
+ const key = keys[0];
737
+ if (key.startsWith("/")) return null;
738
+ const inner = value[key];
739
+ if (typeof inner === "string") return buildNode(key, null, inner);
740
+ if (Array.isArray(inner)) return buildNode(key, inner, void 0);
741
+ return buildNode(key, null, void 0);
742
+ }
743
+ if (typeof value === "string") {
744
+ return buildNode(value, null, void 0);
745
+ }
746
+ return null;
747
+ };
748
+ const buildNode = (key, rawChildren, inlineText) => {
749
+ const parsed = parseKey(key);
750
+ const ref = parsed.attrs.ref ?? nextSynthRef();
751
+ refIndex.set(ref, { kind: "web", ref });
752
+ const node = {
753
+ ref,
754
+ role: parsed.role
755
+ };
756
+ if (parsed.name !== void 0) node.name = parsed.name;
757
+ if (inlineText !== void 0) {
758
+ node.value = inlineText;
759
+ } else if (parsed.attrs.level) {
760
+ node.value = parsed.attrs.level;
761
+ }
762
+ const state = {};
763
+ if ("disabled" in parsed.attrs) state.disabled = parsed.attrs.disabled !== "false";
764
+ if ("focused" in parsed.attrs) state.focused = parsed.attrs.focused !== "false";
765
+ if ("selected" in parsed.attrs) state.selected = parsed.attrs.selected !== "false";
766
+ if ("expanded" in parsed.attrs) state.expanded = parsed.attrs.expanded !== "false";
767
+ if (Object.keys(state).length > 0) node.state = state;
768
+ if (Array.isArray(rawChildren)) {
769
+ const children = [];
770
+ for (const child of rawChildren) {
771
+ const built = consumeNode(child);
772
+ if (built) children.push(built);
773
+ }
774
+ if (children.length > 0) node.children = children;
775
+ }
776
+ return node;
777
+ };
778
+ let loaded;
779
+ try {
780
+ loaded = yaml.load(snapshotYaml) ?? [];
781
+ } catch {
782
+ loaded = [];
783
+ }
784
+ const topLevel = [];
785
+ if (Array.isArray(loaded)) {
786
+ for (const entry of loaded) {
787
+ const node = consumeNode(entry);
788
+ if (node) topLevel.push(node);
789
+ }
790
+ } else if (loaded && typeof loaded === "object") {
791
+ const node = consumeNode(loaded);
792
+ if (node) topLevel.push(node);
793
+ } else if (typeof loaded === "string" && loaded.length > 0) {
794
+ const node = consumeNode(loaded);
795
+ if (node) topLevel.push(node);
796
+ }
797
+ if (topLevel.length === 1) {
798
+ return { tree: topLevel[0], refIndex };
799
+ }
800
+ const rootRef = nextSynthRef();
801
+ refIndex.set(rootRef, { kind: "web", ref: rootRef });
802
+ const root = { ref: rootRef, role: "RootWebArea" };
803
+ if (topLevel.length > 0) root.children = topLevel;
804
+ return { tree: root, refIndex };
805
+ }
806
+
807
+ // src/engine/PlaywrightEngine.ts
808
+ var PlaywrightEngine = class {
809
+ id = "playwright";
810
+ sessions = /* @__PURE__ */ new Map();
811
+ async open(opts) {
812
+ if (opts.platform !== "web") {
813
+ throw new UnsupportedPlatformError(opts.platform);
814
+ }
815
+ const browserName = opts.browser ?? "chromium";
816
+ const launcher = browserName === "firefox" ? firefox : browserName === "webkit" ? webkit : chromium;
817
+ const headless = opts.headless ?? (process.env.CI ? true : false);
818
+ const browser = await launcher.launch({ headless });
819
+ const contextOptions = {};
820
+ if (opts.viewport) contextOptions.viewport = opts.viewport;
821
+ if (opts.user_agent) contextOptions.userAgent = opts.user_agent;
822
+ if (opts.locale) contextOptions.locale = opts.locale;
823
+ const context = await browser.newContext(contextOptions);
824
+ const page = await context.newPage();
825
+ if (opts.url) {
826
+ await page.goto(opts.url, { waitUntil: "domcontentloaded" });
827
+ }
828
+ const sessionId = randomUUID3();
829
+ const session = {
830
+ id: sessionId,
831
+ platform: "web",
832
+ browser,
833
+ context,
834
+ page
835
+ };
836
+ this.sessions.set(sessionId, {
837
+ session,
838
+ refIndex: /* @__PURE__ */ new Map(),
839
+ snapshotGeneration: 0,
840
+ refGeneration: -1,
841
+ lastSnapshotAt: null
842
+ });
843
+ log.info("session opened", {
844
+ session_id: sessionId,
845
+ browser: browserName,
846
+ url: opts.url ?? null
847
+ });
848
+ return { id: sessionId, platform: "web" };
849
+ }
850
+ async close(session) {
851
+ const s = this.requireSession(session.id);
852
+ await s.session.context.close().catch((err) => {
853
+ log.warn("context close failed", { session_id: session.id, err: String(err) });
854
+ });
855
+ await s.session.browser.close().catch((err) => {
856
+ log.warn("browser close failed", { session_id: session.id, err: String(err) });
857
+ });
858
+ this.sessions.delete(session.id);
859
+ log.info("session closed", { session_id: session.id });
860
+ }
861
+ async snapshot(session, mode = "visible") {
862
+ const s = this.requireSession(session.id);
863
+ const ariaYaml = await s.session.page.ariaSnapshot({ mode: "ai" });
864
+ const { tree, refIndex } = parseAriaSnapshot(ariaYaml);
865
+ void mode;
866
+ s.snapshotGeneration += 1;
867
+ s.refGeneration = s.snapshotGeneration;
868
+ s.refIndex = refIndex;
869
+ s.lastSnapshotAt = (/* @__PURE__ */ new Date()).toISOString();
870
+ return {
871
+ session_id: session.id,
872
+ platform: "web",
873
+ url_or_screen: s.session.page.url(),
874
+ taken_at: s.lastSnapshotAt,
875
+ tree
876
+ };
877
+ }
878
+ async click(session, ref, opts) {
879
+ const s = this.requireSession(session.id);
880
+ const locator = this.resolveLocator(s, ref);
881
+ await locator.click(opts?.button ? { button: opts.button } : void 0);
882
+ this.invalidateRefs(s);
883
+ }
884
+ async type(session, ref, text, opts) {
885
+ const s = this.requireSession(session.id);
886
+ const locator = this.resolveLocator(s, ref);
887
+ if (opts?.clearFirst) await locator.fill("");
888
+ await locator.fill(text);
889
+ this.invalidateRefs(s);
890
+ }
891
+ async key(session, key) {
892
+ const s = this.requireSession(session.id);
893
+ await s.session.page.keyboard.press(key);
894
+ this.invalidateRefs(s);
895
+ }
896
+ async scroll(session, dir, amount = 400, ref) {
897
+ const s = this.requireSession(session.id);
898
+ const dx = dir === "left" ? -amount : dir === "right" ? amount : 0;
899
+ const dy = dir === "up" ? -amount : dir === "down" ? amount : 0;
900
+ if (ref) {
901
+ const locator = this.resolveLocator(s, ref);
902
+ await locator.evaluate(
903
+ (el, [x, y]) => el.scrollBy(x, y),
904
+ [dx, dy]
905
+ );
906
+ } else {
907
+ await s.session.page.mouse.wheel(dx, dy);
908
+ }
909
+ this.invalidateRefs(s);
910
+ }
911
+ async waitFor(session, cond, timeoutMs = 1e4) {
912
+ const s = this.requireSession(session.id);
913
+ const page = s.session.page;
914
+ switch (cond.kind) {
915
+ case "text_visible":
916
+ await page.getByText(cond.text, { exact: false }).first().waitFor({ state: "visible", timeout: timeoutMs });
917
+ break;
918
+ case "ref_exists":
919
+ await page.getByRole("button", { name: cond.query }).first().waitFor({ state: "attached", timeout: timeoutMs });
920
+ break;
921
+ case "url_matches":
922
+ await page.waitForURL(new RegExp(cond.pattern), { timeout: timeoutMs });
923
+ break;
924
+ case "idle":
925
+ await page.waitForLoadState("networkidle", { timeout: timeoutMs });
926
+ await page.waitForTimeout(cond.ms);
927
+ break;
928
+ }
929
+ this.invalidateRefs(s);
930
+ }
931
+ async screenshot(session, fullPage = false) {
932
+ const s = this.requireSession(session.id);
933
+ return s.session.page.screenshot({ fullPage });
934
+ }
935
+ async navigate(session, url) {
936
+ const s = this.requireSession(session.id);
937
+ if (s.session.platform !== "web") {
938
+ throw new UnsupportedPlatformError(s.session.platform);
939
+ }
940
+ await s.session.page.goto(url, { waitUntil: "domcontentloaded" });
941
+ this.invalidateRefs(s);
942
+ }
943
+ /**
944
+ * Composite-only escape hatch — exposes the raw Playwright Page so a
945
+ * composite tool that genuinely needs page-level APIs (axe-core,
946
+ * `getByText`, etc.) can use them without bloating the Engine interface
947
+ * with web-specific verbs. Throws if the session is not web.
948
+ */
949
+ getPageForSession(sessionId) {
950
+ const s = this.requireSession(sessionId);
951
+ if (s.session.platform !== "web") {
952
+ throw new UnsupportedPlatformError(s.session.platform);
953
+ }
954
+ return s.session.page;
955
+ }
956
+ /** Increment generation; the next ref-using call will see them as stale. */
957
+ bumpGeneration(sessionId) {
958
+ const s = this.requireSession(sessionId);
959
+ this.invalidateRefs(s);
960
+ }
961
+ // -------------------------------------------------------------------------
962
+ // Internal helpers
963
+ // -------------------------------------------------------------------------
964
+ requireSession(sessionId) {
965
+ const s = this.sessions.get(sessionId);
966
+ if (!s) {
967
+ throw new RolepodMcpError(
968
+ "unknown_session",
969
+ `No open session with id "${sessionId}".`,
970
+ { session_id: sessionId }
971
+ );
972
+ }
973
+ return s;
974
+ }
975
+ resolveLocator(s, ref) {
976
+ if (s.refGeneration !== s.snapshotGeneration) {
977
+ throw new RolepodMcpError(
978
+ "stale_ref",
979
+ `Ref "${ref}" is stale \u2014 re-snapshot before retrying.`,
980
+ {
981
+ session_id: s.session.id,
982
+ ref,
983
+ last_valid_snapshot_at: s.lastSnapshotAt
984
+ }
985
+ );
986
+ }
987
+ const meta = s.refIndex.get(ref);
988
+ if (!meta) throw new UnknownRefError(s.session.id, ref);
989
+ if (meta.ref.startsWith("s")) {
990
+ throw new UnknownRefError(s.session.id, ref);
991
+ }
992
+ return s.session.page.locator(`aria-ref=${meta.ref}`);
993
+ }
994
+ invalidateRefs(s) {
995
+ s.snapshotGeneration += 1;
996
+ }
997
+ /**
998
+ * Test / shutdown helper. Closes every open session.
999
+ */
1000
+ async shutdown() {
1001
+ const ids = [...this.sessions.keys()];
1002
+ await Promise.all(
1003
+ ids.map((id) => this.close({ id, platform: "web" }).catch(() => void 0))
1004
+ );
1005
+ }
1006
+ };
1007
+
1008
+ // src/engine/factory.ts
1009
+ function createWebEngine() {
1010
+ const choice = (process.env.ROLEPOD_MCP_WEB_ENGINE ?? "playwright").toLowerCase();
1011
+ switch (choice) {
1012
+ case "playwright":
1013
+ return new PlaywrightEngine();
1014
+ case "selenium":
1015
+ throw new RolepodMcpError(
1016
+ "unsupported_engine",
1017
+ "SeleniumEngine ships in v0.4 \u2014 set ROLEPOD_MCP_WEB_ENGINE=playwright until then.",
1018
+ { requested: choice }
1019
+ );
1020
+ default:
1021
+ throw new RolepodMcpError(
1022
+ "unsupported_engine",
1023
+ `Unknown web engine "${choice}" \u2014 supported: playwright.`,
1024
+ { requested: choice }
1025
+ );
1026
+ }
1027
+ }
1028
+ function createMobileEngine() {
1029
+ return new AppiumEngine();
1030
+ }
1031
+
1032
+ // src/session/SessionRegistry.ts
1033
+ var SessionRegistry = class {
1034
+ enginesByPlatform = /* @__PURE__ */ new Map();
1035
+ engineBySession = /* @__PURE__ */ new Map();
1036
+ platformBySession = /* @__PURE__ */ new Map();
1037
+ lastActivity = /* @__PURE__ */ new Map();
1038
+ idleTimeoutMs;
1039
+ idleTimer = null;
1040
+ constructor(opts = {}) {
1041
+ this.idleTimeoutMs = opts.idleTimeoutMs ?? 5 * 60 * 1e3;
1042
+ }
1043
+ /** Register an engine as the handler for a given platform. */
1044
+ register(platform, engine) {
1045
+ this.enginesByPlatform.set(platform, engine);
1046
+ }
1047
+ async open(opts) {
1048
+ const engine = this.enginesByPlatform.get(opts.platform);
1049
+ if (!engine) throw new UnsupportedPlatformError(opts.platform);
1050
+ const session = await engine.open(opts);
1051
+ this.engineBySession.set(session.id, engine);
1052
+ this.platformBySession.set(session.id, session.platform);
1053
+ this.touch(session.id);
1054
+ this.ensureIdleSweep();
1055
+ return session;
1056
+ }
1057
+ engineFor(sessionId) {
1058
+ const engine = this.engineBySession.get(sessionId);
1059
+ if (!engine) throw new UnknownSessionError(sessionId);
1060
+ this.touch(sessionId);
1061
+ return engine;
1062
+ }
1063
+ async close(session) {
1064
+ const engine = this.engineBySession.get(session.id);
1065
+ if (!engine) throw new UnknownSessionError(session.id);
1066
+ await engine.close(session);
1067
+ this.engineBySession.delete(session.id);
1068
+ this.platformBySession.delete(session.id);
1069
+ this.lastActivity.delete(session.id);
1070
+ }
1071
+ /** Look up the platform recorded for an open session. */
1072
+ platformOf(sessionId) {
1073
+ const platform = this.platformBySession.get(sessionId);
1074
+ if (!platform) throw new UnknownSessionError(sessionId);
1075
+ return platform;
1076
+ }
1077
+ async shutdown() {
1078
+ if (this.idleTimer) {
1079
+ clearInterval(this.idleTimer);
1080
+ this.idleTimer = null;
1081
+ }
1082
+ const closes = [];
1083
+ for (const [sessionId, engine] of this.engineBySession) {
1084
+ const platform = this.platformFor(sessionId);
1085
+ closes.push(
1086
+ engine.close({ id: sessionId, platform }).catch(
1087
+ (err) => log.warn("shutdown close failed", { sessionId, err: String(err) })
1088
+ )
1089
+ );
1090
+ }
1091
+ await Promise.all(closes);
1092
+ this.engineBySession.clear();
1093
+ this.platformBySession.clear();
1094
+ this.lastActivity.clear();
1095
+ }
1096
+ touch(sessionId) {
1097
+ this.lastActivity.set(sessionId, Date.now());
1098
+ }
1099
+ platformFor(sessionId) {
1100
+ return this.platformBySession.get(sessionId) ?? "web";
1101
+ }
1102
+ ensureIdleSweep() {
1103
+ if (this.idleTimer || this.idleTimeoutMs <= 0) return;
1104
+ const interval = Math.max(3e4, Math.floor(this.idleTimeoutMs / 4));
1105
+ this.idleTimer = setInterval(() => {
1106
+ const cutoff = Date.now() - this.idleTimeoutMs;
1107
+ for (const [sessionId, lastSeen] of this.lastActivity) {
1108
+ if (lastSeen < cutoff) {
1109
+ const engine = this.engineBySession.get(sessionId);
1110
+ if (!engine) {
1111
+ this.lastActivity.delete(sessionId);
1112
+ continue;
1113
+ }
1114
+ log.info("idle session sweep \u2014 closing", { sessionId });
1115
+ engine.close({ id: sessionId, platform: this.platformFor(sessionId) }).catch(
1116
+ (err) => log.warn("idle close failed", { sessionId, err: String(err) })
1117
+ ).finally(() => {
1118
+ this.engineBySession.delete(sessionId);
1119
+ this.lastActivity.delete(sessionId);
1120
+ });
1121
+ }
1122
+ }
1123
+ }, interval);
1124
+ this.idleTimer.unref();
1125
+ }
1126
+ };
1127
+
1128
+ // src/schema/tools.ts
1129
+ import { z } from "zod";
1130
+ var platformSchema = z.enum(["web", "ios", "android"]);
1131
+ var browserSchema = z.enum(["chromium", "firefox", "webkit"]);
1132
+ var viewportSchema = z.object({
1133
+ width: z.number().int().positive(),
1134
+ height: z.number().int().positive()
1135
+ });
1136
+ var bboxSchema = z.object({
1137
+ x: z.number(),
1138
+ y: z.number(),
1139
+ w: z.number(),
1140
+ h: z.number()
1141
+ });
1142
+ var a11yStateSchema = z.object({
1143
+ focused: z.boolean().optional(),
1144
+ selected: z.boolean().optional(),
1145
+ expanded: z.boolean().optional(),
1146
+ disabled: z.boolean().optional()
1147
+ });
1148
+ var a11yNodeSchema = z.lazy(
1149
+ () => z.object({
1150
+ ref: z.string(),
1151
+ role: z.string(),
1152
+ name: z.string().optional(),
1153
+ value: z.string().optional(),
1154
+ state: a11yStateSchema.optional(),
1155
+ bbox: bboxSchema.optional(),
1156
+ children: z.array(a11yNodeSchema).optional()
1157
+ })
1158
+ );
1159
+ var waitConditionSchema = z.discriminatedUnion("kind", [
1160
+ z.object({ kind: z.literal("text_visible"), text: z.string() }),
1161
+ z.object({ kind: z.literal("ref_exists"), query: z.string() }),
1162
+ z.object({ kind: z.literal("url_matches"), pattern: z.string() }),
1163
+ z.object({ kind: z.literal("idle"), ms: z.number().int().positive() })
1164
+ ]);
1165
+ var browserOpenShape = {
1166
+ platform: platformSchema.default("web"),
1167
+ url: z.string().url().optional(),
1168
+ browser: browserSchema.optional(),
1169
+ viewport: viewportSchema.optional(),
1170
+ // mobile fields kept for forward compat; v0.1 only handles platform='web'
1171
+ bundle_id: z.string().optional(),
1172
+ device: z.string().optional(),
1173
+ app_package: z.string().optional(),
1174
+ app_activity: z.string().optional(),
1175
+ emulator: z.string().optional(),
1176
+ headless: z.boolean().optional(),
1177
+ user_agent: z.string().optional(),
1178
+ locale: z.string().optional()
1179
+ };
1180
+ var browserOpenSchema = z.object(browserOpenShape);
1181
+ var browserCloseShape = {
1182
+ session_id: z.string().min(1)
1183
+ };
1184
+ var browserCloseSchema = z.object(browserCloseShape);
1185
+ var browserSnapshotShape = {
1186
+ session_id: z.string().min(1),
1187
+ mode: z.enum(["visible", "full"]).optional()
1188
+ };
1189
+ var browserSnapshotSchema = z.object(browserSnapshotShape);
1190
+ var browserClickShape = {
1191
+ session_id: z.string().min(1),
1192
+ ref: z.string().min(1),
1193
+ button: z.enum(["left", "right", "middle"]).optional()
1194
+ };
1195
+ var browserClickSchema = z.object(browserClickShape);
1196
+ var browserTypeShape = {
1197
+ session_id: z.string().min(1),
1198
+ ref: z.string().min(1),
1199
+ text: z.string(),
1200
+ clear_first: z.boolean().optional()
1201
+ };
1202
+ var browserTypeSchema = z.object(browserTypeShape);
1203
+ var browserKeyShape = {
1204
+ session_id: z.string().min(1),
1205
+ key: z.string().min(1)
1206
+ };
1207
+ var browserKeySchema = z.object(browserKeyShape);
1208
+ var browserScrollShape = {
1209
+ session_id: z.string().min(1),
1210
+ direction: z.enum(["up", "down", "left", "right"]),
1211
+ amount: z.number().int().positive().optional(),
1212
+ ref: z.string().min(1).optional()
1213
+ };
1214
+ var browserScrollSchema = z.object(browserScrollShape);
1215
+ var browserWaitForShape = {
1216
+ session_id: z.string().min(1),
1217
+ condition: waitConditionSchema,
1218
+ timeout_ms: z.number().int().positive().optional()
1219
+ };
1220
+ var browserWaitForSchema = z.object(browserWaitForShape);
1221
+ var browserScreenshotShape = {
1222
+ session_id: z.string().min(1),
1223
+ full_page: z.boolean().optional()
1224
+ };
1225
+ var browserScreenshotSchema = z.object(browserScreenshotShape);
1226
+ var browserNavigateShape = {
1227
+ session_id: z.string().min(1),
1228
+ url: z.string().url()
1229
+ };
1230
+ var browserNavigateSchema = z.object(browserNavigateShape);
1231
+ var verifyStepSchema = z.discriminatedUnion("kind", [
1232
+ z.object({ kind: z.literal("click"), query: z.string() }),
1233
+ z.object({
1234
+ kind: z.literal("type"),
1235
+ query: z.string(),
1236
+ text: z.string(),
1237
+ clear_first: z.boolean().optional()
1238
+ }),
1239
+ z.object({ kind: z.literal("key"), key: z.string() }),
1240
+ z.object({ kind: z.literal("wait_for"), condition: waitConditionSchema }),
1241
+ z.object({ kind: z.literal("navigate"), url: z.string().url() })
1242
+ ]);
1243
+ var verifyExpectSchema = z.discriminatedUnion("kind", [
1244
+ z.object({ kind: z.literal("text_visible"), text: z.string() }),
1245
+ z.object({ kind: z.literal("text_absent"), text: z.string() }),
1246
+ z.object({ kind: z.literal("url_matches"), pattern: z.string() }),
1247
+ z.object({
1248
+ kind: z.literal("ref_in_state"),
1249
+ query: z.string(),
1250
+ state: z.enum(["visible", "enabled", "focused"])
1251
+ })
1252
+ ]);
1253
+ var captureKindSchema = z.enum([
1254
+ "screenshot",
1255
+ "har",
1256
+ "console",
1257
+ "a11y_tree",
1258
+ "video"
1259
+ ]);
1260
+ var verifyUiFlowShape = {
1261
+ mode: z.enum(["assert", "reproduce"]).default("assert"),
1262
+ open: browserOpenSchema,
1263
+ steps: z.array(verifyStepSchema).default([]),
1264
+ expect: z.array(verifyExpectSchema).default([]),
1265
+ capture: z.array(captureKindSchema).optional(),
1266
+ close_on_finish: z.boolean().default(true),
1267
+ /**
1268
+ * Only consulted when `mode='reproduce'`. When true (default) and the
1269
+ * initial run reproduces the bug, the composite tries to remove each
1270
+ * step in turn and re-runs to find a smaller reproducer.
1271
+ */
1272
+ minimize: z.boolean().default(true)
1273
+ };
1274
+ var verifyUiFlowSchema = z.object(verifyUiFlowShape);
1275
+ var wcagLevelSchema = z.enum(["wcag-a", "wcag-aa", "wcag-aaa"]);
1276
+ var a11ySeveritySchema = z.enum([
1277
+ "critical",
1278
+ "serious",
1279
+ "moderate",
1280
+ "minor"
1281
+ ]);
1282
+ var auditScopeSchema = z.union([
1283
+ z.literal("page"),
1284
+ z.object({ ref: z.string().min(1) })
1285
+ ]);
1286
+ var auditA11yShape = {
1287
+ open: browserOpenSchema,
1288
+ level: wcagLevelSchema.default("wcag-aa"),
1289
+ scope: auditScopeSchema.default("page"),
1290
+ report_format: z.enum(["json", "markdown"]).default("json"),
1291
+ close_on_finish: z.boolean().default(true)
1292
+ };
1293
+ var auditA11ySchema = z.object(auditA11yShape);
1294
+ var visualDiffShape = {
1295
+ open: browserOpenSchema,
1296
+ baseline_id: z.string().min(1),
1297
+ viewport: viewportSchema.optional(),
1298
+ threshold_pct: z.number().min(0).max(1).default(0.1),
1299
+ close_on_finish: z.boolean().default(true),
1300
+ /** Pixel sensitivity for pixelmatch (0 = strict, 1 = lax). Default 0.1. */
1301
+ pixel_threshold: z.number().min(0).max(1).default(0.1)
1302
+ };
1303
+ var visualDiffSchema = z.object(visualDiffShape);
1304
+ var e2eFrameworkSchema = z.enum([
1305
+ "playwright-test",
1306
+ "vitest+playwright",
1307
+ "pytest+selenium"
1308
+ ]);
1309
+ var scaffoldE2eShape = {
1310
+ framework: e2eFrameworkSchema,
1311
+ scenario_nl: z.string().min(1),
1312
+ url: z.string().url(),
1313
+ recorded_bundle: z.string().min(1).optional(),
1314
+ /** Override the generated test file name. */
1315
+ filename: z.string().min(1).optional()
1316
+ };
1317
+ var scaffoldE2eSchema = z.object(scaffoldE2eShape);
1318
+ var extractUiStateShape = {
1319
+ session_id: z.string().min(1).optional(),
1320
+ open: browserOpenSchema.optional(),
1321
+ question_nl: z.string().min(1),
1322
+ close_on_finish: z.boolean().default(false)
1323
+ };
1324
+ var extractUiStateSchema = z.object(extractUiStateShape);
1325
+ var ToolNames = {
1326
+ browserOpen: "rolepod_browser_open",
1327
+ browserClose: "rolepod_browser_close",
1328
+ browserSnapshot: "rolepod_browser_snapshot",
1329
+ browserClick: "rolepod_browser_click",
1330
+ browserType: "rolepod_browser_type",
1331
+ browserKey: "rolepod_browser_key",
1332
+ browserScroll: "rolepod_browser_scroll",
1333
+ browserWaitFor: "rolepod_browser_wait_for",
1334
+ browserScreenshot: "rolepod_browser_screenshot",
1335
+ browserNavigate: "rolepod_browser_navigate",
1336
+ verifyUiFlow: "rolepod_verify_ui_flow",
1337
+ auditA11y: "rolepod_audit_a11y",
1338
+ visualDiff: "rolepod_visual_diff",
1339
+ scaffoldE2e: "rolepod_scaffold_e2e",
1340
+ extractUiState: "rolepod_extract_ui_state"
1341
+ };
1342
+
1343
+ // src/replay/minimize.ts
1344
+ async function ddmin(input, reproduces) {
1345
+ let current = [...input];
1346
+ let n = 2;
1347
+ while (current.length >= 2) {
1348
+ const chunkSize = Math.max(1, Math.floor(current.length / n));
1349
+ let reduced = false;
1350
+ for (let i = 0; i < current.length; i += chunkSize) {
1351
+ const complement = [
1352
+ ...current.slice(0, i),
1353
+ ...current.slice(i + chunkSize)
1354
+ ];
1355
+ if (complement.length === 0) continue;
1356
+ if (await reproduces(complement)) {
1357
+ current = complement;
1358
+ n = Math.max(n - 1, 2);
1359
+ reduced = true;
1360
+ break;
1361
+ }
1362
+ }
1363
+ if (reduced) continue;
1364
+ if (n >= current.length) break;
1365
+ n = Math.min(n * 2, current.length);
1366
+ }
1367
+ return current;
1368
+ }
1369
+
1370
+ // src/tools/result.ts
1371
+ function ok(value) {
1372
+ return {
1373
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
1374
+ structuredContent: value
1375
+ };
1376
+ }
1377
+ function failure(err) {
1378
+ if (err instanceof RolepodMcpError) {
1379
+ const payload2 = err.toJSON();
1380
+ return {
1381
+ isError: true,
1382
+ content: [{ type: "text", text: JSON.stringify(payload2, null, 2) }],
1383
+ structuredContent: payload2
1384
+ };
1385
+ }
1386
+ const message = err instanceof Error ? err.message : String(err);
1387
+ const payload = { code: "engine_error", message };
1388
+ return {
1389
+ isError: true,
1390
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
1391
+ structuredContent: payload
1392
+ };
1393
+ }
1394
+ function safeHandler(fn) {
1395
+ return async (args) => {
1396
+ try {
1397
+ return await fn(args);
1398
+ } catch (err) {
1399
+ return failure(err);
1400
+ }
1401
+ };
1402
+ }
1403
+
1404
+ // src/tools/composite/verify_ui_flow.ts
1405
+ var verifyUiFlowTool = {
1406
+ name: ToolNames.verifyUiFlow,
1407
+ description: "Open a session, run UI steps, evaluate assertions, and save evidence. Set mode='reproduce' for bug reproduction with optional step minimization.",
1408
+ inputShape: verifyUiFlowShape,
1409
+ build(ctx) {
1410
+ return safeHandler(async (args) => {
1411
+ const { runId, runDir } = await ctx.store.startRun("verify");
1412
+ const initial = await runFlow(ctx, args, args.steps, runDir, {
1413
+ captureEvidence: true,
1414
+ bundleName: "replay.json"
1415
+ });
1416
+ const result = {
1417
+ run_id: runId,
1418
+ mode: args.mode,
1419
+ passed: initial.passed,
1420
+ evidence_paths: initial.evidence
1421
+ };
1422
+ if (initial.failedAtStep !== void 0) result.failed_at_step = initial.failedAtStep;
1423
+ if (initial.failureReason !== void 0) result.failure_reason = initial.failureReason;
1424
+ if (initial.finalUrl !== void 0) result.final_url_or_screen = initial.finalUrl;
1425
+ if (args.mode === "reproduce" && initial.passed && args.minimize) {
1426
+ const min = await minimize(ctx, args, args.steps, runDir);
1427
+ result.minimized = {
1428
+ original_step_count: args.steps.length,
1429
+ minimized_step_count: min.steps.length,
1430
+ minimal_steps: min.steps,
1431
+ steps_removed: min.removed,
1432
+ replay_bundle: min.replayPath,
1433
+ attempts: min.attempts
1434
+ };
1435
+ }
1436
+ return ok(result);
1437
+ });
1438
+ }
1439
+ };
1440
+ async function runFlow(ctx, args, steps, runDir, opts) {
1441
+ const evidence = { screenshots: [] };
1442
+ let passed = false;
1443
+ let failedAtStep;
1444
+ let failureReason;
1445
+ let finalSnapshot;
1446
+ const session = await ctx.registry.open(args.open);
1447
+ const engine = ctx.registry.engineFor(session.id);
1448
+ const sessionHandle = { id: session.id, platform: session.platform };
1449
+ try {
1450
+ for (let i = 0; i < steps.length; i++) {
1451
+ const step = steps[i];
1452
+ const beforeSnap = await engine.snapshot(sessionHandle);
1453
+ try {
1454
+ await runStep(engine, sessionHandle, step, beforeSnap);
1455
+ } catch (err) {
1456
+ failedAtStep = i;
1457
+ failureReason = `Step ${i} (${step.kind}) failed: ${describeError(err)}`;
1458
+ throw err;
1459
+ }
1460
+ }
1461
+ finalSnapshot = await engine.snapshot(sessionHandle);
1462
+ const failures = [];
1463
+ for (let i = 0; i < args.expect.length; i++) {
1464
+ const expectation = args.expect[i];
1465
+ if (!evaluateExpect(expectation, finalSnapshot)) {
1466
+ failures.push(`expect[${i}] ${describeExpect(expectation)}`);
1467
+ }
1468
+ }
1469
+ if (failures.length === 0) {
1470
+ passed = true;
1471
+ } else {
1472
+ failureReason = `Expectations failed: ${failures.join("; ")}`;
1473
+ }
1474
+ } catch (err) {
1475
+ if (!failureReason) failureReason = describeError(err);
1476
+ passed = false;
1477
+ } finally {
1478
+ if (opts.captureEvidence) {
1479
+ const wantScreenshot = !args.capture || args.capture.includes("screenshot");
1480
+ if (wantScreenshot) {
1481
+ try {
1482
+ const buf = await engine.screenshot(sessionHandle, true);
1483
+ const p = await ctx.store.writeScreenshot(runDir, buf, "final");
1484
+ evidence.screenshots.push(p);
1485
+ } catch (err) {
1486
+ failureReason ??= `screenshot capture failed: ${describeError(err)}`;
1487
+ }
1488
+ }
1489
+ try {
1490
+ evidence.replay_bundle = await ctx.store.writeReplayBundle(
1491
+ runDir,
1492
+ {
1493
+ version: 1,
1494
+ run_id: runDir.split("/").pop() ?? "run",
1495
+ recorded_at: (/* @__PURE__ */ new Date()).toISOString(),
1496
+ open: args.open,
1497
+ steps,
1498
+ expect: args.expect
1499
+ },
1500
+ opts.bundleName
1501
+ );
1502
+ } catch {
1503
+ }
1504
+ }
1505
+ if (args.close_on_finish) {
1506
+ await ctx.registry.close(sessionHandle).catch(() => void 0);
1507
+ }
1508
+ }
1509
+ const out = { passed, evidence };
1510
+ if (failedAtStep !== void 0) out.failedAtStep = failedAtStep;
1511
+ if (failureReason !== void 0) out.failureReason = failureReason;
1512
+ if (finalSnapshot) out.finalUrl = finalSnapshot.url_or_screen;
1513
+ return out;
1514
+ }
1515
+ async function minimize(ctx, args, initialSteps, runDir) {
1516
+ const tagged = initialSteps.map((step, origIndex) => ({ step, origIndex }));
1517
+ let attempts = 0;
1518
+ const predicate = async (subset) => {
1519
+ attempts += 1;
1520
+ const outcome = await runFlow(
1521
+ ctx,
1522
+ args,
1523
+ subset.map((t) => t.step),
1524
+ runDir,
1525
+ { captureEvidence: false, bundleName: "minimize-tmp.json" }
1526
+ );
1527
+ return outcome.passed;
1528
+ };
1529
+ const minimal = await ddmin(tagged, predicate);
1530
+ const remainingIdx = new Set(minimal.map((t) => t.origIndex));
1531
+ const removed = tagged.map((t) => t.origIndex).filter((i) => !remainingIdx.has(i));
1532
+ let replayPath;
1533
+ if (minimal.length !== initialSteps.length) {
1534
+ const finalRun = await runFlow(
1535
+ ctx,
1536
+ args,
1537
+ minimal.map((t) => t.step),
1538
+ runDir,
1539
+ { captureEvidence: true, bundleName: "replay-minimized.json" }
1540
+ );
1541
+ replayPath = finalRun.evidence.replay_bundle;
1542
+ }
1543
+ return {
1544
+ steps: minimal.map((t) => t.step),
1545
+ removed: removed.sort((a, b) => a - b),
1546
+ attempts,
1547
+ replayPath
1548
+ };
1549
+ }
1550
+ async function runStep(engine, session, step, snap) {
1551
+ switch (step.kind) {
1552
+ case "click": {
1553
+ const ref = findRefByQuery(snap.tree, step.query);
1554
+ if (!ref) throw missingQuery(step.query);
1555
+ await engine.click(session, ref);
1556
+ return;
1557
+ }
1558
+ case "type": {
1559
+ const ref = findRefByQuery(snap.tree, step.query);
1560
+ if (!ref) throw missingQuery(step.query);
1561
+ await engine.type(
1562
+ session,
1563
+ ref,
1564
+ step.text,
1565
+ step.clear_first ? { clearFirst: true } : void 0
1566
+ );
1567
+ return;
1568
+ }
1569
+ case "key":
1570
+ await engine.key(session, step.key);
1571
+ return;
1572
+ case "wait_for":
1573
+ await engine.waitFor(session, step.condition);
1574
+ return;
1575
+ case "navigate":
1576
+ await engine.navigate(session, step.url);
1577
+ return;
1578
+ }
1579
+ }
1580
+ function evaluateExpect(exp, snap) {
1581
+ switch (exp.kind) {
1582
+ case "text_visible":
1583
+ return treeHasText(snap.tree, exp.text);
1584
+ case "text_absent":
1585
+ return !treeHasText(snap.tree, exp.text);
1586
+ case "url_matches":
1587
+ return new RegExp(exp.pattern).test(snap.url_or_screen);
1588
+ case "ref_in_state": {
1589
+ const node = findNodeByQuery(snap.tree, exp.query);
1590
+ if (!node) return false;
1591
+ switch (exp.state) {
1592
+ case "visible":
1593
+ return true;
1594
+ case "enabled":
1595
+ return node.state?.disabled !== true;
1596
+ case "focused":
1597
+ return node.state?.focused === true;
1598
+ }
1599
+ }
1600
+ }
1601
+ }
1602
+ function describeExpect(exp) {
1603
+ switch (exp.kind) {
1604
+ case "text_visible":
1605
+ return `text_visible "${exp.text}"`;
1606
+ case "text_absent":
1607
+ return `text_absent "${exp.text}"`;
1608
+ case "url_matches":
1609
+ return `url_matches /${exp.pattern}/`;
1610
+ case "ref_in_state":
1611
+ return `ref_in_state "${exp.query}" \u2192 ${exp.state}`;
1612
+ }
1613
+ }
1614
+ function missingQuery(query) {
1615
+ return new RolepodMcpError(
1616
+ "invalid_input",
1617
+ `No element matched query "${query}" in the current snapshot.`,
1618
+ { query }
1619
+ );
1620
+ }
1621
+ function describeError(err) {
1622
+ if (err instanceof Error) return err.message;
1623
+ return String(err);
1624
+ }
1625
+ function findRefByQuery(tree, query) {
1626
+ const node = findNodeByQuery(tree, query);
1627
+ return node ? node.ref : null;
1628
+ }
1629
+ function findNodeByQuery(tree, query) {
1630
+ const target = query.toLowerCase();
1631
+ const visit = (node) => {
1632
+ if (node.name && node.name.toLowerCase().includes(target) || node.value && node.value.toLowerCase().includes(target)) {
1633
+ return node;
1634
+ }
1635
+ if (node.children) {
1636
+ for (const c of node.children) {
1637
+ const hit = visit(c);
1638
+ if (hit) return hit;
1639
+ }
1640
+ }
1641
+ return null;
1642
+ };
1643
+ return visit(tree);
1644
+ }
1645
+ function treeHasText(tree, text) {
1646
+ const target = text.toLowerCase();
1647
+ const visit = (node) => {
1648
+ if (node.name && node.name.toLowerCase().includes(target) || node.value && node.value.toLowerCase().includes(target)) {
1649
+ return true;
1650
+ }
1651
+ return node.children?.some(visit) ?? false;
1652
+ };
1653
+ return visit(tree);
1654
+ }
1655
+
1656
+ // src/cli/replay.ts
1657
+ async function runReplay(bundlePath) {
1658
+ const abs = resolve3(bundlePath);
1659
+ const raw = await readFile(abs, "utf8");
1660
+ const bundle = JSON.parse(raw);
1661
+ if (bundle.version !== 1) {
1662
+ process.stderr.write(`Unsupported replay bundle version: ${bundle.version}
1663
+ `);
1664
+ return 1;
1665
+ }
1666
+ const webEngine = createWebEngine();
1667
+ const registry = new SessionRegistry({ idleTimeoutMs: 0 });
1668
+ registry.register("web", webEngine);
1669
+ const mobileEngine = createMobileEngine();
1670
+ registry.register("ios", mobileEngine);
1671
+ registry.register("android", mobileEngine);
1672
+ const store = new ArtifactStore();
1673
+ const ctx = { registry, store };
1674
+ try {
1675
+ const handler = verifyUiFlowTool.build(ctx);
1676
+ const result = await handler({
1677
+ mode: "assert",
1678
+ open: bundle.open ?? {},
1679
+ steps: bundle.steps ?? [],
1680
+ expect: bundle.expect ?? [],
1681
+ capture: ["screenshot"],
1682
+ close_on_finish: true,
1683
+ minimize: false
1684
+ });
1685
+ const body = result.structuredContent;
1686
+ process.stdout.write(JSON.stringify(body, null, 2) + "\n");
1687
+ return body.passed === true ? 0 : 1;
1688
+ } finally {
1689
+ await registry.shutdown().catch(() => void 0);
1690
+ }
1691
+ }
1692
+
1693
+ // src/server.ts
1694
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1695
+
1696
+ // src/tools/atomic/browser_click.ts
1697
+ var browserClickTool = {
1698
+ name: ToolNames.browserClick,
1699
+ description: "Click the element identified by `ref` from the most recent snapshot. Invalidates all refs on success.",
1700
+ inputShape: browserClickShape,
1701
+ build(ctx) {
1702
+ return safeHandler(async (args) => {
1703
+ const engine = ctx.registry.engineFor(args.session_id);
1704
+ await engine.click(
1705
+ { id: args.session_id, platform: ctx.registry.platformOf(args.session_id) },
1706
+ args.ref,
1707
+ args.button ? { button: args.button } : void 0
1708
+ );
1709
+ return ok({ clicked: true });
1710
+ });
1711
+ }
1712
+ };
1713
+
1714
+ // src/tools/atomic/browser_close.ts
1715
+ var browserCloseTool = {
1716
+ name: ToolNames.browserClose,
1717
+ description: "Close an open session and free its browser / driver resources.",
1718
+ inputShape: browserCloseShape,
1719
+ build(ctx) {
1720
+ return safeHandler(async (args) => {
1721
+ const platform = ctx.registry.platformOf(args.session_id);
1722
+ await ctx.registry.close({ id: args.session_id, platform });
1723
+ return ok({ closed: true });
1724
+ });
1725
+ }
1726
+ };
1727
+
1728
+ // src/tools/atomic/browser_key.ts
1729
+ var browserKeyTool = {
1730
+ name: ToolNames.browserKey,
1731
+ description: "Press a single key (e.g. 'Enter', 'Tab', 'Escape'). Invalidates all refs on success.",
1732
+ inputShape: browserKeyShape,
1733
+ build(ctx) {
1734
+ return safeHandler(async (args) => {
1735
+ const engine = ctx.registry.engineFor(args.session_id);
1736
+ await engine.key({ id: args.session_id, platform: ctx.registry.platformOf(args.session_id) }, args.key);
1737
+ return ok({ pressed: true });
1738
+ });
1739
+ }
1740
+ };
1741
+
1742
+ // src/tools/atomic/browser_navigate.ts
1743
+ var browserNavigateTool = {
1744
+ name: ToolNames.browserNavigate,
1745
+ description: "Navigate the session to a new URL (web only). Invalidates all refs on success.",
1746
+ inputShape: browserNavigateShape,
1747
+ build(ctx) {
1748
+ return safeHandler(async (args) => {
1749
+ const engine = ctx.registry.engineFor(args.session_id);
1750
+ await engine.navigate({ id: args.session_id, platform: ctx.registry.platformOf(args.session_id) }, args.url);
1751
+ return ok({ navigated: true, url: args.url });
1752
+ });
1753
+ }
1754
+ };
1755
+
1756
+ // src/tools/atomic/browser_open.ts
1757
+ var browserOpenTool = {
1758
+ name: ToolNames.browserOpen,
1759
+ description: "Open a new browser or mobile session against a target. v0.1 supports platform='web' only; mobile lands in v0.3.",
1760
+ inputShape: browserOpenShape,
1761
+ build(ctx) {
1762
+ return safeHandler(async (args) => {
1763
+ const session = await ctx.registry.open(args);
1764
+ return ok({ session_id: session.id, platform: session.platform });
1765
+ });
1766
+ }
1767
+ };
1768
+
1769
+ // src/tools/atomic/browser_screenshot.ts
1770
+ var browserScreenshotTool = {
1771
+ name: ToolNames.browserScreenshot,
1772
+ description: "Capture a screenshot and save under ./.rolepod-uiproof/artifacts/{run_id}/. Set `full_page: true` for full-page capture; default is viewport only. Read-only (does NOT invalidate refs).",
1773
+ inputShape: browserScreenshotShape,
1774
+ build(ctx) {
1775
+ return safeHandler(async (args) => {
1776
+ const engine = ctx.registry.engineFor(args.session_id);
1777
+ const { runId, runDir } = await ctx.store.startRun("snap");
1778
+ const buf = await engine.screenshot(
1779
+ { id: args.session_id, platform: ctx.registry.platformOf(args.session_id) },
1780
+ args.full_page ?? false
1781
+ );
1782
+ const path = await ctx.store.writeScreenshot(runDir, buf, "shot");
1783
+ return ok({
1784
+ run_id: runId,
1785
+ path,
1786
+ width: void 0,
1787
+ height: void 0,
1788
+ bytes: buf.byteLength
1789
+ });
1790
+ });
1791
+ }
1792
+ };
1793
+
1794
+ // src/tools/atomic/browser_scroll.ts
1795
+ var browserScrollTool = {
1796
+ name: ToolNames.browserScroll,
1797
+ description: "Scroll the page (or a specific scrollable element when `ref` is set) by `amount` pixels in `direction`. Invalidates all refs on success.",
1798
+ inputShape: browserScrollShape,
1799
+ build(ctx) {
1800
+ return safeHandler(async (args) => {
1801
+ const engine = ctx.registry.engineFor(args.session_id);
1802
+ await engine.scroll(
1803
+ { id: args.session_id, platform: ctx.registry.platformOf(args.session_id) },
1804
+ args.direction,
1805
+ args.amount,
1806
+ args.ref
1807
+ );
1808
+ return ok({ scrolled: true });
1809
+ });
1810
+ }
1811
+ };
1812
+
1813
+ // src/tools/atomic/browser_snapshot.ts
1814
+ var browserSnapshotTool = {
1815
+ name: ToolNames.browserSnapshot,
1816
+ description: "Return the current accessibility tree with stable refs (e1, e2, \u2026). Refs are valid only until the next state-changing call (D-010).",
1817
+ inputShape: browserSnapshotShape,
1818
+ build(ctx) {
1819
+ return safeHandler(async (args) => {
1820
+ const engine = ctx.registry.engineFor(args.session_id);
1821
+ const snap = await engine.snapshot(
1822
+ { id: args.session_id, platform: ctx.registry.platformOf(args.session_id) },
1823
+ args.mode
1824
+ );
1825
+ return ok({
1826
+ session_id: snap.session_id,
1827
+ url_or_screen: snap.url_or_screen,
1828
+ tree: snap.tree,
1829
+ taken_at: snap.taken_at
1830
+ });
1831
+ });
1832
+ }
1833
+ };
1834
+
1835
+ // src/tools/atomic/browser_type.ts
1836
+ var browserTypeTool = {
1837
+ name: ToolNames.browserType,
1838
+ description: "Type `text` into the element identified by `ref`. Set `clear_first: true` to replace the existing value. Invalidates all refs on success.",
1839
+ inputShape: browserTypeShape,
1840
+ build(ctx) {
1841
+ return safeHandler(async (args) => {
1842
+ const engine = ctx.registry.engineFor(args.session_id);
1843
+ await engine.type(
1844
+ { id: args.session_id, platform: ctx.registry.platformOf(args.session_id) },
1845
+ args.ref,
1846
+ args.text,
1847
+ args.clear_first ? { clearFirst: true } : void 0
1848
+ );
1849
+ return ok({ typed: true });
1850
+ });
1851
+ }
1852
+ };
1853
+
1854
+ // src/tools/atomic/browser_wait_for.ts
1855
+ var browserWaitForTool = {
1856
+ name: ToolNames.browserWaitFor,
1857
+ description: "Wait until a condition holds: text_visible, ref_exists, url_matches, or idle. Defaults to a 10s timeout. Invalidates all refs on success.",
1858
+ inputShape: browserWaitForShape,
1859
+ build(ctx) {
1860
+ return safeHandler(async (args) => {
1861
+ const engine = ctx.registry.engineFor(args.session_id);
1862
+ const start = Date.now();
1863
+ await engine.waitFor(
1864
+ { id: args.session_id, platform: ctx.registry.platformOf(args.session_id) },
1865
+ args.condition,
1866
+ args.timeout_ms
1867
+ );
1868
+ return ok({ matched: true, waited_ms: Date.now() - start });
1869
+ });
1870
+ }
1871
+ };
1872
+
1873
+ // src/tools/composite/audit_a11y.ts
1874
+ import AxeBuilder from "@axe-core/playwright";
1875
+ var TAGS_BY_LEVEL = {
1876
+ "wcag-a": ["wcag2a", "wcag21a"],
1877
+ "wcag-aa": ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"],
1878
+ "wcag-aaa": [
1879
+ "wcag2a",
1880
+ "wcag2aa",
1881
+ "wcag2aaa",
1882
+ "wcag21a",
1883
+ "wcag21aa",
1884
+ "wcag21aaa"
1885
+ ]
1886
+ };
1887
+ var AXE_TO_SEVERITY = {
1888
+ critical: "critical",
1889
+ serious: "serious",
1890
+ moderate: "moderate",
1891
+ minor: "minor"
1892
+ };
1893
+ var auditA11yTool = {
1894
+ name: ToolNames.auditA11y,
1895
+ description: "Run an accessibility audit on the page using axe-core. Returns issues grouped by severity with WCAG references and fix suggestions. v0.2: scope='page' only.",
1896
+ inputShape: auditA11yShape,
1897
+ build(ctx) {
1898
+ return safeHandler(async (args) => {
1899
+ const { runId, runDir } = await ctx.store.startRun("audit");
1900
+ const session = await ctx.registry.open(args.open);
1901
+ const engine = ctx.registry.engineFor(session.id);
1902
+ if (!(engine instanceof PlaywrightEngine)) {
1903
+ throw new RolepodMcpError(
1904
+ "unsupported_engine",
1905
+ "audit_a11y currently requires PlaywrightEngine (mobile a11y audit lands later)."
1906
+ );
1907
+ }
1908
+ const page = engine.getPageForSession(session.id);
1909
+ let reportPath;
1910
+ let issues = [];
1911
+ let scopeTagged = false;
1912
+ try {
1913
+ if (args.scope !== "page") {
1914
+ await engine.snapshot({ id: session.id, platform: "web" });
1915
+ const ref = args.scope.ref;
1916
+ const locator = page.locator(`aria-ref=${ref}`);
1917
+ if (await locator.count() === 0) {
1918
+ throw new RolepodMcpError(
1919
+ "unknown_ref",
1920
+ `Ref "${ref}" not found in the current snapshot.`,
1921
+ { session_id: session.id, ref }
1922
+ );
1923
+ }
1924
+ await locator.first().evaluate((el) => el.setAttribute("data-rolepod-axe-scope", "true"));
1925
+ scopeTagged = true;
1926
+ }
1927
+ const builder = new AxeBuilder({ page }).withTags(TAGS_BY_LEVEL[args.level]);
1928
+ if (scopeTagged) builder.include("[data-rolepod-axe-scope]");
1929
+ const results = await builder.analyze();
1930
+ issues = results.violations.flatMap(
1931
+ (v) => v.nodes.map((n, idx) => ({
1932
+ wcag_ref: pickWcagRef(v.tags) ?? v.id,
1933
+ severity: AXE_TO_SEVERITY[v.impact ?? "minor"] ?? "minor",
1934
+ ref: `${v.id}#${idx}`,
1935
+ description: v.help,
1936
+ fix_suggestion: v.helpUrl,
1937
+ target: n.target.join(" ")
1938
+ }))
1939
+ );
1940
+ const payload = {
1941
+ run_id: runId,
1942
+ level: args.level,
1943
+ counts: countBySeverity(issues),
1944
+ issues
1945
+ };
1946
+ if (args.report_format === "markdown") {
1947
+ reportPath = await ctx.store.writeReport(
1948
+ runDir,
1949
+ "report.md",
1950
+ renderMarkdown(payload)
1951
+ );
1952
+ } else {
1953
+ reportPath = await ctx.store.writeReport(
1954
+ runDir,
1955
+ "report.json",
1956
+ JSON.stringify(payload, null, 2)
1957
+ );
1958
+ }
1959
+ } finally {
1960
+ if (scopeTagged) {
1961
+ await page.locator("[data-rolepod-axe-scope]").first().evaluate((el) => el.removeAttribute("data-rolepod-axe-scope")).catch(() => void 0);
1962
+ }
1963
+ if (args.close_on_finish) {
1964
+ await ctx.registry.close(session).catch(() => void 0);
1965
+ }
1966
+ }
1967
+ return ok({
1968
+ run_id: runId,
1969
+ counts: countBySeverity(issues),
1970
+ issues,
1971
+ report_path: reportPath
1972
+ });
1973
+ });
1974
+ }
1975
+ };
1976
+ function pickWcagRef(tags) {
1977
+ return tags.find((t) => /^wcag\d/.test(t));
1978
+ }
1979
+ function countBySeverity(issues) {
1980
+ const out = {
1981
+ critical: 0,
1982
+ serious: 0,
1983
+ moderate: 0,
1984
+ minor: 0
1985
+ };
1986
+ for (const i of issues) {
1987
+ const s = String(i.severity);
1988
+ out[s] = (out[s] ?? 0) + 1;
1989
+ }
1990
+ return out;
1991
+ }
1992
+ function renderMarkdown(p) {
1993
+ const header = `# A11y audit \u2014 ${p.run_id}
1994
+
1995
+ Level: \`${p.level}\`
1996
+
1997
+ ## Counts
1998
+
1999
+ - critical: ${p.counts.critical}
2000
+ - serious: ${p.counts.serious}
2001
+ - moderate: ${p.counts.moderate}
2002
+ - minor: ${p.counts.minor}
2003
+
2004
+ ## Issues
2005
+
2006
+ `;
2007
+ const body = p.issues.map(
2008
+ (i) => `### ${i.severity} \u2014 ${i.description}
2009
+
2010
+ - WCAG: ${i.wcag_ref}
2011
+ - Target: \`${i.target}\`
2012
+ - Fix: ${i.fix_suggestion}
2013
+ `
2014
+ ).join("\n");
2015
+ return header + body;
2016
+ }
2017
+
2018
+ // src/tools/composite/extract_ui_state.ts
2019
+ var extractUiStateTool = {
2020
+ name: ToolNames.extractUiState,
2021
+ description: "Return the AT-tree subtree most likely to answer a natural-language question, plus matched refs. No LLM is called \u2014 the Lead does the interpretation.",
2022
+ inputShape: extractUiStateShape,
2023
+ build(ctx) {
2024
+ return safeHandler(async (args) => {
2025
+ let session;
2026
+ let openedHere = false;
2027
+ if (args.session_id) {
2028
+ const platform = ctx.registry.platformOf(args.session_id);
2029
+ session = { id: args.session_id, platform };
2030
+ } else if (args.open) {
2031
+ session = await ctx.registry.open(args.open);
2032
+ openedHere = true;
2033
+ } else {
2034
+ throw new RolepodMcpError(
2035
+ "invalid_input",
2036
+ "Provide either `session_id` (existing session) or `open` (to start one)."
2037
+ );
2038
+ }
2039
+ try {
2040
+ const engine = ctx.registry.engineFor(session.id);
2041
+ const snap = await engine.snapshot(session);
2042
+ const tokens = tokenize(args.question_nl);
2043
+ const matches = scoreTree(snap.tree, tokens);
2044
+ const top = matches[0];
2045
+ const subtree = top ? top.subtree : snap.tree;
2046
+ const matchedRefs = matches.slice(0, 8).map((m) => m.ref);
2047
+ let confidence = "low";
2048
+ if (top) {
2049
+ if (top.score >= tokens.length && tokens.length > 0) confidence = "high";
2050
+ else if (top.score >= Math.max(1, Math.ceil(tokens.length / 2)))
2051
+ confidence = "medium";
2052
+ }
2053
+ return ok({
2054
+ snapshot_ref: snap.taken_at,
2055
+ confidence,
2056
+ matched_refs: matchedRefs,
2057
+ value: subtree,
2058
+ url_or_screen: snap.url_or_screen
2059
+ });
2060
+ } finally {
2061
+ if (openedHere && args.close_on_finish) {
2062
+ await ctx.registry.close(session).catch(() => void 0);
2063
+ }
2064
+ }
2065
+ });
2066
+ }
2067
+ };
2068
+ function tokenize(q) {
2069
+ return q.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((t) => t.length >= 2);
2070
+ }
2071
+ function scoreTree(root, tokens) {
2072
+ const matches = [];
2073
+ const visit = (node, ancestors) => {
2074
+ const hay = `${node.name ?? ""} ${node.value ?? ""}`.toLowerCase();
2075
+ let score = 0;
2076
+ for (const t of tokens) if (hay.includes(t)) score += 1;
2077
+ if (score > 0) {
2078
+ const subtree = ancestors.length > 0 ? ancestors[ancestors.length - 1] : node;
2079
+ matches.push({ ref: node.ref, score, subtree });
2080
+ }
2081
+ if (node.children) {
2082
+ for (const c of node.children) visit(c, [...ancestors, node]);
2083
+ }
2084
+ };
2085
+ visit(root, []);
2086
+ matches.sort((a, b) => b.score - a.score);
2087
+ return matches;
2088
+ }
2089
+
2090
+ // src/tools/composite/scaffold_e2e.ts
2091
+ import { readFile as readFile2 } from "fs/promises";
2092
+ import { resolve as resolve4 } from "path";
2093
+ var scaffoldE2eTool = {
2094
+ name: ToolNames.scaffoldE2e,
2095
+ description: "Generate a runnable e2e test file (playwright-test, vitest+playwright, or pytest+selenium) from a scenario description and optional replay bundle from a prior verify_ui_flow run.",
2096
+ inputShape: scaffoldE2eShape,
2097
+ build(ctx) {
2098
+ return safeHandler(async (args) => {
2099
+ const { runId, runDir } = await ctx.store.startRun("scaffold");
2100
+ const slug = slugify(args.scenario_nl);
2101
+ const bundle = args.recorded_bundle ? await loadReplay(args.recorded_bundle) : null;
2102
+ const ctxObj = { args, slug, bundle };
2103
+ let body;
2104
+ let language;
2105
+ let filename;
2106
+ let dependencies;
2107
+ let setupNotes;
2108
+ switch (args.framework) {
2109
+ case "playwright-test":
2110
+ body = renderPlaywrightTest(ctxObj);
2111
+ language = "typescript";
2112
+ filename = args.filename ?? `${slug}.spec.ts`;
2113
+ dependencies = ["@playwright/test"];
2114
+ setupNotes = "Install: `npm i -D @playwright/test && npx playwright install`. Run: `npx playwright test`.";
2115
+ break;
2116
+ case "vitest+playwright":
2117
+ body = renderVitestPlaywright(ctxObj);
2118
+ language = "typescript";
2119
+ filename = args.filename ?? `${slug}.test.ts`;
2120
+ dependencies = ["vitest", "playwright"];
2121
+ setupNotes = "Install: `npm i -D vitest playwright && npx playwright install chromium`. Run: `npx vitest`.";
2122
+ break;
2123
+ case "pytest+selenium":
2124
+ body = renderPytestSelenium(ctxObj);
2125
+ language = "python";
2126
+ filename = args.filename ?? `test_${slug}.py`;
2127
+ dependencies = ["pytest", "selenium"];
2128
+ setupNotes = "Install: `pip install pytest selenium`. Ensure a Chrome driver is on PATH. Run: `pytest`.";
2129
+ break;
2130
+ default:
2131
+ throw new RolepodMcpError(
2132
+ "invalid_input",
2133
+ `Unknown framework "${args.framework}".`
2134
+ );
2135
+ }
2136
+ const path = await ctx.store.writeReport(runDir, filename, body);
2137
+ return ok({
2138
+ run_id: runId,
2139
+ test_file_path: path,
2140
+ language,
2141
+ dependencies,
2142
+ setup_notes: setupNotes,
2143
+ from_replay_bundle: Boolean(bundle)
2144
+ });
2145
+ });
2146
+ }
2147
+ };
2148
+ async function loadReplay(bundlePath) {
2149
+ const raw = await readFile2(resolve4(bundlePath), "utf8");
2150
+ return JSON.parse(raw);
2151
+ }
2152
+ function slugify(s) {
2153
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "scenario";
2154
+ }
2155
+ function renderPlaywrightTest(c) {
2156
+ const url = c.bundle?.open?.url ?? c.args.url;
2157
+ const stepLines = c.bundle?.steps?.length ? c.bundle.steps.map(playwrightStepLine).join("\n") : ` // TODO: implement steps for: ${c.args.scenario_nl}`;
2158
+ const expectLines = c.bundle?.expect?.length ? c.bundle.expect.map(playwrightExpectLine).join("\n") : ` // TODO: add expectations`;
2159
+ return [
2160
+ `import { test, expect } from "@playwright/test";`,
2161
+ ``,
2162
+ `test(${JSON.stringify(c.args.scenario_nl)}, async ({ page }) => {`,
2163
+ ` await page.goto(${JSON.stringify(url)});`,
2164
+ stepLines,
2165
+ expectLines,
2166
+ `});`,
2167
+ ``
2168
+ ].join("\n");
2169
+ }
2170
+ function renderVitestPlaywright(c) {
2171
+ const url = c.bundle?.open?.url ?? c.args.url;
2172
+ const stepLines = c.bundle?.steps?.length ? c.bundle.steps.map(playwrightStepLine).join("\n") : ` // TODO: implement steps for: ${c.args.scenario_nl}`;
2173
+ const expectLines = c.bundle?.expect?.length ? c.bundle.expect.map(playwrightExpectLine).join("\n") : ` // TODO: add expectations`;
2174
+ return [
2175
+ `import { test } from "vitest";`,
2176
+ `import { chromium, expect } from "playwright/test";`,
2177
+ ``,
2178
+ `test(${JSON.stringify(c.args.scenario_nl)}, async () => {`,
2179
+ ` const browser = await chromium.launch();`,
2180
+ ` const page = await browser.newPage();`,
2181
+ ` try {`,
2182
+ ` await page.goto(${JSON.stringify(url)});`,
2183
+ indent(stepLines, 2),
2184
+ indent(expectLines, 2),
2185
+ ` } finally {`,
2186
+ ` await browser.close();`,
2187
+ ` }`,
2188
+ `});`,
2189
+ ``
2190
+ ].join("\n");
2191
+ }
2192
+ function renderPytestSelenium(c) {
2193
+ const url = c.bundle?.open?.url ?? c.args.url;
2194
+ const stepLines = c.bundle?.steps?.length ? c.bundle.steps.map(seleniumStepLine).join("\n") : ` # TODO: implement steps for: ${c.args.scenario_nl}`;
2195
+ const expectLines = c.bundle?.expect?.length ? c.bundle.expect.map(seleniumExpectLine).join("\n") : ` # TODO: add expectations`;
2196
+ return [
2197
+ `import pytest`,
2198
+ `from selenium import webdriver`,
2199
+ `from selenium.webdriver.common.by import By`,
2200
+ `from selenium.webdriver.common.keys import Keys`,
2201
+ ``,
2202
+ `def test_${slugifyPy(c.args.scenario_nl)}():`,
2203
+ ` """${c.args.scenario_nl}"""`,
2204
+ ` driver = webdriver.Chrome()`,
2205
+ ` try:`,
2206
+ ` driver.get(${JSON.stringify(url)})`,
2207
+ indent(stepLines, 2),
2208
+ indent(expectLines, 2),
2209
+ ` finally:`,
2210
+ ` driver.quit()`,
2211
+ ``
2212
+ ].join("\n");
2213
+ }
2214
+ function playwrightStepLine(step) {
2215
+ switch (step.kind) {
2216
+ case "click":
2217
+ return ` await page.getByText(${JSON.stringify(step.query)}, { exact: false }).first().click();`;
2218
+ case "type":
2219
+ return ` await page.getByRole("textbox", { name: ${JSON.stringify(step.query)} }).fill(${JSON.stringify(step.text)});`;
2220
+ case "key":
2221
+ return ` await page.keyboard.press(${JSON.stringify(step.key)});`;
2222
+ case "navigate":
2223
+ return ` await page.goto(${JSON.stringify(step.url)});`;
2224
+ case "wait_for":
2225
+ return ` // wait_for: ${JSON.stringify(step.condition)} \u2014 translate to page.waitForXxx()`;
2226
+ default:
2227
+ return ` // unsupported step kind: ${step.kind}`;
2228
+ }
2229
+ }
2230
+ function playwrightExpectLine(exp) {
2231
+ switch (exp.kind) {
2232
+ case "text_visible":
2233
+ return ` await expect(page.getByText(${JSON.stringify(exp.text)}, { exact: false }).first()).toBeVisible();`;
2234
+ case "text_absent":
2235
+ return ` await expect(page.getByText(${JSON.stringify(exp.text)}, { exact: false }).first()).toHaveCount(0);`;
2236
+ case "url_matches":
2237
+ return ` await expect(page).toHaveURL(new RegExp(${JSON.stringify(exp.pattern)}));`;
2238
+ case "ref_in_state":
2239
+ return ` // ref_in_state ${JSON.stringify(exp.query)} \u2192 ${String(exp.state)} \u2014 translate as needed`;
2240
+ default:
2241
+ return ` // unsupported expect kind: ${exp.kind}`;
2242
+ }
2243
+ }
2244
+ function seleniumStepLine(step) {
2245
+ switch (step.kind) {
2246
+ case "click":
2247
+ return ` driver.find_element(By.XPATH, f"//*[contains(text(), \\"${escapePy(String(step.query))}\\")]").click()`;
2248
+ case "type":
2249
+ return ` driver.find_element(By.XPATH, f"//*[@aria-label=\\"${escapePy(String(step.query))}\\" or @placeholder=\\"${escapePy(String(step.query))}\\"]").send_keys(${JSON.stringify(step.text)})`;
2250
+ case "key":
2251
+ return ` driver.switch_to.active_element.send_keys(Keys.${pyKeyName(String(step.key))})`;
2252
+ case "navigate":
2253
+ return ` driver.get(${JSON.stringify(step.url)})`;
2254
+ case "wait_for":
2255
+ return ` # wait_for: ${JSON.stringify(step.condition)} \u2014 translate to WebDriverWait`;
2256
+ default:
2257
+ return ` # unsupported step kind: ${step.kind}`;
2258
+ }
2259
+ }
2260
+ function seleniumExpectLine(exp) {
2261
+ switch (exp.kind) {
2262
+ case "text_visible":
2263
+ return ` assert ${JSON.stringify(exp.text)} in driver.page_source`;
2264
+ case "text_absent":
2265
+ return ` assert ${JSON.stringify(exp.text)} not in driver.page_source`;
2266
+ case "url_matches":
2267
+ return ` import re; assert re.search(${JSON.stringify(exp.pattern)}, driver.current_url)`;
2268
+ case "ref_in_state":
2269
+ return ` # ref_in_state ${JSON.stringify(exp.query)} \u2192 ${String(exp.state)}`;
2270
+ default:
2271
+ return ` # unsupported expect kind: ${exp.kind}`;
2272
+ }
2273
+ }
2274
+ function slugifyPy(s) {
2275
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "scenario";
2276
+ }
2277
+ function escapePy(s) {
2278
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2279
+ }
2280
+ function pyKeyName(k) {
2281
+ const map = {
2282
+ Enter: "ENTER",
2283
+ Tab: "TAB",
2284
+ Escape: "ESCAPE",
2285
+ Backspace: "BACK_SPACE",
2286
+ ArrowUp: "ARROW_UP",
2287
+ ArrowDown: "ARROW_DOWN",
2288
+ ArrowLeft: "ARROW_LEFT",
2289
+ ArrowRight: "ARROW_RIGHT"
2290
+ };
2291
+ return map[k] ?? `RETURN # unknown key: ${k}`;
2292
+ }
2293
+ function indent(block, n) {
2294
+ const pad = " ".repeat(n);
2295
+ return block.split("\n").map((l) => l.length > 0 ? pad + l : l).join("\n");
2296
+ }
2297
+
2298
+ // src/tools/composite/visual_diff.ts
2299
+ import { existsSync as existsSync2 } from "fs";
2300
+ import { readFile as readFile3 } from "fs/promises";
2301
+ import { resolve as resolve5 } from "path";
2302
+ import pixelmatch from "pixelmatch";
2303
+ import { PNG } from "pngjs";
2304
+ var visualDiffTool = {
2305
+ name: ToolNames.visualDiff,
2306
+ description: "Capture a screenshot and compare against a named baseline under ./.rolepod-uiproof/baselines/. First call for a baseline_id seeds the baseline (passed=true, diff_pct=0). Subsequent calls return the diff percentage and an annotated diff image.",
2307
+ inputShape: visualDiffShape,
2308
+ build(ctx) {
2309
+ return safeHandler(async (args) => {
2310
+ const { runId, runDir } = await ctx.store.startRun("vdiff");
2311
+ const session = await ctx.registry.open({
2312
+ ...args.open,
2313
+ ...args.viewport ? { viewport: args.viewport } : {}
2314
+ });
2315
+ const engine = ctx.registry.engineFor(session.id);
2316
+ if (!(engine instanceof PlaywrightEngine)) {
2317
+ throw new RolepodMcpError(
2318
+ "unsupported_engine",
2319
+ "visual_diff currently requires PlaywrightEngine."
2320
+ );
2321
+ }
2322
+ try {
2323
+ const buf = await engine.screenshot(
2324
+ { id: session.id, platform: session.platform },
2325
+ true
2326
+ );
2327
+ const currentPath = await ctx.store.writeScreenshot(runDir, buf, "current");
2328
+ await ctx.store.ensureDir(ctx.store.baselineDir);
2329
+ const baselinePath = resolve5(
2330
+ ctx.store.baselineDir,
2331
+ `${args.baseline_id}.png`
2332
+ );
2333
+ if (!existsSync2(baselinePath)) {
2334
+ await ctx.store.writeBytes(
2335
+ ctx.store.baselineDir,
2336
+ `${args.baseline_id}.png`,
2337
+ buf
2338
+ );
2339
+ return ok({
2340
+ run_id: runId,
2341
+ baseline_id: args.baseline_id,
2342
+ diff_pct: 0,
2343
+ passed: true,
2344
+ baseline_path: baselinePath,
2345
+ current_path: currentPath,
2346
+ note: "Baseline did not exist \u2014 current capture saved as the new baseline."
2347
+ });
2348
+ }
2349
+ const [baselineRaw, currentRaw] = await Promise.all([
2350
+ readFile3(baselinePath),
2351
+ readFile3(currentPath)
2352
+ ]);
2353
+ const baseline = PNG.sync.read(baselineRaw);
2354
+ const current = PNG.sync.read(currentRaw);
2355
+ if (baseline.width !== current.width || baseline.height !== current.height) {
2356
+ throw new RolepodMcpError(
2357
+ "engine_error",
2358
+ `Dimension mismatch for baseline "${args.baseline_id}" \u2014 baseline ${baseline.width}x${baseline.height}, current ${current.width}x${current.height}. Delete the baseline or pass a matching viewport.`,
2359
+ {
2360
+ baseline: { w: baseline.width, h: baseline.height },
2361
+ current: { w: current.width, h: current.height }
2362
+ }
2363
+ );
2364
+ }
2365
+ const diff = new PNG({ width: baseline.width, height: baseline.height });
2366
+ const diffPixels = pixelmatch(
2367
+ baseline.data,
2368
+ current.data,
2369
+ diff.data,
2370
+ baseline.width,
2371
+ baseline.height,
2372
+ { threshold: args.pixel_threshold, includeAA: true }
2373
+ );
2374
+ const total = baseline.width * baseline.height;
2375
+ const diffPct = diffPixels / total;
2376
+ const diffImagePath = await ctx.store.writeBytes(
2377
+ runDir,
2378
+ "diff.png",
2379
+ PNG.sync.write(diff)
2380
+ );
2381
+ return ok({
2382
+ run_id: runId,
2383
+ baseline_id: args.baseline_id,
2384
+ diff_pct: Number(diffPct.toFixed(6)),
2385
+ diff_pixels: diffPixels,
2386
+ total_pixels: total,
2387
+ passed: diffPct <= args.threshold_pct,
2388
+ baseline_path: baselinePath,
2389
+ current_path: currentPath,
2390
+ diff_image_path: diffImagePath
2391
+ });
2392
+ } finally {
2393
+ if (args.close_on_finish) {
2394
+ await ctx.registry.close(session).catch(() => void 0);
2395
+ }
2396
+ }
2397
+ });
2398
+ }
2399
+ };
2400
+
2401
+ // src/server.ts
2402
+ var SERVER_NAME = "rolepod-uiproof";
2403
+ var SERVER_VERSION = "0.4.0";
2404
+ function buildServer(opts = {}) {
2405
+ const webEngine = createWebEngine();
2406
+ const registry = new SessionRegistry({ idleTimeoutMs: opts.idleTimeoutMs });
2407
+ registry.register("web", webEngine);
2408
+ const mobileEngine = createMobileEngine();
2409
+ registry.register("ios", mobileEngine);
2410
+ registry.register("android", mobileEngine);
2411
+ const storeOpts = {};
2412
+ if (opts.artifactRoot !== void 0) storeOpts.rootDir = opts.artifactRoot;
2413
+ const store = new ArtifactStore(storeOpts);
2414
+ const ctx = { registry, store };
2415
+ const mcp = new McpServer({
2416
+ name: SERVER_NAME,
2417
+ version: SERVER_VERSION
2418
+ });
2419
+ const tools = [
2420
+ // atomic
2421
+ browserOpenTool,
2422
+ browserCloseTool,
2423
+ browserSnapshotTool,
2424
+ browserClickTool,
2425
+ browserTypeTool,
2426
+ browserKeyTool,
2427
+ browserScrollTool,
2428
+ browserWaitForTool,
2429
+ browserScreenshotTool,
2430
+ browserNavigateTool,
2431
+ // composite
2432
+ verifyUiFlowTool,
2433
+ auditA11yTool,
2434
+ visualDiffTool,
2435
+ scaffoldE2eTool,
2436
+ extractUiStateTool
2437
+ ];
2438
+ for (const t of tools) {
2439
+ mcp.registerTool(
2440
+ t.name,
2441
+ { description: t.description, inputSchema: t.inputShape },
2442
+ t.build(ctx)
2443
+ );
2444
+ }
2445
+ log.info("rolepod-uiproof server built", {
2446
+ version: SERVER_VERSION,
2447
+ tools: tools.map((t) => t.name)
2448
+ });
2449
+ return {
2450
+ mcp,
2451
+ registry,
2452
+ store,
2453
+ async shutdown() {
2454
+ await registry.shutdown();
2455
+ await mcp.close().catch(() => void 0);
2456
+ }
2457
+ };
2458
+ }
2459
+
2460
+ // bin/rolepod-uiproof.ts
2461
+ var HELP = `rolepod-uiproof ${SERVER_VERSION}
2462
+
2463
+ Usage:
2464
+ rolepod-uiproof Start the MCP server on stdio (default)
2465
+ rolepod-uiproof doctor Health check (Node, Playwright, Appium, SDKs)
2466
+ rolepod-uiproof install:mobile Print mobile setup checklist (iOS / Android)
2467
+ rolepod-uiproof replay <file> Re-run a verify_ui_flow replay bundle
2468
+ rolepod-uiproof --version Print version
2469
+ rolepod-uiproof --help This help
2470
+ `;
2471
+ async function main() {
2472
+ const [, , sub, ...rest] = process.argv;
2473
+ switch (sub) {
2474
+ case void 0:
2475
+ case "serve":
2476
+ return startServer();
2477
+ case "doctor":
2478
+ process.exit(await runDoctor());
2479
+ return;
2480
+ case "install:mobile":
2481
+ case "install":
2482
+ process.exit(runInstallMobile());
2483
+ return;
2484
+ case "replay": {
2485
+ const target = rest[0];
2486
+ if (!target) {
2487
+ process.stderr.write("Usage: rolepod-uiproof replay <bundle.json>\n");
2488
+ process.exit(2);
2489
+ }
2490
+ process.exit(await runReplay(target));
2491
+ return;
2492
+ }
2493
+ case "--version":
2494
+ case "-v":
2495
+ process.stdout.write(`${SERVER_VERSION}
2496
+ `);
2497
+ return;
2498
+ case "--help":
2499
+ case "-h":
2500
+ case "help":
2501
+ process.stdout.write(HELP);
2502
+ return;
2503
+ default:
2504
+ process.stderr.write(`Unknown subcommand: ${sub}
2505
+ ${HELP}`);
2506
+ process.exit(2);
2507
+ }
2508
+ }
2509
+ async function startServer() {
2510
+ const server = buildServer();
2511
+ const transport = new StdioServerTransport();
2512
+ const shutdown = async (signal) => {
2513
+ log.info("shutting down", { signal });
2514
+ await server.shutdown().catch(
2515
+ (err) => log.error("shutdown failed", { err: String(err) })
2516
+ );
2517
+ process.exit(0);
2518
+ };
2519
+ process.on("SIGINT", () => void shutdown("SIGINT"));
2520
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
2521
+ await server.mcp.connect(transport);
2522
+ log.info("rolepod-uiproof connected on stdio");
2523
+ }
2524
+ main().catch((err) => {
2525
+ log.error("fatal startup error", {
2526
+ err: err instanceof Error ? err.stack : String(err)
2527
+ });
2528
+ process.exit(1);
2529
+ });
2530
+ //# sourceMappingURL=rolepod-uiproof.js.map