@rolepod/uiproof 0.4.0 → 0.5.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.
@@ -582,6 +582,34 @@ var AppiumEngine = class {
582
582
  throw new UnsupportedPlatformError(_session.platform);
583
583
  }
584
584
  // -------------------------------------------------------------------------
585
+ // v0.5 cross-platform additions — mobile stubs.
586
+ // These ship as `not_implemented_in_v05` until the mobile gesture work lands.
587
+ // -------------------------------------------------------------------------
588
+ async hover(_session, _ref) {
589
+ throw new RolepodMcpError(
590
+ "engine_error",
591
+ "hover is not yet implemented for mobile (Appium). Use long-press via custom gesture if needed."
592
+ );
593
+ }
594
+ async drag(_session, _fromRef, _toRef) {
595
+ throw new RolepodMcpError(
596
+ "engine_error",
597
+ "drag is not yet implemented for mobile (Appium). Use the W3C Actions API directly if needed."
598
+ );
599
+ }
600
+ async fillForm(session, fields) {
601
+ for (const f of fields) {
602
+ const v = typeof f.value === "boolean" ? String(f.value) : f.value;
603
+ await this.type(session, f.ref, v);
604
+ }
605
+ }
606
+ async uploadFile(_session, _ref, _filePath) {
607
+ throw new RolepodMcpError(
608
+ "engine_error",
609
+ "upload_file is not supported on mobile (Appium)."
610
+ );
611
+ }
612
+ // -------------------------------------------------------------------------
585
613
  // Internals
586
614
  // -------------------------------------------------------------------------
587
615
  async loadWdio() {
@@ -696,6 +724,7 @@ function treeIncludesText(node, text) {
696
724
 
697
725
  // src/engine/PlaywrightEngine.ts
698
726
  import { randomUUID as randomUUID3 } from "crypto";
727
+ import { resolve as resolvePath, isAbsolute } from "path";
699
728
  import {
700
729
  chromium,
701
730
  firefox,
@@ -805,6 +834,83 @@ function parseAriaSnapshot(snapshotYaml) {
805
834
  }
806
835
 
807
836
  // src/engine/PlaywrightEngine.ts
837
+ var CONSOLE_BUFFER_CAP = 1e3;
838
+ var NETWORK_BUFFER_CAP = 1e3;
839
+ var NETWORK_PRESETS = {
840
+ offline: { offline: true, downloadThroughput: 0, uploadThroughput: 0, latency: 0 },
841
+ "slow-3g": {
842
+ offline: false,
843
+ // 500 Kbps down / 500 Kbps up / 400ms RTT
844
+ downloadThroughput: 500 * 1024 / 8,
845
+ uploadThroughput: 500 * 1024 / 8,
846
+ latency: 400
847
+ },
848
+ "fast-3g": {
849
+ offline: false,
850
+ downloadThroughput: 1.5 * 1024 * 1024 / 8,
851
+ uploadThroughput: 750 * 1024 / 8,
852
+ latency: 150
853
+ },
854
+ "slow-4g": {
855
+ offline: false,
856
+ downloadThroughput: 4 * 1024 * 1024 / 8,
857
+ uploadThroughput: 3 * 1024 * 1024 / 8,
858
+ latency: 100
859
+ },
860
+ "fast-4g": {
861
+ offline: false,
862
+ downloadThroughput: 9 * 1024 * 1024 / 8,
863
+ uploadThroughput: 4.5 * 1024 * 1024 / 8,
864
+ latency: 60
865
+ },
866
+ "no-throttling": {
867
+ offline: false,
868
+ downloadThroughput: -1,
869
+ uploadThroughput: -1,
870
+ latency: 0
871
+ }
872
+ };
873
+ function pushRing(buf, entry, cap) {
874
+ buf.push(entry);
875
+ if (buf.length > cap) buf.splice(0, buf.length - cap);
876
+ }
877
+ function mapConsoleLevel(t) {
878
+ switch (t) {
879
+ case "error":
880
+ return "error";
881
+ case "warning":
882
+ return "warning";
883
+ case "info":
884
+ return "info";
885
+ case "debug":
886
+ return "debug";
887
+ case "trace":
888
+ return "trace";
889
+ default:
890
+ return "log";
891
+ }
892
+ }
893
+ function formatConsoleLocation(msg) {
894
+ try {
895
+ const loc = msg.location();
896
+ if (!loc?.url) return void 0;
897
+ return `${loc.url}:${loc.lineNumber}:${loc.columnNumber}`;
898
+ } catch {
899
+ return void 0;
900
+ }
901
+ }
902
+ function findNetworkEntry(buf, req) {
903
+ const url = req.url();
904
+ const method = req.method();
905
+ for (let i = buf.length - 1; i >= 0; i--) {
906
+ const e = buf[i];
907
+ if (!e) continue;
908
+ if (e.url === url && e.method === method && e.status === void 0 && !e.failure) {
909
+ return e;
910
+ }
911
+ }
912
+ return void 0;
913
+ }
808
914
  var PlaywrightEngine = class {
809
915
  id = "playwright";
810
916
  sessions = /* @__PURE__ */ new Map();
@@ -820,35 +926,80 @@ var PlaywrightEngine = class {
820
926
  if (opts.viewport) contextOptions.viewport = opts.viewport;
821
927
  if (opts.user_agent) contextOptions.userAgent = opts.user_agent;
822
928
  if (opts.locale) contextOptions.locale = opts.locale;
929
+ if (opts.capture?.har) {
930
+ contextOptions.recordHar = { path: opts.capture.har.path };
931
+ }
932
+ if (opts.capture?.video) {
933
+ contextOptions.recordVideo = {
934
+ dir: opts.capture.video.dir,
935
+ size: opts.capture.video.sizeWidth && opts.capture.video.sizeHeight ? {
936
+ width: opts.capture.video.sizeWidth,
937
+ height: opts.capture.video.sizeHeight
938
+ } : void 0
939
+ };
940
+ }
823
941
  const context = await browser.newContext(contextOptions);
824
- const page = await context.newPage();
825
- if (opts.url) {
826
- await page.goto(opts.url, { waitUntil: "domcontentloaded" });
942
+ if (opts.capture?.trace) {
943
+ await context.tracing.start({
944
+ screenshots: true,
945
+ snapshots: true,
946
+ sources: false
947
+ });
827
948
  }
949
+ const page = await context.newPage();
828
950
  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,
951
+ const internals = {
952
+ session: {
953
+ id: sessionId,
954
+ platform: "web",
955
+ browser,
956
+ context,
957
+ mainPage: page
958
+ },
838
959
  refIndex: /* @__PURE__ */ new Map(),
839
960
  snapshotGeneration: 0,
840
961
  refGeneration: -1,
841
- lastSnapshotAt: null
962
+ lastSnapshotAt: null,
963
+ pages: [page],
964
+ activePageIndex: 0,
965
+ consoleBuffer: [],
966
+ networkBuffer: [],
967
+ networkInflight: /* @__PURE__ */ new Map(),
968
+ networkNextId: 1,
969
+ dialogArming: null,
970
+ captureOpts: opts.capture,
971
+ traceStarted: !!opts.capture?.trace
972
+ };
973
+ this.attachPageListeners(internals, page);
974
+ context.on("page", (newPage) => {
975
+ internals.pages.push(newPage);
976
+ this.attachPageListeners(internals, newPage);
842
977
  });
978
+ if (opts.url) {
979
+ await page.goto(opts.url, { waitUntil: "domcontentloaded" });
980
+ }
981
+ this.sessions.set(sessionId, internals);
843
982
  log.info("session opened", {
844
983
  session_id: sessionId,
845
984
  browser: browserName,
846
- url: opts.url ?? null
985
+ url: opts.url ?? null,
986
+ capture: opts.capture ? Object.keys(opts.capture).filter(
987
+ (k) => opts.capture[k]
988
+ ) : []
847
989
  });
848
990
  return { id: sessionId, platform: "web" };
849
991
  }
850
992
  async close(session) {
851
993
  const s = this.requireSession(session.id);
994
+ if (s.traceStarted && s.captureOpts?.trace) {
995
+ const tracePath = resolvePath(s.captureOpts.trace.artifactDir, "trace.zip");
996
+ await s.session.context.tracing.stop({ path: tracePath }).catch((err) => {
997
+ log.warn("trace stop failed", {
998
+ session_id: session.id,
999
+ err: String(err)
1000
+ });
1001
+ });
1002
+ }
852
1003
  await s.session.context.close().catch((err) => {
853
1004
  log.warn("context close failed", { session_id: session.id, err: String(err) });
854
1005
  });
@@ -860,7 +1011,7 @@ var PlaywrightEngine = class {
860
1011
  }
861
1012
  async snapshot(session, mode = "visible") {
862
1013
  const s = this.requireSession(session.id);
863
- const ariaYaml = await s.session.page.ariaSnapshot({ mode: "ai" });
1014
+ const ariaYaml = await this.activePage(s).ariaSnapshot({ mode: "ai" });
864
1015
  const { tree, refIndex } = parseAriaSnapshot(ariaYaml);
865
1016
  void mode;
866
1017
  s.snapshotGeneration += 1;
@@ -870,7 +1021,7 @@ var PlaywrightEngine = class {
870
1021
  return {
871
1022
  session_id: session.id,
872
1023
  platform: "web",
873
- url_or_screen: s.session.page.url(),
1024
+ url_or_screen: this.activePage(s).url(),
874
1025
  taken_at: s.lastSnapshotAt,
875
1026
  tree
876
1027
  };
@@ -890,7 +1041,7 @@ var PlaywrightEngine = class {
890
1041
  }
891
1042
  async key(session, key) {
892
1043
  const s = this.requireSession(session.id);
893
- await s.session.page.keyboard.press(key);
1044
+ await this.activePage(s).keyboard.press(key);
894
1045
  this.invalidateRefs(s);
895
1046
  }
896
1047
  async scroll(session, dir, amount = 400, ref) {
@@ -904,13 +1055,13 @@ var PlaywrightEngine = class {
904
1055
  [dx, dy]
905
1056
  );
906
1057
  } else {
907
- await s.session.page.mouse.wheel(dx, dy);
1058
+ await this.activePage(s).mouse.wheel(dx, dy);
908
1059
  }
909
1060
  this.invalidateRefs(s);
910
1061
  }
911
1062
  async waitFor(session, cond, timeoutMs = 1e4) {
912
1063
  const s = this.requireSession(session.id);
913
- const page = s.session.page;
1064
+ const page = this.activePage(s);
914
1065
  switch (cond.kind) {
915
1066
  case "text_visible":
916
1067
  await page.getByText(cond.text, { exact: false }).first().waitFor({ state: "visible", timeout: timeoutMs });
@@ -930,14 +1081,14 @@ var PlaywrightEngine = class {
930
1081
  }
931
1082
  async screenshot(session, fullPage = false) {
932
1083
  const s = this.requireSession(session.id);
933
- return s.session.page.screenshot({ fullPage });
1084
+ return this.activePage(s).screenshot({ fullPage });
934
1085
  }
935
1086
  async navigate(session, url) {
936
1087
  const s = this.requireSession(session.id);
937
1088
  if (s.session.platform !== "web") {
938
1089
  throw new UnsupportedPlatformError(s.session.platform);
939
1090
  }
940
- await s.session.page.goto(url, { waitUntil: "domcontentloaded" });
1091
+ await this.activePage(s).goto(url, { waitUntil: "domcontentloaded" });
941
1092
  this.invalidateRefs(s);
942
1093
  }
943
1094
  /**
@@ -951,7 +1102,7 @@ var PlaywrightEngine = class {
951
1102
  if (s.session.platform !== "web") {
952
1103
  throw new UnsupportedPlatformError(s.session.platform);
953
1104
  }
954
- return s.session.page;
1105
+ return this.activePage(s);
955
1106
  }
956
1107
  /** Increment generation; the next ref-using call will see them as stale. */
957
1108
  bumpGeneration(sessionId) {
@@ -959,8 +1110,311 @@ var PlaywrightEngine = class {
959
1110
  this.invalidateRefs(s);
960
1111
  }
961
1112
  // -------------------------------------------------------------------------
1113
+ // v0.5 — input additions
1114
+ // -------------------------------------------------------------------------
1115
+ async hover(session, ref) {
1116
+ const s = this.requireSession(session.id);
1117
+ const locator = this.resolveLocator(s, ref);
1118
+ await locator.hover();
1119
+ }
1120
+ async drag(session, fromRef, toRef) {
1121
+ const s = this.requireSession(session.id);
1122
+ const from = this.resolveLocator(s, fromRef);
1123
+ const to = this.resolveLocator(s, toRef);
1124
+ await from.dragTo(to);
1125
+ this.invalidateRefs(s);
1126
+ }
1127
+ async fillForm(session, fields) {
1128
+ const s = this.requireSession(session.id);
1129
+ for (const field of fields) {
1130
+ const locator = this.resolveLocator(s, field.ref);
1131
+ const kind = field.kind;
1132
+ if (kind === "checkbox" || kind === "radio") {
1133
+ const checked = typeof field.value === "boolean" ? field.value : field.value === "true" || field.value === "on";
1134
+ await locator.setChecked(checked);
1135
+ } else if (kind === "select") {
1136
+ await locator.selectOption(String(field.value));
1137
+ } else {
1138
+ await locator.fill(String(field.value));
1139
+ }
1140
+ }
1141
+ this.invalidateRefs(s);
1142
+ }
1143
+ async uploadFile(session, ref, filePath) {
1144
+ const s = this.requireSession(session.id);
1145
+ if (!isAbsolute(filePath)) {
1146
+ throw new RolepodMcpError(
1147
+ "invalid_input",
1148
+ `upload_file requires an absolute path; got "${filePath}".`,
1149
+ { file_path: filePath }
1150
+ );
1151
+ }
1152
+ const locator = this.resolveLocator(s, ref);
1153
+ await locator.setInputFiles(filePath);
1154
+ this.invalidateRefs(s);
1155
+ }
1156
+ // -------------------------------------------------------------------------
1157
+ // v0.5 — web-only extensions (not on Engine interface; tools cast to
1158
+ // PlaywrightEngine before calling).
1159
+ // -------------------------------------------------------------------------
1160
+ /**
1161
+ * Pre-arm a one-shot dialog handler for the next dialog raised on the
1162
+ * active page. Returns when either the dialog fires (and is handled)
1163
+ * or the timeout elapses. The caller is expected to trigger the
1164
+ * dialog (via click etc.) AFTER arming.
1165
+ */
1166
+ async handleDialog(sessionId, opts) {
1167
+ const s = this.requireSession(sessionId);
1168
+ const timeoutMs = opts.timeoutMs ?? 3e4;
1169
+ const expiresAt = Date.now() + timeoutMs;
1170
+ if (s.dialogArming) {
1171
+ s.dialogArming.resolve(false);
1172
+ }
1173
+ return new Promise((resolve6) => {
1174
+ const arming = {
1175
+ action: opts.action,
1176
+ text: opts.text,
1177
+ expiresAt,
1178
+ resolve: (handled) => {
1179
+ s.dialogArming = null;
1180
+ resolve6({ handled });
1181
+ }
1182
+ };
1183
+ s.dialogArming = arming;
1184
+ const timer = setTimeout(() => {
1185
+ if (s.dialogArming === arming) {
1186
+ s.dialogArming = null;
1187
+ resolve6({ handled: false });
1188
+ }
1189
+ }, timeoutMs);
1190
+ timer.unref?.();
1191
+ });
1192
+ }
1193
+ getConsole(sessionId, opts) {
1194
+ const s = this.requireSession(sessionId);
1195
+ const levels = opts?.levels;
1196
+ const contains = opts?.contains;
1197
+ const limit = opts?.limit ?? 50;
1198
+ let entries = s.consoleBuffer;
1199
+ if (levels && levels.length > 0) {
1200
+ entries = entries.filter((e) => levels.includes(e.level));
1201
+ }
1202
+ if (contains) {
1203
+ entries = entries.filter((e) => e.text.includes(contains));
1204
+ }
1205
+ const result = entries.slice(-limit);
1206
+ if (opts?.clear) s.consoleBuffer = [];
1207
+ return result;
1208
+ }
1209
+ getNetwork(sessionId, opts) {
1210
+ const s = this.requireSession(sessionId);
1211
+ let entries = s.networkBuffer;
1212
+ if (opts?.urlPattern) {
1213
+ if (opts.patternKind === "regex") {
1214
+ const re = new RegExp(opts.urlPattern);
1215
+ entries = entries.filter((e) => re.test(e.url));
1216
+ } else {
1217
+ entries = entries.filter((e) => e.url.includes(opts.urlPattern));
1218
+ }
1219
+ }
1220
+ if (opts?.method) {
1221
+ const m = opts.method.toUpperCase();
1222
+ entries = entries.filter((e) => e.method.toUpperCase() === m);
1223
+ }
1224
+ if (opts?.statusRange) {
1225
+ const { min, max } = opts.statusRange;
1226
+ entries = entries.filter(
1227
+ (e) => e.status !== void 0 && e.status >= min && e.status <= max
1228
+ );
1229
+ }
1230
+ if (opts?.onlyFailed) {
1231
+ entries = entries.filter(
1232
+ (e) => !!e.failure || e.status !== void 0 && e.status >= 400
1233
+ );
1234
+ }
1235
+ const limit = opts?.limit ?? 50;
1236
+ const result = entries.slice(-limit);
1237
+ if (opts?.clear) s.networkBuffer = [];
1238
+ return result;
1239
+ }
1240
+ /**
1241
+ * Read the consoleBuffer/networkBuffer directly without filtering —
1242
+ * used by verify_ui_flow expect evaluators.
1243
+ */
1244
+ peekBuffers(sessionId) {
1245
+ const s = this.requireSession(sessionId);
1246
+ return { console: s.consoleBuffer, network: s.networkBuffer };
1247
+ }
1248
+ /**
1249
+ * Runtime mutation of context-level emulation. CPU + network throttle
1250
+ * use CDP and only work on chromium; everything else is cross-browser.
1251
+ */
1252
+ async setEnv(sessionId, opts) {
1253
+ const s = this.requireSession(sessionId);
1254
+ const page = this.activePage(s);
1255
+ const ctx = s.session.context;
1256
+ if (opts.viewport) {
1257
+ await page.setViewportSize(opts.viewport);
1258
+ }
1259
+ if (opts.offline !== void 0) {
1260
+ await ctx.setOffline(opts.offline);
1261
+ }
1262
+ if (opts.geolocation) {
1263
+ await ctx.setGeolocation(opts.geolocation);
1264
+ }
1265
+ if (opts.extraHeaders) {
1266
+ await ctx.setExtraHTTPHeaders(opts.extraHeaders);
1267
+ }
1268
+ if (opts.colorScheme || opts.reducedMotion) {
1269
+ await page.emulateMedia({
1270
+ ...opts.colorScheme ? { colorScheme: opts.colorScheme } : {},
1271
+ ...opts.reducedMotion ? { reducedMotion: opts.reducedMotion } : {}
1272
+ });
1273
+ }
1274
+ if (opts.networkThrottle || opts.cpuThrottle !== void 0) {
1275
+ const browserName = ctx.browser()?.browserType().name();
1276
+ if (browserName !== "chromium") {
1277
+ throw new RolepodMcpError(
1278
+ "unsupported_engine",
1279
+ `networkThrottle / cpuThrottle require chromium (CDP-backed); current browser is "${browserName}".`
1280
+ );
1281
+ }
1282
+ const cdp = await ctx.newCDPSession(page);
1283
+ try {
1284
+ if (opts.networkThrottle) {
1285
+ const preset = NETWORK_PRESETS[opts.networkThrottle];
1286
+ await cdp.send("Network.enable");
1287
+ await cdp.send("Network.emulateNetworkConditions", preset);
1288
+ }
1289
+ if (opts.cpuThrottle !== void 0) {
1290
+ await cdp.send("Emulation.setCPUThrottlingRate", {
1291
+ rate: opts.cpuThrottle
1292
+ });
1293
+ }
1294
+ } finally {
1295
+ await cdp.detach().catch(() => void 0);
1296
+ }
1297
+ }
1298
+ this.invalidateRefs(s);
1299
+ }
1300
+ /**
1301
+ * Execute a JavaScript function in the page context. ALWAYS gated by
1302
+ * the tool layer (`ROLEPOD_ALLOW_EVAL=1`); this method does not enforce
1303
+ * the env check.
1304
+ */
1305
+ async evaluate(sessionId, script, args) {
1306
+ const s = this.requireSession(sessionId);
1307
+ const page = this.activePage(s);
1308
+ return page.evaluate(
1309
+ ({ src, a }) => (
1310
+ // eslint-disable-next-line no-new-func
1311
+ new Function("args", `return (async () => { ${src} })();`)(a)
1312
+ ),
1313
+ { src: script, a: args ?? [] }
1314
+ );
1315
+ }
1316
+ listPages(sessionId) {
1317
+ const s = this.requireSession(sessionId);
1318
+ return s.pages.map((p, i) => ({
1319
+ index: i,
1320
+ url: p.url(),
1321
+ title_promise: p.title(),
1322
+ active: i === s.activePageIndex
1323
+ }));
1324
+ }
1325
+ async switchPage(sessionId, index) {
1326
+ const s = this.requireSession(sessionId);
1327
+ if (index < 0 || index >= s.pages.length) {
1328
+ throw new RolepodMcpError(
1329
+ "invalid_input",
1330
+ `Page index ${index} out of range (have ${s.pages.length} page(s)).`,
1331
+ { index, available: s.pages.length }
1332
+ );
1333
+ }
1334
+ s.activePageIndex = index;
1335
+ this.invalidateRefs(s);
1336
+ }
1337
+ // -------------------------------------------------------------------------
962
1338
  // Internal helpers
963
1339
  // -------------------------------------------------------------------------
1340
+ activePage(s) {
1341
+ return s.pages[s.activePageIndex] ?? s.session.mainPage;
1342
+ }
1343
+ attachPageListeners(s, page) {
1344
+ page.on("console", (msg) => {
1345
+ const level = mapConsoleLevel(msg.type());
1346
+ pushRing(
1347
+ s.consoleBuffer,
1348
+ {
1349
+ level,
1350
+ text: msg.text(),
1351
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1352
+ location: formatConsoleLocation(msg)
1353
+ },
1354
+ CONSOLE_BUFFER_CAP
1355
+ );
1356
+ });
1357
+ page.on("request", (req) => {
1358
+ const id = s.networkNextId++;
1359
+ s.networkInflight.set(req.url() + "::" + req.method() + "::" + id, {
1360
+ id,
1361
+ startedAt: Date.now(),
1362
+ resourceType: req.resourceType()
1363
+ });
1364
+ pushRing(
1365
+ s.networkBuffer,
1366
+ {
1367
+ id,
1368
+ url: req.url(),
1369
+ method: req.method(),
1370
+ resource_type: req.resourceType(),
1371
+ ts: (/* @__PURE__ */ new Date()).toISOString()
1372
+ },
1373
+ NETWORK_BUFFER_CAP
1374
+ );
1375
+ });
1376
+ page.on("response", (res) => {
1377
+ const req = res.request();
1378
+ const entry = findNetworkEntry(s.networkBuffer, req);
1379
+ if (entry) {
1380
+ entry.status = res.status();
1381
+ entry.duration_ms = Date.now() - new Date(entry.ts).getTime();
1382
+ }
1383
+ });
1384
+ page.on("requestfailed", (req) => {
1385
+ const entry = findNetworkEntry(s.networkBuffer, req);
1386
+ if (entry) {
1387
+ entry.failure = req.failure()?.errorText ?? "request failed";
1388
+ }
1389
+ });
1390
+ page.on("dialog", (dialog) => {
1391
+ void this.handlePageDialog(s, dialog);
1392
+ });
1393
+ }
1394
+ async handlePageDialog(s, dialog) {
1395
+ const arm = s.dialogArming;
1396
+ if (!arm || Date.now() > arm.expiresAt) {
1397
+ await dialog.dismiss().catch(() => void 0);
1398
+ if (arm) arm.resolve(false);
1399
+ return;
1400
+ }
1401
+ try {
1402
+ if (arm.action === "accept") {
1403
+ await dialog.accept();
1404
+ } else if (arm.action === "accept_with_text") {
1405
+ await dialog.accept(arm.text ?? "");
1406
+ } else {
1407
+ await dialog.dismiss();
1408
+ }
1409
+ arm.resolve(true);
1410
+ } catch (err) {
1411
+ log.warn("dialog handle failed", {
1412
+ session_id: s.session.id,
1413
+ err: String(err)
1414
+ });
1415
+ arm.resolve(false);
1416
+ }
1417
+ }
964
1418
  requireSession(sessionId) {
965
1419
  const s = this.sessions.get(sessionId);
966
1420
  if (!s) {
@@ -989,7 +1443,7 @@ var PlaywrightEngine = class {
989
1443
  if (meta.ref.startsWith("s")) {
990
1444
  throw new UnknownRefError(s.session.id, ref);
991
1445
  }
992
- return s.session.page.locator(`aria-ref=${meta.ref}`);
1446
+ return this.activePage(s).locator(`aria-ref=${meta.ref}`);
993
1447
  }
994
1448
  invalidateRefs(s) {
995
1449
  s.snapshotGeneration += 1;
@@ -1125,6 +1579,10 @@ var SessionRegistry = class {
1125
1579
  }
1126
1580
  };
1127
1581
 
1582
+ // src/tools/composite/verify_ui_flow.ts
1583
+ import { readdir } from "fs/promises";
1584
+ import { resolve as resolvePath2 } from "path";
1585
+
1128
1586
  // src/schema/tools.ts
1129
1587
  import { z } from "zod";
1130
1588
  var platformSchema = z.enum(["web", "ios", "android"]);
@@ -1228,6 +1686,143 @@ var browserNavigateShape = {
1228
1686
  url: z.string().url()
1229
1687
  };
1230
1688
  var browserNavigateSchema = z.object(browserNavigateShape);
1689
+ var browserHoverShape = {
1690
+ session_id: z.string().min(1),
1691
+ ref: z.string().min(1)
1692
+ };
1693
+ var browserHoverSchema = z.object(browserHoverShape);
1694
+ var browserDragShape = {
1695
+ session_id: z.string().min(1),
1696
+ from_ref: z.string().min(1),
1697
+ to_ref: z.string().min(1)
1698
+ };
1699
+ var browserDragSchema = z.object(browserDragShape);
1700
+ var fillFieldKindSchema = z.enum([
1701
+ "input",
1702
+ "select",
1703
+ "checkbox",
1704
+ "radio"
1705
+ ]);
1706
+ var fillFormFieldSchema = z.object({
1707
+ ref: z.string().min(1),
1708
+ value: z.union([z.string(), z.boolean()]),
1709
+ kind: fillFieldKindSchema.optional()
1710
+ });
1711
+ var browserFillFormShape = {
1712
+ session_id: z.string().min(1),
1713
+ fields: z.array(fillFormFieldSchema).min(1)
1714
+ };
1715
+ var browserFillFormSchema = z.object(browserFillFormShape);
1716
+ var browserUploadFileShape = {
1717
+ session_id: z.string().min(1),
1718
+ ref: z.string().min(1),
1719
+ file_path: z.string().min(1)
1720
+ };
1721
+ var browserUploadFileSchema = z.object(browserUploadFileShape);
1722
+ var dialogActionSchema = z.enum([
1723
+ "accept",
1724
+ "dismiss",
1725
+ "accept_with_text"
1726
+ ]);
1727
+ var browserHandleDialogShape = {
1728
+ session_id: z.string().min(1),
1729
+ action: dialogActionSchema,
1730
+ /** Only used when action='accept_with_text'. */
1731
+ text: z.string().optional(),
1732
+ /**
1733
+ * Arming behavior: registers a one-shot handler for the NEXT dialog
1734
+ * raised on the page. Call this BEFORE the action that triggers the
1735
+ * dialog (e.g. before clicking the button that calls `confirm()`).
1736
+ * Default 30s if no dialog appears, handler is auto-removed.
1737
+ */
1738
+ timeout_ms: z.number().int().positive().optional()
1739
+ };
1740
+ var browserHandleDialogSchema = z.object(browserHandleDialogShape);
1741
+ var consoleLevelSchema = z.enum([
1742
+ "error",
1743
+ "warning",
1744
+ "info",
1745
+ "log",
1746
+ "debug",
1747
+ "trace"
1748
+ ]);
1749
+ var browserConsoleShape = {
1750
+ session_id: z.string().min(1),
1751
+ /** Filter to only these levels. Default: errors+warnings. */
1752
+ levels: z.array(consoleLevelSchema).optional(),
1753
+ /** Substring match on message text. */
1754
+ contains: z.string().optional(),
1755
+ /** Drop all buffered messages after returning. */
1756
+ clear: z.boolean().default(false),
1757
+ /** Cap on returned messages (artifact still holds full ring buffer). */
1758
+ limit: z.number().int().positive().max(1e3).default(50)
1759
+ };
1760
+ var browserConsoleSchema = z.object(browserConsoleShape);
1761
+ var browserNetworkShape = {
1762
+ session_id: z.string().min(1),
1763
+ /** Substring or regex (per `pattern_kind`) match on URL. */
1764
+ url_pattern: z.string().optional(),
1765
+ pattern_kind: z.enum(["substring", "regex"]).default("substring"),
1766
+ method: z.string().optional(),
1767
+ /** Inclusive range — e.g. `{min: 400, max: 599}` for any error response. */
1768
+ status_range: z.object({
1769
+ min: z.number().int().min(100).max(599),
1770
+ max: z.number().int().min(100).max(599)
1771
+ }).optional(),
1772
+ only_failed: z.boolean().default(false),
1773
+ /** Write the full HAR file for this session to artifacts/{runId}/network.har. */
1774
+ export_har: z.boolean().default(false),
1775
+ /** Drop buffered entries after returning. */
1776
+ clear: z.boolean().default(false),
1777
+ limit: z.number().int().positive().max(1e3).default(50)
1778
+ };
1779
+ var browserNetworkSchema = z.object(browserNetworkShape);
1780
+ var networkPresetSchema = z.enum([
1781
+ "offline",
1782
+ "slow-3g",
1783
+ "fast-3g",
1784
+ "slow-4g",
1785
+ "fast-4g",
1786
+ "no-throttling"
1787
+ ]);
1788
+ var geolocationSchema = z.object({
1789
+ latitude: z.number().min(-90).max(90),
1790
+ longitude: z.number().min(-180).max(180),
1791
+ accuracy: z.number().nonnegative().optional()
1792
+ });
1793
+ var browserSetEnvShape = {
1794
+ session_id: z.string().min(1),
1795
+ viewport: viewportSchema.optional(),
1796
+ offline: z.boolean().optional(),
1797
+ geolocation: geolocationSchema.optional(),
1798
+ color_scheme: z.enum(["light", "dark", "no-preference"]).optional(),
1799
+ reduced_motion: z.enum(["reduce", "no-preference"]).optional(),
1800
+ extra_headers: z.record(z.string(), z.string()).optional(),
1801
+ network_throttle: networkPresetSchema.optional(),
1802
+ /** CPU slowdown multiplier (1 = no throttle, 4 = 4x slower). Chromium only. */
1803
+ cpu_throttle: z.number().min(1).max(20).optional()
1804
+ };
1805
+ var browserSetEnvSchema = z.object(browserSetEnvShape);
1806
+ var browserEvaluateShape = {
1807
+ session_id: z.string().min(1),
1808
+ script: z.string().min(1),
1809
+ args: z.array(z.unknown()).optional()
1810
+ };
1811
+ var browserEvaluateSchema = z.object(browserEvaluateShape);
1812
+ var browserPagesShape = {
1813
+ session_id: z.string().min(1)
1814
+ };
1815
+ var browserPagesSchema = z.object(browserPagesShape);
1816
+ var browserSwitchPageShape = {
1817
+ session_id: z.string().min(1),
1818
+ index: z.number().int().nonnegative()
1819
+ };
1820
+ var browserSwitchPageSchema = z.object(browserSwitchPageShape);
1821
+ var verifyFillFieldSchema = z.object({
1822
+ query: z.string(),
1823
+ value: z.union([z.string(), z.boolean()]),
1824
+ kind: fillFieldKindSchema.optional()
1825
+ });
1231
1826
  var verifyStepSchema = z.discriminatedUnion("kind", [
1232
1827
  z.object({ kind: z.literal("click"), query: z.string() }),
1233
1828
  z.object({
@@ -1238,7 +1833,44 @@ var verifyStepSchema = z.discriminatedUnion("kind", [
1238
1833
  }),
1239
1834
  z.object({ kind: z.literal("key"), key: z.string() }),
1240
1835
  z.object({ kind: z.literal("wait_for"), condition: waitConditionSchema }),
1241
- z.object({ kind: z.literal("navigate"), url: z.string().url() })
1836
+ z.object({ kind: z.literal("navigate"), url: z.string().url() }),
1837
+ // v0.5 additions
1838
+ z.object({ kind: z.literal("hover"), query: z.string() }),
1839
+ z.object({
1840
+ kind: z.literal("drag"),
1841
+ from_query: z.string(),
1842
+ to_query: z.string()
1843
+ }),
1844
+ z.object({
1845
+ kind: z.literal("fill_form"),
1846
+ fields: z.array(verifyFillFieldSchema).min(1)
1847
+ }),
1848
+ z.object({
1849
+ kind: z.literal("upload"),
1850
+ query: z.string(),
1851
+ file_path: z.string().min(1)
1852
+ }),
1853
+ z.object({
1854
+ kind: z.literal("dialog"),
1855
+ action: dialogActionSchema,
1856
+ text: z.string().optional()
1857
+ }),
1858
+ z.object({
1859
+ kind: z.literal("set_env"),
1860
+ viewport: viewportSchema.optional(),
1861
+ offline: z.boolean().optional(),
1862
+ geolocation: geolocationSchema.optional(),
1863
+ color_scheme: z.enum(["light", "dark", "no-preference"]).optional(),
1864
+ reduced_motion: z.enum(["reduce", "no-preference"]).optional(),
1865
+ extra_headers: z.record(z.string(), z.string()).optional(),
1866
+ network_throttle: networkPresetSchema.optional(),
1867
+ cpu_throttle: z.number().min(1).max(20).optional()
1868
+ }),
1869
+ z.object({
1870
+ kind: z.literal("switch_page"),
1871
+ index: z.number().int().nonnegative()
1872
+ }),
1873
+ z.object({ kind: z.literal("evaluate"), script: z.string().min(1) })
1242
1874
  ]);
1243
1875
  var verifyExpectSchema = z.discriminatedUnion("kind", [
1244
1876
  z.object({ kind: z.literal("text_visible"), text: z.string() }),
@@ -1248,6 +1880,28 @@ var verifyExpectSchema = z.discriminatedUnion("kind", [
1248
1880
  kind: z.literal("ref_in_state"),
1249
1881
  query: z.string(),
1250
1882
  state: z.enum(["visible", "enabled", "focused"])
1883
+ }),
1884
+ // v0.5 additions
1885
+ z.object({
1886
+ kind: z.literal("no_console_errors"),
1887
+ exclude_patterns: z.array(z.string()).optional()
1888
+ }),
1889
+ z.object({
1890
+ kind: z.literal("no_failed_requests"),
1891
+ exclude_patterns: z.array(z.string()).optional(),
1892
+ /** When true, only 5xx counts as a failure. Default false (4xx + 5xx). */
1893
+ allow_4xx: z.boolean().optional()
1894
+ }),
1895
+ z.object({
1896
+ kind: z.literal("request_made"),
1897
+ url_pattern: z.string(),
1898
+ method: z.string().optional(),
1899
+ min_count: z.number().int().positive().optional()
1900
+ }),
1901
+ z.object({
1902
+ kind: z.literal("response_status"),
1903
+ url_pattern: z.string(),
1904
+ status: z.number().int().min(100).max(599)
1251
1905
  })
1252
1906
  ]);
1253
1907
  var captureKindSchema = z.enum([
@@ -1255,7 +1909,8 @@ var captureKindSchema = z.enum([
1255
1909
  "har",
1256
1910
  "console",
1257
1911
  "a11y_tree",
1258
- "video"
1912
+ "video",
1913
+ "trace"
1259
1914
  ]);
1260
1915
  var verifyUiFlowShape = {
1261
1916
  mode: z.enum(["assert", "reproduce"]).default("assert"),
@@ -1333,6 +1988,19 @@ var ToolNames = {
1333
1988
  browserWaitFor: "rolepod_browser_wait_for",
1334
1989
  browserScreenshot: "rolepod_browser_screenshot",
1335
1990
  browserNavigate: "rolepod_browser_navigate",
1991
+ // v0.5 atomics
1992
+ browserHover: "rolepod_browser_hover",
1993
+ browserDrag: "rolepod_browser_drag",
1994
+ browserFillForm: "rolepod_browser_fill_form",
1995
+ browserUploadFile: "rolepod_browser_upload_file",
1996
+ browserHandleDialog: "rolepod_browser_handle_dialog",
1997
+ browserConsole: "rolepod_browser_console",
1998
+ browserNetwork: "rolepod_browser_network",
1999
+ browserSetEnv: "rolepod_browser_set_env",
2000
+ browserEvaluate: "rolepod_browser_evaluate",
2001
+ browserPages: "rolepod_browser_pages",
2002
+ browserSwitchPage: "rolepod_browser_switch_page",
2003
+ // composite
1336
2004
  verifyUiFlow: "rolepod_verify_ui_flow",
1337
2005
  auditA11y: "rolepod_audit_a11y",
1338
2006
  visualDiff: "rolepod_visual_diff",
@@ -1437,13 +2105,32 @@ var verifyUiFlowTool = {
1437
2105
  });
1438
2106
  }
1439
2107
  };
2108
+ function buildCaptureOptions(captures, runDir) {
2109
+ const cap = {};
2110
+ if (captures.has("har")) {
2111
+ cap.har = { path: resolvePath2(runDir, "network.har") };
2112
+ }
2113
+ if (captures.has("video")) {
2114
+ cap.video = { dir: resolvePath2(runDir, "videos") };
2115
+ }
2116
+ if (captures.has("trace")) {
2117
+ cap.trace = { artifactDir: runDir };
2118
+ }
2119
+ return Object.keys(cap).length > 0 ? cap : void 0;
2120
+ }
1440
2121
  async function runFlow(ctx, args, steps, runDir, opts) {
1441
2122
  const evidence = { screenshots: [] };
2123
+ const captures = new Set(args.capture ?? ["screenshot"]);
1442
2124
  let passed = false;
1443
2125
  let failedAtStep;
1444
2126
  let failureReason;
1445
2127
  let finalSnapshot;
1446
- const session = await ctx.registry.open(args.open);
2128
+ const openOpts = { ...args.open };
2129
+ const captureCfg = buildCaptureOptions(captures, runDir);
2130
+ if (captureCfg) {
2131
+ openOpts.capture = captureCfg;
2132
+ }
2133
+ const session = await ctx.registry.open(openOpts);
1447
2134
  const engine = ctx.registry.engineFor(session.id);
1448
2135
  const sessionHandle = { id: session.id, platform: session.platform };
1449
2136
  try {
@@ -1462,7 +2149,7 @@ async function runFlow(ctx, args, steps, runDir, opts) {
1462
2149
  const failures = [];
1463
2150
  for (let i = 0; i < args.expect.length; i++) {
1464
2151
  const expectation = args.expect[i];
1465
- if (!evaluateExpect(expectation, finalSnapshot)) {
2152
+ if (!evaluateExpect(expectation, finalSnapshot, engine, session.id)) {
1466
2153
  failures.push(`expect[${i}] ${describeExpect(expectation)}`);
1467
2154
  }
1468
2155
  }
@@ -1476,8 +2163,7 @@ async function runFlow(ctx, args, steps, runDir, opts) {
1476
2163
  passed = false;
1477
2164
  } finally {
1478
2165
  if (opts.captureEvidence) {
1479
- const wantScreenshot = !args.capture || args.capture.includes("screenshot");
1480
- if (wantScreenshot) {
2166
+ if (captures.has("screenshot")) {
1481
2167
  try {
1482
2168
  const buf = await engine.screenshot(sessionHandle, true);
1483
2169
  const p = await ctx.store.writeScreenshot(runDir, buf, "final");
@@ -1486,6 +2172,37 @@ async function runFlow(ctx, args, steps, runDir, opts) {
1486
2172
  failureReason ??= `screenshot capture failed: ${describeError(err)}`;
1487
2173
  }
1488
2174
  }
2175
+ if (captures.has("console") && engine instanceof PlaywrightEngine) {
2176
+ try {
2177
+ const messages = engine.peekBuffers(session.id).console;
2178
+ evidence.console = await ctx.store.writeReport(
2179
+ runDir,
2180
+ "console.json",
2181
+ JSON.stringify(
2182
+ {
2183
+ count: messages.length,
2184
+ by_level: countByLevel(messages),
2185
+ messages
2186
+ },
2187
+ null,
2188
+ 2
2189
+ )
2190
+ );
2191
+ } catch (err) {
2192
+ failureReason ??= `console capture failed: ${describeError(err)}`;
2193
+ }
2194
+ }
2195
+ if (captures.has("a11y_tree") && finalSnapshot) {
2196
+ try {
2197
+ evidence.a11y_tree = await ctx.store.writeReport(
2198
+ runDir,
2199
+ "a11y_tree.json",
2200
+ JSON.stringify(finalSnapshot, null, 2)
2201
+ );
2202
+ } catch (err) {
2203
+ failureReason ??= `a11y_tree capture failed: ${describeError(err)}`;
2204
+ }
2205
+ }
1489
2206
  try {
1490
2207
  evidence.replay_bundle = await ctx.store.writeReplayBundle(
1491
2208
  runDir,
@@ -1506,12 +2223,32 @@ async function runFlow(ctx, args, steps, runDir, opts) {
1506
2223
  await ctx.registry.close(sessionHandle).catch(() => void 0);
1507
2224
  }
1508
2225
  }
2226
+ if (opts.captureEvidence) {
2227
+ if (captureCfg?.har) evidence.har = captureCfg.har.path;
2228
+ if (captureCfg?.trace) {
2229
+ evidence.trace = resolvePath2(captureCfg.trace.artifactDir, "trace.zip");
2230
+ }
2231
+ if (captureCfg?.video) {
2232
+ try {
2233
+ const files = await readdir(captureCfg.video.dir).catch(() => []);
2234
+ evidence.video = files.filter((f) => f.endsWith(".webm")).map((f) => resolvePath2(captureCfg.video.dir, f));
2235
+ } catch {
2236
+ }
2237
+ }
2238
+ }
1509
2239
  const out = { passed, evidence };
1510
2240
  if (failedAtStep !== void 0) out.failedAtStep = failedAtStep;
1511
2241
  if (failureReason !== void 0) out.failureReason = failureReason;
1512
2242
  if (finalSnapshot) out.finalUrl = finalSnapshot.url_or_screen;
1513
2243
  return out;
1514
2244
  }
2245
+ function countByLevel(messages) {
2246
+ const counts = {};
2247
+ for (const m of messages) {
2248
+ counts[m.level] = (counts[m.level] ?? 0) + 1;
2249
+ }
2250
+ return counts;
2251
+ }
1515
2252
  async function minimize(ctx, args, initialSteps, runDir) {
1516
2253
  const tagged = initialSteps.map((step, origIndex) => ({ step, origIndex }));
1517
2254
  let attempts = 0;
@@ -1575,9 +2312,84 @@ async function runStep(engine, session, step, snap) {
1575
2312
  case "navigate":
1576
2313
  await engine.navigate(session, step.url);
1577
2314
  return;
2315
+ case "hover": {
2316
+ const ref = findRefByQuery(snap.tree, step.query);
2317
+ if (!ref) throw missingQuery(step.query);
2318
+ await engine.hover(session, ref);
2319
+ return;
2320
+ }
2321
+ case "drag": {
2322
+ const fromRef = findRefByQuery(snap.tree, step.from_query);
2323
+ if (!fromRef) throw missingQuery(step.from_query);
2324
+ const toRef = findRefByQuery(snap.tree, step.to_query);
2325
+ if (!toRef) throw missingQuery(step.to_query);
2326
+ await engine.drag(session, fromRef, toRef);
2327
+ return;
2328
+ }
2329
+ case "fill_form": {
2330
+ const resolved = step.fields.map((f) => {
2331
+ const ref = findRefByQuery(snap.tree, f.query);
2332
+ if (!ref) throw missingQuery(f.query);
2333
+ return f.kind !== void 0 ? { ref, value: f.value, kind: f.kind } : { ref, value: f.value };
2334
+ });
2335
+ await engine.fillForm(session, resolved);
2336
+ return;
2337
+ }
2338
+ case "upload": {
2339
+ const ref = findRefByQuery(snap.tree, step.query);
2340
+ if (!ref) throw missingQuery(step.query);
2341
+ await engine.uploadFile(session, ref, step.file_path);
2342
+ return;
2343
+ }
2344
+ case "dialog": {
2345
+ requirePlaywright(engine, "dialog");
2346
+ void engine.handleDialog(session.id, {
2347
+ action: step.action,
2348
+ ...step.text !== void 0 ? { text: step.text } : {}
2349
+ }).catch(() => void 0);
2350
+ return;
2351
+ }
2352
+ case "set_env": {
2353
+ requirePlaywright(engine, "set_env");
2354
+ await engine.setEnv(session.id, {
2355
+ ...step.viewport !== void 0 ? { viewport: step.viewport } : {},
2356
+ ...step.offline !== void 0 ? { offline: step.offline } : {},
2357
+ ...step.geolocation !== void 0 ? { geolocation: step.geolocation } : {},
2358
+ ...step.color_scheme !== void 0 ? { colorScheme: step.color_scheme } : {},
2359
+ ...step.reduced_motion !== void 0 ? { reducedMotion: step.reduced_motion } : {},
2360
+ ...step.extra_headers !== void 0 ? { extraHeaders: step.extra_headers } : {},
2361
+ ...step.network_throttle !== void 0 ? { networkThrottle: step.network_throttle } : {},
2362
+ ...step.cpu_throttle !== void 0 ? { cpuThrottle: step.cpu_throttle } : {}
2363
+ });
2364
+ return;
2365
+ }
2366
+ case "switch_page": {
2367
+ requirePlaywright(engine, "switch_page");
2368
+ await engine.switchPage(session.id, step.index);
2369
+ return;
2370
+ }
2371
+ case "evaluate": {
2372
+ requirePlaywright(engine, "evaluate");
2373
+ if (process.env.ROLEPOD_ALLOW_EVAL !== "1") {
2374
+ throw new RolepodMcpError(
2375
+ "engine_error",
2376
+ "verify_ui_flow step kind 'evaluate' is disabled. Restart the rolepod-uiproof MCP server with ROLEPOD_ALLOW_EVAL=1 to enable."
2377
+ );
2378
+ }
2379
+ await engine.evaluate(session.id, step.script);
2380
+ return;
2381
+ }
2382
+ }
2383
+ }
2384
+ function requirePlaywright(engine, stepKind) {
2385
+ if (!(engine instanceof PlaywrightEngine)) {
2386
+ throw new RolepodMcpError(
2387
+ "unsupported_engine",
2388
+ `verify_ui_flow step kind "${stepKind}" is web-only and requires PlaywrightEngine.`
2389
+ );
1578
2390
  }
1579
2391
  }
1580
- function evaluateExpect(exp, snap) {
2392
+ function evaluateExpect(exp, snap, engine, sessionId) {
1581
2393
  switch (exp.kind) {
1582
2394
  case "text_visible":
1583
2395
  return treeHasText(snap.tree, exp.text);
@@ -1597,6 +2409,49 @@ function evaluateExpect(exp, snap) {
1597
2409
  return node.state?.focused === true;
1598
2410
  }
1599
2411
  }
2412
+ case "no_console_errors": {
2413
+ if (!(engine instanceof PlaywrightEngine)) return true;
2414
+ const msgs = engine.peekBuffers(sessionId).console.filter(
2415
+ (m) => m.level === "error"
2416
+ );
2417
+ const excludes = exp.exclude_patterns ?? [];
2418
+ const remaining = msgs.filter(
2419
+ (m) => !excludes.some((p) => m.text.includes(p))
2420
+ );
2421
+ return remaining.length === 0;
2422
+ }
2423
+ case "no_failed_requests": {
2424
+ if (!(engine instanceof PlaywrightEngine)) return true;
2425
+ const reqs = engine.peekBuffers(sessionId).network.filter((r) => {
2426
+ if (r.failure) return true;
2427
+ if (r.status === void 0) return false;
2428
+ if (exp.allow_4xx) return r.status >= 500;
2429
+ return r.status >= 400;
2430
+ });
2431
+ const excludes = exp.exclude_patterns ?? [];
2432
+ const remaining = reqs.filter(
2433
+ (r) => !excludes.some((p) => r.url.includes(p))
2434
+ );
2435
+ return remaining.length === 0;
2436
+ }
2437
+ case "request_made": {
2438
+ if (!(engine instanceof PlaywrightEngine)) return false;
2439
+ const re = new RegExp(exp.url_pattern);
2440
+ const wantMethod = exp.method?.toUpperCase();
2441
+ const matches = engine.peekBuffers(sessionId).network.filter((r) => {
2442
+ if (!re.test(r.url)) return false;
2443
+ if (wantMethod && r.method.toUpperCase() !== wantMethod) return false;
2444
+ return true;
2445
+ });
2446
+ const min = exp.min_count ?? 1;
2447
+ return matches.length >= min;
2448
+ }
2449
+ case "response_status": {
2450
+ if (!(engine instanceof PlaywrightEngine)) return false;
2451
+ const re = new RegExp(exp.url_pattern);
2452
+ const match = engine.peekBuffers(sessionId).network.find((r) => re.test(r.url) && r.status === exp.status);
2453
+ return match !== void 0;
2454
+ }
1600
2455
  }
1601
2456
  }
1602
2457
  function describeExpect(exp) {
@@ -1609,6 +2464,14 @@ function describeExpect(exp) {
1609
2464
  return `url_matches /${exp.pattern}/`;
1610
2465
  case "ref_in_state":
1611
2466
  return `ref_in_state "${exp.query}" \u2192 ${exp.state}`;
2467
+ case "no_console_errors":
2468
+ return "no_console_errors";
2469
+ case "no_failed_requests":
2470
+ return "no_failed_requests";
2471
+ case "request_made":
2472
+ return `request_made ${exp.method ?? ""} ${exp.url_pattern}`.trim();
2473
+ case "response_status":
2474
+ return `response_status ${exp.url_pattern} = ${exp.status}`;
1612
2475
  }
1613
2476
  }
1614
2477
  function missingQuery(query) {
@@ -1725,6 +2588,151 @@ var browserCloseTool = {
1725
2588
  }
1726
2589
  };
1727
2590
 
2591
+ // src/tools/atomic/browser_console.ts
2592
+ var browserConsoleTool = {
2593
+ name: ToolNames.browserConsole,
2594
+ description: "List console messages emitted by the active page since the session opened (or since the last `clear`). Filters: `levels` (default: errors+warnings), substring `contains`, and `limit` (default 50, max 1000). Set `clear: true` to drain the buffer after returning. Read-only.",
2595
+ inputShape: browserConsoleShape,
2596
+ build(ctx) {
2597
+ return safeHandler(async (args) => {
2598
+ const engine = ctx.registry.engineFor(args.session_id);
2599
+ if (!(engine instanceof PlaywrightEngine)) {
2600
+ throw new RolepodMcpError(
2601
+ "unsupported_engine",
2602
+ "console is web-only and requires PlaywrightEngine."
2603
+ );
2604
+ }
2605
+ const levels = args.levels && args.levels.length > 0 ? args.levels : ["error", "warning"];
2606
+ const messages = engine.getConsole(args.session_id, {
2607
+ levels,
2608
+ ...args.contains !== void 0 ? { contains: args.contains } : {},
2609
+ clear: args.clear,
2610
+ limit: args.limit
2611
+ });
2612
+ return ok({
2613
+ count: messages.length,
2614
+ messages
2615
+ });
2616
+ });
2617
+ }
2618
+ };
2619
+
2620
+ // src/tools/atomic/browser_drag.ts
2621
+ var browserDragTool = {
2622
+ name: ToolNames.browserDrag,
2623
+ description: "Drag the element identified by `from_ref` onto the element identified by `to_ref`. Both refs come from the most recent snapshot. Invalidates all refs on success.",
2624
+ inputShape: browserDragShape,
2625
+ build(ctx) {
2626
+ return safeHandler(async (args) => {
2627
+ const engine = ctx.registry.engineFor(args.session_id);
2628
+ await engine.drag(
2629
+ {
2630
+ id: args.session_id,
2631
+ platform: ctx.registry.platformOf(args.session_id)
2632
+ },
2633
+ args.from_ref,
2634
+ args.to_ref
2635
+ );
2636
+ return ok({ dragged: true });
2637
+ });
2638
+ }
2639
+ };
2640
+
2641
+ // src/tools/atomic/browser_evaluate.ts
2642
+ var browserEvaluateTool = {
2643
+ name: ToolNames.browserEvaluate,
2644
+ description: "Execute JavaScript in the active page's context. The `script` is the body of an async function \u2014 use `return` for the result and reference inputs via the implicit `args` array. DISABLED unless the MCP server was launched with `ROLEPOD_ALLOW_EVAL=1`; intended for trusted automation setups only (state seeding, computed-style reads, synthetic event dispatch).",
2645
+ inputShape: browserEvaluateShape,
2646
+ build(ctx) {
2647
+ const allowed = process.env.ROLEPOD_ALLOW_EVAL === "1";
2648
+ return safeHandler(async (args) => {
2649
+ if (!allowed) {
2650
+ throw new RolepodMcpError(
2651
+ "engine_error",
2652
+ "browser_evaluate is disabled. Restart the rolepod-uiproof MCP server with the env var ROLEPOD_ALLOW_EVAL=1 to enable arbitrary JavaScript execution in the page context."
2653
+ );
2654
+ }
2655
+ const engine = ctx.registry.engineFor(args.session_id);
2656
+ if (!(engine instanceof PlaywrightEngine)) {
2657
+ throw new RolepodMcpError(
2658
+ "unsupported_engine",
2659
+ "evaluate is web-only and requires PlaywrightEngine."
2660
+ );
2661
+ }
2662
+ const result = await engine.evaluate(
2663
+ args.session_id,
2664
+ args.script,
2665
+ args.args
2666
+ );
2667
+ return ok({ result });
2668
+ });
2669
+ }
2670
+ };
2671
+
2672
+ // src/tools/atomic/browser_fill_form.ts
2673
+ var browserFillFormTool = {
2674
+ name: ToolNames.browserFillForm,
2675
+ description: "Batch-fill multiple form fields (inputs, selects, checkboxes, radios) in one call. Each field needs a `ref` from the latest snapshot and a `value`; pass `kind` to disambiguate non-input controls. Token-efficient alternative to a sequence of `type` calls. Invalidates all refs on success.",
2676
+ inputShape: browserFillFormShape,
2677
+ build(ctx) {
2678
+ return safeHandler(async (args) => {
2679
+ const engine = ctx.registry.engineFor(args.session_id);
2680
+ await engine.fillForm(
2681
+ {
2682
+ id: args.session_id,
2683
+ platform: ctx.registry.platformOf(args.session_id)
2684
+ },
2685
+ args.fields
2686
+ );
2687
+ return ok({ filled: args.fields.length });
2688
+ });
2689
+ }
2690
+ };
2691
+
2692
+ // src/tools/atomic/browser_handle_dialog.ts
2693
+ var browserHandleDialogTool = {
2694
+ name: ToolNames.browserHandleDialog,
2695
+ description: "Pre-arm a one-shot handler for the NEXT JavaScript dialog (`alert`/`confirm`/`prompt`/`beforeunload`) on the active page. Call this BEFORE the action that triggers the dialog (e.g. before clicking the button that calls `confirm()`). Returns when the dialog fires or the timeout (default 30s) elapses. Un-armed dialogs are auto-dismissed so the page does not hang.",
2696
+ inputShape: browserHandleDialogShape,
2697
+ build(ctx) {
2698
+ return safeHandler(async (args) => {
2699
+ const engine = ctx.registry.engineFor(args.session_id);
2700
+ if (!(engine instanceof PlaywrightEngine)) {
2701
+ throw new RolepodMcpError(
2702
+ "unsupported_engine",
2703
+ "handle_dialog is web-only and requires PlaywrightEngine."
2704
+ );
2705
+ }
2706
+ const { handled } = await engine.handleDialog(args.session_id, {
2707
+ action: args.action,
2708
+ ...args.text !== void 0 ? { text: args.text } : {},
2709
+ ...args.timeout_ms !== void 0 ? { timeoutMs: args.timeout_ms } : {}
2710
+ });
2711
+ return ok({ handled, action: args.action });
2712
+ });
2713
+ }
2714
+ };
2715
+
2716
+ // src/tools/atomic/browser_hover.ts
2717
+ var browserHoverTool = {
2718
+ name: ToolNames.browserHover,
2719
+ description: "Hover the pointer over the element identified by `ref`. Refs remain valid afterwards (read-mostly).",
2720
+ inputShape: browserHoverShape,
2721
+ build(ctx) {
2722
+ return safeHandler(async (args) => {
2723
+ const engine = ctx.registry.engineFor(args.session_id);
2724
+ await engine.hover(
2725
+ {
2726
+ id: args.session_id,
2727
+ platform: ctx.registry.platformOf(args.session_id)
2728
+ },
2729
+ args.ref
2730
+ );
2731
+ return ok({ hovered: true });
2732
+ });
2733
+ }
2734
+ };
2735
+
1728
2736
  // src/tools/atomic/browser_key.ts
1729
2737
  var browserKeyTool = {
1730
2738
  name: ToolNames.browserKey,
@@ -1753,6 +2761,46 @@ var browserNavigateTool = {
1753
2761
  }
1754
2762
  };
1755
2763
 
2764
+ // src/tools/atomic/browser_network.ts
2765
+ var browserNetworkTool = {
2766
+ name: ToolNames.browserNetwork,
2767
+ description: 'List network requests captured on the active page. Filters: `url_pattern` (substring or regex via `pattern_kind`), `method`, `status_range`, `only_failed`. Set `export_har: true` to require the session to have been opened with HAR recording \u2014 see `verify_ui_flow` capture=["har"] or call `browser_open` with capture.har=true. Read-only unless `clear: true`.',
2768
+ inputShape: browserNetworkShape,
2769
+ build(ctx) {
2770
+ return safeHandler(async (args) => {
2771
+ const engine = ctx.registry.engineFor(args.session_id);
2772
+ if (!(engine instanceof PlaywrightEngine)) {
2773
+ throw new RolepodMcpError(
2774
+ "unsupported_engine",
2775
+ "network is web-only and requires PlaywrightEngine."
2776
+ );
2777
+ }
2778
+ const requests = engine.getNetwork(args.session_id, {
2779
+ ...args.url_pattern !== void 0 ? { urlPattern: args.url_pattern } : {},
2780
+ patternKind: args.pattern_kind,
2781
+ ...args.method !== void 0 ? { method: args.method } : {},
2782
+ ...args.status_range !== void 0 ? { statusRange: args.status_range } : {},
2783
+ onlyFailed: args.only_failed,
2784
+ clear: args.clear,
2785
+ limit: args.limit
2786
+ });
2787
+ const failed = requests.filter(
2788
+ (r) => !!r.failure || r.status !== void 0 && r.status >= 400
2789
+ ).length;
2790
+ return ok({
2791
+ count: requests.length,
2792
+ failed_count: failed,
2793
+ requests,
2794
+ // HAR file lives wherever the session was opened with
2795
+ // `capture.har.path`. We don't echo it here to avoid leaking
2796
+ // filesystem paths into untrusted logs; the verify_ui_flow run
2797
+ // result surfaces it in `evidence_paths.har`.
2798
+ har_recording: args.export_har ? "HAR is written at session close to the path passed via capture.har at open time." : void 0
2799
+ });
2800
+ });
2801
+ }
2802
+ };
2803
+
1756
2804
  // src/tools/atomic/browser_open.ts
1757
2805
  var browserOpenTool = {
1758
2806
  name: ToolNames.browserOpen,
@@ -1766,6 +2814,34 @@ var browserOpenTool = {
1766
2814
  }
1767
2815
  };
1768
2816
 
2817
+ // src/tools/atomic/browser_pages.ts
2818
+ var browserPagesTool = {
2819
+ name: ToolNames.browserPages,
2820
+ description: "List all pages currently open in the session's browser context \u2014 typically just the main page, plus any popups, OAuth windows, or `target=_blank` tabs that the page itself opened. Each entry carries `{ index, url, title, active }`. Read-only.",
2821
+ inputShape: browserPagesShape,
2822
+ build(ctx) {
2823
+ return safeHandler(async (args) => {
2824
+ const engine = ctx.registry.engineFor(args.session_id);
2825
+ if (!(engine instanceof PlaywrightEngine)) {
2826
+ throw new RolepodMcpError(
2827
+ "unsupported_engine",
2828
+ "pages is web-only and requires PlaywrightEngine."
2829
+ );
2830
+ }
2831
+ const raw = engine.listPages(args.session_id);
2832
+ const pages = await Promise.all(
2833
+ raw.map(async (p) => ({
2834
+ index: p.index,
2835
+ url: p.url,
2836
+ title: await p.title_promise.catch(() => ""),
2837
+ active: p.active
2838
+ }))
2839
+ );
2840
+ return ok({ count: pages.length, pages });
2841
+ });
2842
+ }
2843
+ };
2844
+
1769
2845
  // src/tools/atomic/browser_screenshot.ts
1770
2846
  var browserScreenshotTool = {
1771
2847
  name: ToolNames.browserScreenshot,
@@ -1810,6 +2886,35 @@ var browserScrollTool = {
1810
2886
  }
1811
2887
  };
1812
2888
 
2889
+ // src/tools/atomic/browser_set_env.ts
2890
+ var browserSetEnvTool = {
2891
+ name: ToolNames.browserSetEnv,
2892
+ description: "Mutate session environment at runtime: viewport, offline state, geolocation, color_scheme (`light`/`dark`), reduced_motion, extra HTTP headers, network throttle preset (`slow-3g`/`fast-3g`/`slow-4g`/`fast-4g`/`offline`/`no-throttling`), and CPU throttle multiplier. `network_throttle` and `cpu_throttle` are chromium-only (CDP). `user_agent`, `locale`, and `timezone` cannot be changed mid-session \u2014 set them at `browser_open` time. Invalidates all refs.",
2893
+ inputShape: browserSetEnvShape,
2894
+ build(ctx) {
2895
+ return safeHandler(async (args) => {
2896
+ const engine = ctx.registry.engineFor(args.session_id);
2897
+ if (!(engine instanceof PlaywrightEngine)) {
2898
+ throw new RolepodMcpError(
2899
+ "unsupported_engine",
2900
+ "set_env is web-only and requires PlaywrightEngine."
2901
+ );
2902
+ }
2903
+ await engine.setEnv(args.session_id, {
2904
+ ...args.viewport !== void 0 ? { viewport: args.viewport } : {},
2905
+ ...args.offline !== void 0 ? { offline: args.offline } : {},
2906
+ ...args.geolocation !== void 0 ? { geolocation: args.geolocation } : {},
2907
+ ...args.color_scheme !== void 0 ? { colorScheme: args.color_scheme } : {},
2908
+ ...args.reduced_motion !== void 0 ? { reducedMotion: args.reduced_motion } : {},
2909
+ ...args.extra_headers !== void 0 ? { extraHeaders: args.extra_headers } : {},
2910
+ ...args.network_throttle !== void 0 ? { networkThrottle: args.network_throttle } : {},
2911
+ ...args.cpu_throttle !== void 0 ? { cpuThrottle: args.cpu_throttle } : {}
2912
+ });
2913
+ return ok({ applied: true });
2914
+ });
2915
+ }
2916
+ };
2917
+
1813
2918
  // src/tools/atomic/browser_snapshot.ts
1814
2919
  var browserSnapshotTool = {
1815
2920
  name: ToolNames.browserSnapshot,
@@ -1832,6 +2937,26 @@ var browserSnapshotTool = {
1832
2937
  }
1833
2938
  };
1834
2939
 
2940
+ // src/tools/atomic/browser_switch_page.ts
2941
+ var browserSwitchPageTool = {
2942
+ name: ToolNames.browserSwitchPage,
2943
+ description: "Set the active page for subsequent tool calls. Use `browser_pages` to discover indexes. Switching invalidates all refs because each page has its own DOM. Page 0 is the main page; popups land at higher indexes in the order they opened.",
2944
+ inputShape: browserSwitchPageShape,
2945
+ build(ctx) {
2946
+ return safeHandler(async (args) => {
2947
+ const engine = ctx.registry.engineFor(args.session_id);
2948
+ if (!(engine instanceof PlaywrightEngine)) {
2949
+ throw new RolepodMcpError(
2950
+ "unsupported_engine",
2951
+ "switch_page is web-only and requires PlaywrightEngine."
2952
+ );
2953
+ }
2954
+ await engine.switchPage(args.session_id, args.index);
2955
+ return ok({ active_index: args.index });
2956
+ });
2957
+ }
2958
+ };
2959
+
1835
2960
  // src/tools/atomic/browser_type.ts
1836
2961
  var browserTypeTool = {
1837
2962
  name: ToolNames.browserType,
@@ -1851,6 +2976,27 @@ var browserTypeTool = {
1851
2976
  }
1852
2977
  };
1853
2978
 
2979
+ // src/tools/atomic/browser_upload_file.ts
2980
+ var browserUploadFileTool = {
2981
+ name: ToolNames.browserUploadFile,
2982
+ description: "Attach a local file to the `<input type=file>` element identified by `ref`. `file_path` MUST be an absolute path on the host filesystem. Invalidates all refs on success.",
2983
+ inputShape: browserUploadFileShape,
2984
+ build(ctx) {
2985
+ return safeHandler(async (args) => {
2986
+ const engine = ctx.registry.engineFor(args.session_id);
2987
+ await engine.uploadFile(
2988
+ {
2989
+ id: args.session_id,
2990
+ platform: ctx.registry.platformOf(args.session_id)
2991
+ },
2992
+ args.ref,
2993
+ args.file_path
2994
+ );
2995
+ return ok({ uploaded: true, file_path: args.file_path });
2996
+ });
2997
+ }
2998
+ };
2999
+
1854
3000
  // src/tools/atomic/browser_wait_for.ts
1855
3001
  var browserWaitForTool = {
1856
3002
  name: ToolNames.browserWaitFor,
@@ -2223,6 +3369,67 @@ function playwrightStepLine(step) {
2223
3369
  return ` await page.goto(${JSON.stringify(step.url)});`;
2224
3370
  case "wait_for":
2225
3371
  return ` // wait_for: ${JSON.stringify(step.condition)} \u2014 translate to page.waitForXxx()`;
3372
+ case "hover":
3373
+ return ` await page.getByText(${JSON.stringify(step.query)}, { exact: false }).first().hover();`;
3374
+ case "drag":
3375
+ return [
3376
+ ` await page`,
3377
+ ` .getByText(${JSON.stringify(step.from_query)}, { exact: false })`,
3378
+ ` .first()`,
3379
+ ` .dragTo(page.getByText(${JSON.stringify(step.to_query)}, { exact: false }).first());`
3380
+ ].join("\n");
3381
+ case "fill_form": {
3382
+ const fields = Array.isArray(step.fields) ? step.fields : [];
3383
+ return fields.map((f) => {
3384
+ const q = JSON.stringify(f.query);
3385
+ if (f.kind === "select") {
3386
+ return ` await page.getByLabel(${q}).selectOption(${JSON.stringify(String(f.value))});`;
3387
+ }
3388
+ if (f.kind === "checkbox" || f.kind === "radio") {
3389
+ const checked = typeof f.value === "boolean" ? f.value : String(f.value) === "true" || String(f.value) === "on";
3390
+ return ` await page.getByLabel(${q}).setChecked(${checked});`;
3391
+ }
3392
+ return ` await page.getByLabel(${q}).fill(${JSON.stringify(String(f.value))});`;
3393
+ }).join("\n");
3394
+ }
3395
+ case "upload":
3396
+ return ` await page.getByLabel(${JSON.stringify(step.query)}).setInputFiles(${JSON.stringify(step.file_path)});`;
3397
+ case "dialog":
3398
+ return [
3399
+ ` page.once("dialog", async (dialog) => {`,
3400
+ step.action === "accept" ? ` await dialog.accept();` : step.action === "accept_with_text" ? ` await dialog.accept(${JSON.stringify(step.text ?? "")});` : ` await dialog.dismiss();`,
3401
+ ` });`
3402
+ ].join("\n");
3403
+ case "set_env": {
3404
+ const lines = [];
3405
+ if (step.viewport && typeof step.viewport === "object") {
3406
+ const v = step.viewport;
3407
+ lines.push(` await page.setViewportSize({ width: ${v.width}, height: ${v.height} });`);
3408
+ }
3409
+ if (step.offline !== void 0) {
3410
+ lines.push(` await page.context().setOffline(${Boolean(step.offline)});`);
3411
+ }
3412
+ if (step.geolocation) {
3413
+ lines.push(` await page.context().setGeolocation(${JSON.stringify(step.geolocation)});`);
3414
+ }
3415
+ if (step.color_scheme || step.reduced_motion) {
3416
+ const opts = {};
3417
+ if (step.color_scheme) opts.colorScheme = step.color_scheme;
3418
+ if (step.reduced_motion) opts.reducedMotion = step.reduced_motion;
3419
+ lines.push(` await page.emulateMedia(${JSON.stringify(opts)});`);
3420
+ }
3421
+ if (step.extra_headers) {
3422
+ lines.push(` await page.context().setExtraHTTPHeaders(${JSON.stringify(step.extra_headers)});`);
3423
+ }
3424
+ if (step.network_throttle || step.cpu_throttle !== void 0) {
3425
+ lines.push(` // network/cpu throttle requires CDP \u2014 see Playwright docs (chromium only)`);
3426
+ }
3427
+ return lines.length > 0 ? lines.join("\n") : ` // set_env: nothing to apply`;
3428
+ }
3429
+ case "switch_page":
3430
+ return ` const allPages = page.context().pages(); /* switch to index ${step.index} */ if (allPages[${step.index}]) await allPages[${step.index}].bringToFront();`;
3431
+ case "evaluate":
3432
+ return ` await page.evaluate(${JSON.stringify(step.script)});`;
2226
3433
  default:
2227
3434
  return ` // unsupported step kind: ${step.kind}`;
2228
3435
  }
@@ -2237,6 +3444,20 @@ function playwrightExpectLine(exp) {
2237
3444
  return ` await expect(page).toHaveURL(new RegExp(${JSON.stringify(exp.pattern)}));`;
2238
3445
  case "ref_in_state":
2239
3446
  return ` // ref_in_state ${JSON.stringify(exp.query)} \u2192 ${String(exp.state)} \u2014 translate as needed`;
3447
+ case "no_console_errors":
3448
+ return [
3449
+ ` // no_console_errors \u2014 collect via page.on('console') before the steps, then:`,
3450
+ ` // expect(consoleErrors).toEqual([]);`
3451
+ ].join("\n");
3452
+ case "no_failed_requests":
3453
+ return [
3454
+ ` // no_failed_requests \u2014 collect via page.on('requestfailed'/'response') before the steps, then:`,
3455
+ ` // expect(failedRequests).toEqual([]);`
3456
+ ].join("\n");
3457
+ case "request_made":
3458
+ return ` await page.waitForRequest(new RegExp(${JSON.stringify(exp.url_pattern)}));`;
3459
+ case "response_status":
3460
+ return ` await page.waitForResponse((r) => new RegExp(${JSON.stringify(exp.url_pattern)}).test(r.url()) && r.status() === ${Number(exp.status)});`;
2240
3461
  default:
2241
3462
  return ` // unsupported expect kind: ${exp.kind}`;
2242
3463
  }
@@ -2253,6 +3474,56 @@ function seleniumStepLine(step) {
2253
3474
  return ` driver.get(${JSON.stringify(step.url)})`;
2254
3475
  case "wait_for":
2255
3476
  return ` # wait_for: ${JSON.stringify(step.condition)} \u2014 translate to WebDriverWait`;
3477
+ case "hover":
3478
+ return [
3479
+ ` from selenium.webdriver.common.action_chains import ActionChains`,
3480
+ ` target = driver.find_element(By.XPATH, f"//*[contains(text(), \\"${escapePy(String(step.query))}\\")]")`,
3481
+ ` ActionChains(driver).move_to_element(target).perform()`
3482
+ ].join("\n");
3483
+ case "drag":
3484
+ return [
3485
+ ` from selenium.webdriver.common.action_chains import ActionChains`,
3486
+ ` src = driver.find_element(By.XPATH, f"//*[contains(text(), \\"${escapePy(String(step.from_query))}\\")]")`,
3487
+ ` dst = driver.find_element(By.XPATH, f"//*[contains(text(), \\"${escapePy(String(step.to_query))}\\")]")`,
3488
+ ` ActionChains(driver).drag_and_drop(src, dst).perform()`
3489
+ ].join("\n");
3490
+ case "fill_form": {
3491
+ const fields = Array.isArray(step.fields) ? step.fields : [];
3492
+ return fields.map((f) => {
3493
+ const q = escapePy(f.query);
3494
+ if (f.kind === "select") {
3495
+ return [
3496
+ ` from selenium.webdriver.support.ui import Select`,
3497
+ ` Select(driver.find_element(By.XPATH, f"//*[@aria-label=\\"${q}\\"]")).select_by_visible_text(${JSON.stringify(String(f.value))})`
3498
+ ].join("\n");
3499
+ }
3500
+ if (f.kind === "checkbox" || f.kind === "radio") {
3501
+ const checked = typeof f.value === "boolean" ? f.value : String(f.value) === "true";
3502
+ return ` el = driver.find_element(By.XPATH, f"//*[@aria-label=\\"${q}\\"]"); el.click() if el.is_selected() != ${checked ? "True" : "False"} else None`;
3503
+ }
3504
+ return ` driver.find_element(By.XPATH, f"//*[@aria-label=\\"${q}\\"]").send_keys(${JSON.stringify(String(f.value))})`;
3505
+ }).join("\n");
3506
+ }
3507
+ case "upload":
3508
+ return ` driver.find_element(By.XPATH, f"//*[@aria-label=\\"${escapePy(String(step.query))}\\"]").send_keys(${JSON.stringify(step.file_path)})`;
3509
+ case "dialog":
3510
+ return [
3511
+ ` alert = driver.switch_to.alert`,
3512
+ step.action === "accept" ? ` alert.accept()` : step.action === "accept_with_text" ? ` alert.send_keys(${JSON.stringify(step.text ?? "")}); alert.accept()` : ` alert.dismiss()`
3513
+ ].join("\n");
3514
+ case "set_env": {
3515
+ const lines = [];
3516
+ if (step.viewport && typeof step.viewport === "object") {
3517
+ const v = step.viewport;
3518
+ lines.push(` driver.set_window_size(${v.width}, ${v.height})`);
3519
+ }
3520
+ lines.push(` # set_env partially supported in Selenium \u2014 see selenium docs for offline/geolocation/colorScheme via CDP`);
3521
+ return lines.join("\n");
3522
+ }
3523
+ case "switch_page":
3524
+ return ` driver.switch_to.window(driver.window_handles[${step.index}])`;
3525
+ case "evaluate":
3526
+ return ` driver.execute_script(${JSON.stringify(step.script)})`;
2256
3527
  default:
2257
3528
  return ` # unsupported step kind: ${step.kind}`;
2258
3529
  }
@@ -2267,6 +3538,18 @@ function seleniumExpectLine(exp) {
2267
3538
  return ` import re; assert re.search(${JSON.stringify(exp.pattern)}, driver.current_url)`;
2268
3539
  case "ref_in_state":
2269
3540
  return ` # ref_in_state ${JSON.stringify(exp.query)} \u2192 ${String(exp.state)}`;
3541
+ case "no_console_errors":
3542
+ return [
3543
+ ` # no_console_errors \u2014 read browser logs via driver.get_log("browser")`,
3544
+ ` errors = [l for l in driver.get_log("browser") if l.get("level") == "SEVERE"]`,
3545
+ ` assert errors == [], f"console errors: {errors}"`
3546
+ ].join("\n");
3547
+ case "no_failed_requests":
3548
+ return ` # no_failed_requests \u2014 selenium has no built-in network capture. Enable selenium-wire or BiDi for this.`;
3549
+ case "request_made":
3550
+ return ` # request_made ${JSON.stringify(exp.url_pattern)} \u2014 use selenium-wire (driver.requests) or BiDi`;
3551
+ case "response_status":
3552
+ return ` # response_status ${JSON.stringify(exp.url_pattern)} == ${Number(exp.status)} \u2014 use selenium-wire (driver.requests) or BiDi`;
2270
3553
  default:
2271
3554
  return ` # unsupported expect kind: ${exp.kind}`;
2272
3555
  }
@@ -2398,9 +3681,265 @@ var visualDiffTool = {
2398
3681
  }
2399
3682
  };
2400
3683
 
3684
+ // src/tools/metadata.ts
3685
+ var toolMetadata = {
3686
+ // ---------- atomic ----------
3687
+ [ToolNames.browserOpen]: {
3688
+ title: "Open Browser/Mobile Session",
3689
+ annotations: {
3690
+ title: "Open Browser/Mobile Session",
3691
+ readOnlyHint: false,
3692
+ destructiveHint: true,
3693
+ idempotentHint: false,
3694
+ openWorldHint: true
3695
+ }
3696
+ },
3697
+ [ToolNames.browserClose]: {
3698
+ title: "Close Session",
3699
+ annotations: {
3700
+ title: "Close Session",
3701
+ readOnlyHint: false,
3702
+ destructiveHint: true,
3703
+ idempotentHint: true,
3704
+ openWorldHint: false
3705
+ }
3706
+ },
3707
+ [ToolNames.browserSnapshot]: {
3708
+ title: "Capture Accessibility Snapshot",
3709
+ annotations: {
3710
+ title: "Capture Accessibility Snapshot",
3711
+ readOnlyHint: true,
3712
+ openWorldHint: true
3713
+ }
3714
+ },
3715
+ [ToolNames.browserClick]: {
3716
+ title: "Click Element",
3717
+ annotations: {
3718
+ title: "Click Element",
3719
+ readOnlyHint: false,
3720
+ destructiveHint: true,
3721
+ idempotentHint: false,
3722
+ openWorldHint: true
3723
+ }
3724
+ },
3725
+ [ToolNames.browserType]: {
3726
+ title: "Type Text",
3727
+ annotations: {
3728
+ title: "Type Text",
3729
+ readOnlyHint: false,
3730
+ destructiveHint: true,
3731
+ idempotentHint: false,
3732
+ openWorldHint: true
3733
+ }
3734
+ },
3735
+ [ToolNames.browserKey]: {
3736
+ title: "Press Key",
3737
+ annotations: {
3738
+ title: "Press Key",
3739
+ readOnlyHint: false,
3740
+ destructiveHint: true,
3741
+ idempotentHint: false,
3742
+ openWorldHint: true
3743
+ }
3744
+ },
3745
+ [ToolNames.browserScroll]: {
3746
+ title: "Scroll Viewport",
3747
+ annotations: {
3748
+ title: "Scroll Viewport",
3749
+ readOnlyHint: false,
3750
+ destructiveHint: false,
3751
+ idempotentHint: true,
3752
+ openWorldHint: true
3753
+ }
3754
+ },
3755
+ [ToolNames.browserWaitFor]: {
3756
+ title: "Wait For Condition",
3757
+ annotations: {
3758
+ title: "Wait For Condition",
3759
+ readOnlyHint: true,
3760
+ openWorldHint: true
3761
+ }
3762
+ },
3763
+ [ToolNames.browserScreenshot]: {
3764
+ title: "Take Screenshot",
3765
+ annotations: {
3766
+ title: "Take Screenshot",
3767
+ readOnlyHint: true,
3768
+ openWorldHint: true
3769
+ }
3770
+ },
3771
+ [ToolNames.browserNavigate]: {
3772
+ title: "Navigate URL",
3773
+ annotations: {
3774
+ title: "Navigate URL",
3775
+ readOnlyHint: false,
3776
+ destructiveHint: true,
3777
+ idempotentHint: false,
3778
+ openWorldHint: true
3779
+ }
3780
+ },
3781
+ // ---------- composite ----------
3782
+ [ToolNames.verifyUiFlow]: {
3783
+ title: "Verify UI Flow",
3784
+ annotations: {
3785
+ title: "Verify UI Flow",
3786
+ readOnlyHint: false,
3787
+ destructiveHint: true,
3788
+ idempotentHint: false,
3789
+ openWorldHint: true
3790
+ }
3791
+ },
3792
+ [ToolNames.auditA11y]: {
3793
+ title: "Audit Accessibility (axe-core)",
3794
+ annotations: {
3795
+ title: "Audit Accessibility (axe-core)",
3796
+ readOnlyHint: true,
3797
+ openWorldHint: true
3798
+ }
3799
+ },
3800
+ [ToolNames.visualDiff]: {
3801
+ title: "Visual Diff vs Baseline",
3802
+ annotations: {
3803
+ title: "Visual Diff vs Baseline",
3804
+ // Writes to ./.rolepod-uiproof/{baselines,artifacts}/ but only adds files —
3805
+ // never destroys an existing baseline silently.
3806
+ readOnlyHint: false,
3807
+ destructiveHint: false,
3808
+ idempotentHint: true,
3809
+ openWorldHint: true
3810
+ }
3811
+ },
3812
+ [ToolNames.scaffoldE2e]: {
3813
+ title: "Scaffold E2E Test File",
3814
+ annotations: {
3815
+ title: "Scaffold E2E Test File",
3816
+ // Writes a test file to the local repo.
3817
+ readOnlyHint: false,
3818
+ destructiveHint: true,
3819
+ idempotentHint: false,
3820
+ openWorldHint: false
3821
+ }
3822
+ },
3823
+ [ToolNames.extractUiState]: {
3824
+ title: "Extract UI State (NL Query)",
3825
+ annotations: {
3826
+ title: "Extract UI State (NL Query)",
3827
+ readOnlyHint: true,
3828
+ openWorldHint: true
3829
+ }
3830
+ },
3831
+ // ---------- v0.5 atomic additions ----------
3832
+ [ToolNames.browserHover]: {
3833
+ title: "Hover Element",
3834
+ annotations: {
3835
+ title: "Hover Element",
3836
+ readOnlyHint: false,
3837
+ destructiveHint: false,
3838
+ idempotentHint: true,
3839
+ openWorldHint: true
3840
+ }
3841
+ },
3842
+ [ToolNames.browserDrag]: {
3843
+ title: "Drag Element",
3844
+ annotations: {
3845
+ title: "Drag Element",
3846
+ readOnlyHint: false,
3847
+ destructiveHint: true,
3848
+ idempotentHint: false,
3849
+ openWorldHint: true
3850
+ }
3851
+ },
3852
+ [ToolNames.browserFillForm]: {
3853
+ title: "Fill Form (Batch)",
3854
+ annotations: {
3855
+ title: "Fill Form (Batch)",
3856
+ readOnlyHint: false,
3857
+ destructiveHint: true,
3858
+ idempotentHint: false,
3859
+ openWorldHint: true
3860
+ }
3861
+ },
3862
+ [ToolNames.browserUploadFile]: {
3863
+ title: "Upload File",
3864
+ annotations: {
3865
+ title: "Upload File",
3866
+ readOnlyHint: false,
3867
+ destructiveHint: true,
3868
+ idempotentHint: false,
3869
+ openWorldHint: true
3870
+ }
3871
+ },
3872
+ [ToolNames.browserHandleDialog]: {
3873
+ title: "Pre-arm Dialog Handler",
3874
+ annotations: {
3875
+ title: "Pre-arm Dialog Handler",
3876
+ readOnlyHint: false,
3877
+ destructiveHint: true,
3878
+ idempotentHint: false,
3879
+ openWorldHint: false
3880
+ }
3881
+ },
3882
+ [ToolNames.browserConsole]: {
3883
+ title: "Inspect Console Logs",
3884
+ annotations: {
3885
+ title: "Inspect Console Logs",
3886
+ readOnlyHint: true,
3887
+ openWorldHint: false
3888
+ }
3889
+ },
3890
+ [ToolNames.browserNetwork]: {
3891
+ title: "Inspect Network Requests",
3892
+ annotations: {
3893
+ title: "Inspect Network Requests",
3894
+ readOnlyHint: true,
3895
+ openWorldHint: false
3896
+ }
3897
+ },
3898
+ [ToolNames.browserSetEnv]: {
3899
+ title: "Set Browser Environment",
3900
+ annotations: {
3901
+ title: "Set Browser Environment",
3902
+ readOnlyHint: false,
3903
+ destructiveHint: true,
3904
+ idempotentHint: true,
3905
+ openWorldHint: false
3906
+ }
3907
+ },
3908
+ [ToolNames.browserEvaluate]: {
3909
+ title: "Evaluate JavaScript (gated; arbitrary code execution)",
3910
+ annotations: {
3911
+ title: "Evaluate JavaScript",
3912
+ // Arbitrary code execution in the page context. Gated by
3913
+ // ROLEPOD_ALLOW_EVAL=1 server-side. Always treat as destructive.
3914
+ readOnlyHint: false,
3915
+ destructiveHint: true,
3916
+ idempotentHint: false,
3917
+ openWorldHint: true
3918
+ }
3919
+ },
3920
+ [ToolNames.browserPages]: {
3921
+ title: "List Open Pages",
3922
+ annotations: {
3923
+ title: "List Open Pages",
3924
+ readOnlyHint: true,
3925
+ openWorldHint: false
3926
+ }
3927
+ },
3928
+ [ToolNames.browserSwitchPage]: {
3929
+ title: "Switch Active Page",
3930
+ annotations: {
3931
+ title: "Switch Active Page",
3932
+ readOnlyHint: false,
3933
+ destructiveHint: false,
3934
+ idempotentHint: true,
3935
+ openWorldHint: false
3936
+ }
3937
+ }
3938
+ };
3939
+
2401
3940
  // src/server.ts
2402
3941
  var SERVER_NAME = "rolepod-uiproof";
2403
- var SERVER_VERSION = "0.4.0";
3942
+ var SERVER_VERSION = "0.5.0";
2404
3943
  function buildServer(opts = {}) {
2405
3944
  const webEngine = createWebEngine();
2406
3945
  const registry = new SessionRegistry({ idleTimeoutMs: opts.idleTimeoutMs });
@@ -2417,7 +3956,7 @@ function buildServer(opts = {}) {
2417
3956
  version: SERVER_VERSION
2418
3957
  });
2419
3958
  const tools = [
2420
- // atomic
3959
+ // atomic (v0.1-v0.4)
2421
3960
  browserOpenTool,
2422
3961
  browserCloseTool,
2423
3962
  browserSnapshotTool,
@@ -2428,6 +3967,18 @@ function buildServer(opts = {}) {
2428
3967
  browserWaitForTool,
2429
3968
  browserScreenshotTool,
2430
3969
  browserNavigateTool,
3970
+ // atomic (v0.5)
3971
+ browserHoverTool,
3972
+ browserDragTool,
3973
+ browserFillFormTool,
3974
+ browserUploadFileTool,
3975
+ browserHandleDialogTool,
3976
+ browserConsoleTool,
3977
+ browserNetworkTool,
3978
+ browserSetEnvTool,
3979
+ browserEvaluateTool,
3980
+ browserPagesTool,
3981
+ browserSwitchPageTool,
2431
3982
  // composite
2432
3983
  verifyUiFlowTool,
2433
3984
  auditA11yTool,
@@ -2436,9 +3987,15 @@ function buildServer(opts = {}) {
2436
3987
  extractUiStateTool
2437
3988
  ];
2438
3989
  for (const t of tools) {
3990
+ const meta = toolMetadata[t.name];
2439
3991
  mcp.registerTool(
2440
3992
  t.name,
2441
- { description: t.description, inputSchema: t.inputShape },
3993
+ {
3994
+ title: meta?.title,
3995
+ description: t.description,
3996
+ inputSchema: t.inputShape,
3997
+ annotations: meta?.annotations
3998
+ },
2442
3999
  t.build(ctx)
2443
4000
  );
2444
4001
  }