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