@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.
package/dist/index.js CHANGED
@@ -415,6 +415,34 @@ var AppiumEngine = class {
415
415
  throw new UnsupportedPlatformError(_session.platform);
416
416
  }
417
417
  // -------------------------------------------------------------------------
418
+ // v0.5 cross-platform additions — mobile stubs.
419
+ // These ship as `not_implemented_in_v05` until the mobile gesture work lands.
420
+ // -------------------------------------------------------------------------
421
+ async hover(_session, _ref) {
422
+ throw new RolepodMcpError(
423
+ "engine_error",
424
+ "hover is not yet implemented for mobile (Appium). Use long-press via custom gesture if needed."
425
+ );
426
+ }
427
+ async drag(_session, _fromRef, _toRef) {
428
+ throw new RolepodMcpError(
429
+ "engine_error",
430
+ "drag is not yet implemented for mobile (Appium). Use the W3C Actions API directly if needed."
431
+ );
432
+ }
433
+ async fillForm(session, fields) {
434
+ for (const f of fields) {
435
+ const v = typeof f.value === "boolean" ? String(f.value) : f.value;
436
+ await this.type(session, f.ref, v);
437
+ }
438
+ }
439
+ async uploadFile(_session, _ref, _filePath) {
440
+ throw new RolepodMcpError(
441
+ "engine_error",
442
+ "upload_file is not supported on mobile (Appium)."
443
+ );
444
+ }
445
+ // -------------------------------------------------------------------------
418
446
  // Internals
419
447
  // -------------------------------------------------------------------------
420
448
  async loadWdio() {
@@ -529,6 +557,7 @@ function treeIncludesText(node, text) {
529
557
 
530
558
  // src/engine/PlaywrightEngine.ts
531
559
  import { randomUUID as randomUUID3 } from "crypto";
560
+ import { resolve as resolvePath, isAbsolute } from "path";
532
561
  import {
533
562
  chromium,
534
563
  firefox,
@@ -638,6 +667,83 @@ function parseAriaSnapshot(snapshotYaml) {
638
667
  }
639
668
 
640
669
  // src/engine/PlaywrightEngine.ts
670
+ var CONSOLE_BUFFER_CAP = 1e3;
671
+ var NETWORK_BUFFER_CAP = 1e3;
672
+ var NETWORK_PRESETS = {
673
+ offline: { offline: true, downloadThroughput: 0, uploadThroughput: 0, latency: 0 },
674
+ "slow-3g": {
675
+ offline: false,
676
+ // 500 Kbps down / 500 Kbps up / 400ms RTT
677
+ downloadThroughput: 500 * 1024 / 8,
678
+ uploadThroughput: 500 * 1024 / 8,
679
+ latency: 400
680
+ },
681
+ "fast-3g": {
682
+ offline: false,
683
+ downloadThroughput: 1.5 * 1024 * 1024 / 8,
684
+ uploadThroughput: 750 * 1024 / 8,
685
+ latency: 150
686
+ },
687
+ "slow-4g": {
688
+ offline: false,
689
+ downloadThroughput: 4 * 1024 * 1024 / 8,
690
+ uploadThroughput: 3 * 1024 * 1024 / 8,
691
+ latency: 100
692
+ },
693
+ "fast-4g": {
694
+ offline: false,
695
+ downloadThroughput: 9 * 1024 * 1024 / 8,
696
+ uploadThroughput: 4.5 * 1024 * 1024 / 8,
697
+ latency: 60
698
+ },
699
+ "no-throttling": {
700
+ offline: false,
701
+ downloadThroughput: -1,
702
+ uploadThroughput: -1,
703
+ latency: 0
704
+ }
705
+ };
706
+ function pushRing(buf, entry, cap) {
707
+ buf.push(entry);
708
+ if (buf.length > cap) buf.splice(0, buf.length - cap);
709
+ }
710
+ function mapConsoleLevel(t) {
711
+ switch (t) {
712
+ case "error":
713
+ return "error";
714
+ case "warning":
715
+ return "warning";
716
+ case "info":
717
+ return "info";
718
+ case "debug":
719
+ return "debug";
720
+ case "trace":
721
+ return "trace";
722
+ default:
723
+ return "log";
724
+ }
725
+ }
726
+ function formatConsoleLocation(msg) {
727
+ try {
728
+ const loc = msg.location();
729
+ if (!loc?.url) return void 0;
730
+ return `${loc.url}:${loc.lineNumber}:${loc.columnNumber}`;
731
+ } catch {
732
+ return void 0;
733
+ }
734
+ }
735
+ function findNetworkEntry(buf, req) {
736
+ const url = req.url();
737
+ const method = req.method();
738
+ for (let i = buf.length - 1; i >= 0; i--) {
739
+ const e = buf[i];
740
+ if (!e) continue;
741
+ if (e.url === url && e.method === method && e.status === void 0 && !e.failure) {
742
+ return e;
743
+ }
744
+ }
745
+ return void 0;
746
+ }
641
747
  var PlaywrightEngine = class {
642
748
  id = "playwright";
643
749
  sessions = /* @__PURE__ */ new Map();
@@ -653,35 +759,80 @@ var PlaywrightEngine = class {
653
759
  if (opts.viewport) contextOptions.viewport = opts.viewport;
654
760
  if (opts.user_agent) contextOptions.userAgent = opts.user_agent;
655
761
  if (opts.locale) contextOptions.locale = opts.locale;
762
+ if (opts.capture?.har) {
763
+ contextOptions.recordHar = { path: opts.capture.har.path };
764
+ }
765
+ if (opts.capture?.video) {
766
+ contextOptions.recordVideo = {
767
+ dir: opts.capture.video.dir,
768
+ size: opts.capture.video.sizeWidth && opts.capture.video.sizeHeight ? {
769
+ width: opts.capture.video.sizeWidth,
770
+ height: opts.capture.video.sizeHeight
771
+ } : void 0
772
+ };
773
+ }
656
774
  const context = await browser.newContext(contextOptions);
657
- const page = await context.newPage();
658
- if (opts.url) {
659
- await page.goto(opts.url, { waitUntil: "domcontentloaded" });
775
+ if (opts.capture?.trace) {
776
+ await context.tracing.start({
777
+ screenshots: true,
778
+ snapshots: true,
779
+ sources: false
780
+ });
660
781
  }
782
+ const page = await context.newPage();
661
783
  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,
784
+ const internals = {
785
+ session: {
786
+ id: sessionId,
787
+ platform: "web",
788
+ browser,
789
+ context,
790
+ mainPage: page
791
+ },
671
792
  refIndex: /* @__PURE__ */ new Map(),
672
793
  snapshotGeneration: 0,
673
794
  refGeneration: -1,
674
- lastSnapshotAt: null
795
+ lastSnapshotAt: null,
796
+ pages: [page],
797
+ activePageIndex: 0,
798
+ consoleBuffer: [],
799
+ networkBuffer: [],
800
+ networkInflight: /* @__PURE__ */ new Map(),
801
+ networkNextId: 1,
802
+ dialogArming: null,
803
+ captureOpts: opts.capture,
804
+ traceStarted: !!opts.capture?.trace
805
+ };
806
+ this.attachPageListeners(internals, page);
807
+ context.on("page", (newPage) => {
808
+ internals.pages.push(newPage);
809
+ this.attachPageListeners(internals, newPage);
675
810
  });
811
+ if (opts.url) {
812
+ await page.goto(opts.url, { waitUntil: "domcontentloaded" });
813
+ }
814
+ this.sessions.set(sessionId, internals);
676
815
  log.info("session opened", {
677
816
  session_id: sessionId,
678
817
  browser: browserName,
679
- url: opts.url ?? null
818
+ url: opts.url ?? null,
819
+ capture: opts.capture ? Object.keys(opts.capture).filter(
820
+ (k) => opts.capture[k]
821
+ ) : []
680
822
  });
681
823
  return { id: sessionId, platform: "web" };
682
824
  }
683
825
  async close(session) {
684
826
  const s = this.requireSession(session.id);
827
+ if (s.traceStarted && s.captureOpts?.trace) {
828
+ const tracePath = resolvePath(s.captureOpts.trace.artifactDir, "trace.zip");
829
+ await s.session.context.tracing.stop({ path: tracePath }).catch((err) => {
830
+ log.warn("trace stop failed", {
831
+ session_id: session.id,
832
+ err: String(err)
833
+ });
834
+ });
835
+ }
685
836
  await s.session.context.close().catch((err) => {
686
837
  log.warn("context close failed", { session_id: session.id, err: String(err) });
687
838
  });
@@ -693,7 +844,7 @@ var PlaywrightEngine = class {
693
844
  }
694
845
  async snapshot(session, mode = "visible") {
695
846
  const s = this.requireSession(session.id);
696
- const ariaYaml = await s.session.page.ariaSnapshot({ mode: "ai" });
847
+ const ariaYaml = await this.activePage(s).ariaSnapshot({ mode: "ai" });
697
848
  const { tree, refIndex } = parseAriaSnapshot(ariaYaml);
698
849
  void mode;
699
850
  s.snapshotGeneration += 1;
@@ -703,7 +854,7 @@ var PlaywrightEngine = class {
703
854
  return {
704
855
  session_id: session.id,
705
856
  platform: "web",
706
- url_or_screen: s.session.page.url(),
857
+ url_or_screen: this.activePage(s).url(),
707
858
  taken_at: s.lastSnapshotAt,
708
859
  tree
709
860
  };
@@ -723,7 +874,7 @@ var PlaywrightEngine = class {
723
874
  }
724
875
  async key(session, key) {
725
876
  const s = this.requireSession(session.id);
726
- await s.session.page.keyboard.press(key);
877
+ await this.activePage(s).keyboard.press(key);
727
878
  this.invalidateRefs(s);
728
879
  }
729
880
  async scroll(session, dir, amount = 400, ref) {
@@ -737,13 +888,13 @@ var PlaywrightEngine = class {
737
888
  [dx, dy]
738
889
  );
739
890
  } else {
740
- await s.session.page.mouse.wheel(dx, dy);
891
+ await this.activePage(s).mouse.wheel(dx, dy);
741
892
  }
742
893
  this.invalidateRefs(s);
743
894
  }
744
895
  async waitFor(session, cond, timeoutMs = 1e4) {
745
896
  const s = this.requireSession(session.id);
746
- const page = s.session.page;
897
+ const page = this.activePage(s);
747
898
  switch (cond.kind) {
748
899
  case "text_visible":
749
900
  await page.getByText(cond.text, { exact: false }).first().waitFor({ state: "visible", timeout: timeoutMs });
@@ -763,14 +914,14 @@ var PlaywrightEngine = class {
763
914
  }
764
915
  async screenshot(session, fullPage = false) {
765
916
  const s = this.requireSession(session.id);
766
- return s.session.page.screenshot({ fullPage });
917
+ return this.activePage(s).screenshot({ fullPage });
767
918
  }
768
919
  async navigate(session, url) {
769
920
  const s = this.requireSession(session.id);
770
921
  if (s.session.platform !== "web") {
771
922
  throw new UnsupportedPlatformError(s.session.platform);
772
923
  }
773
- await s.session.page.goto(url, { waitUntil: "domcontentloaded" });
924
+ await this.activePage(s).goto(url, { waitUntil: "domcontentloaded" });
774
925
  this.invalidateRefs(s);
775
926
  }
776
927
  /**
@@ -784,7 +935,7 @@ var PlaywrightEngine = class {
784
935
  if (s.session.platform !== "web") {
785
936
  throw new UnsupportedPlatformError(s.session.platform);
786
937
  }
787
- return s.session.page;
938
+ return this.activePage(s);
788
939
  }
789
940
  /** Increment generation; the next ref-using call will see them as stale. */
790
941
  bumpGeneration(sessionId) {
@@ -792,8 +943,311 @@ var PlaywrightEngine = class {
792
943
  this.invalidateRefs(s);
793
944
  }
794
945
  // -------------------------------------------------------------------------
946
+ // v0.5 — input additions
947
+ // -------------------------------------------------------------------------
948
+ async hover(session, ref) {
949
+ const s = this.requireSession(session.id);
950
+ const locator = this.resolveLocator(s, ref);
951
+ await locator.hover();
952
+ }
953
+ async drag(session, fromRef, toRef) {
954
+ const s = this.requireSession(session.id);
955
+ const from = this.resolveLocator(s, fromRef);
956
+ const to = this.resolveLocator(s, toRef);
957
+ await from.dragTo(to);
958
+ this.invalidateRefs(s);
959
+ }
960
+ async fillForm(session, fields) {
961
+ const s = this.requireSession(session.id);
962
+ for (const field of fields) {
963
+ const locator = this.resolveLocator(s, field.ref);
964
+ const kind = field.kind;
965
+ if (kind === "checkbox" || kind === "radio") {
966
+ const checked = typeof field.value === "boolean" ? field.value : field.value === "true" || field.value === "on";
967
+ await locator.setChecked(checked);
968
+ } else if (kind === "select") {
969
+ await locator.selectOption(String(field.value));
970
+ } else {
971
+ await locator.fill(String(field.value));
972
+ }
973
+ }
974
+ this.invalidateRefs(s);
975
+ }
976
+ async uploadFile(session, ref, filePath) {
977
+ const s = this.requireSession(session.id);
978
+ if (!isAbsolute(filePath)) {
979
+ throw new RolepodMcpError(
980
+ "invalid_input",
981
+ `upload_file requires an absolute path; got "${filePath}".`,
982
+ { file_path: filePath }
983
+ );
984
+ }
985
+ const locator = this.resolveLocator(s, ref);
986
+ await locator.setInputFiles(filePath);
987
+ this.invalidateRefs(s);
988
+ }
989
+ // -------------------------------------------------------------------------
990
+ // v0.5 — web-only extensions (not on Engine interface; tools cast to
991
+ // PlaywrightEngine before calling).
992
+ // -------------------------------------------------------------------------
993
+ /**
994
+ * Pre-arm a one-shot dialog handler for the next dialog raised on the
995
+ * active page. Returns when either the dialog fires (and is handled)
996
+ * or the timeout elapses. The caller is expected to trigger the
997
+ * dialog (via click etc.) AFTER arming.
998
+ */
999
+ async handleDialog(sessionId, opts) {
1000
+ const s = this.requireSession(sessionId);
1001
+ const timeoutMs = opts.timeoutMs ?? 3e4;
1002
+ const expiresAt = Date.now() + timeoutMs;
1003
+ if (s.dialogArming) {
1004
+ s.dialogArming.resolve(false);
1005
+ }
1006
+ return new Promise((resolve4) => {
1007
+ const arming = {
1008
+ action: opts.action,
1009
+ text: opts.text,
1010
+ expiresAt,
1011
+ resolve: (handled) => {
1012
+ s.dialogArming = null;
1013
+ resolve4({ handled });
1014
+ }
1015
+ };
1016
+ s.dialogArming = arming;
1017
+ const timer = setTimeout(() => {
1018
+ if (s.dialogArming === arming) {
1019
+ s.dialogArming = null;
1020
+ resolve4({ handled: false });
1021
+ }
1022
+ }, timeoutMs);
1023
+ timer.unref?.();
1024
+ });
1025
+ }
1026
+ getConsole(sessionId, opts) {
1027
+ const s = this.requireSession(sessionId);
1028
+ const levels = opts?.levels;
1029
+ const contains = opts?.contains;
1030
+ const limit = opts?.limit ?? 50;
1031
+ let entries = s.consoleBuffer;
1032
+ if (levels && levels.length > 0) {
1033
+ entries = entries.filter((e) => levels.includes(e.level));
1034
+ }
1035
+ if (contains) {
1036
+ entries = entries.filter((e) => e.text.includes(contains));
1037
+ }
1038
+ const result = entries.slice(-limit);
1039
+ if (opts?.clear) s.consoleBuffer = [];
1040
+ return result;
1041
+ }
1042
+ getNetwork(sessionId, opts) {
1043
+ const s = this.requireSession(sessionId);
1044
+ let entries = s.networkBuffer;
1045
+ if (opts?.urlPattern) {
1046
+ if (opts.patternKind === "regex") {
1047
+ const re = new RegExp(opts.urlPattern);
1048
+ entries = entries.filter((e) => re.test(e.url));
1049
+ } else {
1050
+ entries = entries.filter((e) => e.url.includes(opts.urlPattern));
1051
+ }
1052
+ }
1053
+ if (opts?.method) {
1054
+ const m = opts.method.toUpperCase();
1055
+ entries = entries.filter((e) => e.method.toUpperCase() === m);
1056
+ }
1057
+ if (opts?.statusRange) {
1058
+ const { min, max } = opts.statusRange;
1059
+ entries = entries.filter(
1060
+ (e) => e.status !== void 0 && e.status >= min && e.status <= max
1061
+ );
1062
+ }
1063
+ if (opts?.onlyFailed) {
1064
+ entries = entries.filter(
1065
+ (e) => !!e.failure || e.status !== void 0 && e.status >= 400
1066
+ );
1067
+ }
1068
+ const limit = opts?.limit ?? 50;
1069
+ const result = entries.slice(-limit);
1070
+ if (opts?.clear) s.networkBuffer = [];
1071
+ return result;
1072
+ }
1073
+ /**
1074
+ * Read the consoleBuffer/networkBuffer directly without filtering —
1075
+ * used by verify_ui_flow expect evaluators.
1076
+ */
1077
+ peekBuffers(sessionId) {
1078
+ const s = this.requireSession(sessionId);
1079
+ return { console: s.consoleBuffer, network: s.networkBuffer };
1080
+ }
1081
+ /**
1082
+ * Runtime mutation of context-level emulation. CPU + network throttle
1083
+ * use CDP and only work on chromium; everything else is cross-browser.
1084
+ */
1085
+ async setEnv(sessionId, opts) {
1086
+ const s = this.requireSession(sessionId);
1087
+ const page = this.activePage(s);
1088
+ const ctx = s.session.context;
1089
+ if (opts.viewport) {
1090
+ await page.setViewportSize(opts.viewport);
1091
+ }
1092
+ if (opts.offline !== void 0) {
1093
+ await ctx.setOffline(opts.offline);
1094
+ }
1095
+ if (opts.geolocation) {
1096
+ await ctx.setGeolocation(opts.geolocation);
1097
+ }
1098
+ if (opts.extraHeaders) {
1099
+ await ctx.setExtraHTTPHeaders(opts.extraHeaders);
1100
+ }
1101
+ if (opts.colorScheme || opts.reducedMotion) {
1102
+ await page.emulateMedia({
1103
+ ...opts.colorScheme ? { colorScheme: opts.colorScheme } : {},
1104
+ ...opts.reducedMotion ? { reducedMotion: opts.reducedMotion } : {}
1105
+ });
1106
+ }
1107
+ if (opts.networkThrottle || opts.cpuThrottle !== void 0) {
1108
+ const browserName = ctx.browser()?.browserType().name();
1109
+ if (browserName !== "chromium") {
1110
+ throw new RolepodMcpError(
1111
+ "unsupported_engine",
1112
+ `networkThrottle / cpuThrottle require chromium (CDP-backed); current browser is "${browserName}".`
1113
+ );
1114
+ }
1115
+ const cdp = await ctx.newCDPSession(page);
1116
+ try {
1117
+ if (opts.networkThrottle) {
1118
+ const preset = NETWORK_PRESETS[opts.networkThrottle];
1119
+ await cdp.send("Network.enable");
1120
+ await cdp.send("Network.emulateNetworkConditions", preset);
1121
+ }
1122
+ if (opts.cpuThrottle !== void 0) {
1123
+ await cdp.send("Emulation.setCPUThrottlingRate", {
1124
+ rate: opts.cpuThrottle
1125
+ });
1126
+ }
1127
+ } finally {
1128
+ await cdp.detach().catch(() => void 0);
1129
+ }
1130
+ }
1131
+ this.invalidateRefs(s);
1132
+ }
1133
+ /**
1134
+ * Execute a JavaScript function in the page context. ALWAYS gated by
1135
+ * the tool layer (`ROLEPOD_ALLOW_EVAL=1`); this method does not enforce
1136
+ * the env check.
1137
+ */
1138
+ async evaluate(sessionId, script, args) {
1139
+ const s = this.requireSession(sessionId);
1140
+ const page = this.activePage(s);
1141
+ return page.evaluate(
1142
+ ({ src, a }) => (
1143
+ // eslint-disable-next-line no-new-func
1144
+ new Function("args", `return (async () => { ${src} })();`)(a)
1145
+ ),
1146
+ { src: script, a: args ?? [] }
1147
+ );
1148
+ }
1149
+ listPages(sessionId) {
1150
+ const s = this.requireSession(sessionId);
1151
+ return s.pages.map((p, i) => ({
1152
+ index: i,
1153
+ url: p.url(),
1154
+ title_promise: p.title(),
1155
+ active: i === s.activePageIndex
1156
+ }));
1157
+ }
1158
+ async switchPage(sessionId, index) {
1159
+ const s = this.requireSession(sessionId);
1160
+ if (index < 0 || index >= s.pages.length) {
1161
+ throw new RolepodMcpError(
1162
+ "invalid_input",
1163
+ `Page index ${index} out of range (have ${s.pages.length} page(s)).`,
1164
+ { index, available: s.pages.length }
1165
+ );
1166
+ }
1167
+ s.activePageIndex = index;
1168
+ this.invalidateRefs(s);
1169
+ }
1170
+ // -------------------------------------------------------------------------
795
1171
  // Internal helpers
796
1172
  // -------------------------------------------------------------------------
1173
+ activePage(s) {
1174
+ return s.pages[s.activePageIndex] ?? s.session.mainPage;
1175
+ }
1176
+ attachPageListeners(s, page) {
1177
+ page.on("console", (msg) => {
1178
+ const level = mapConsoleLevel(msg.type());
1179
+ pushRing(
1180
+ s.consoleBuffer,
1181
+ {
1182
+ level,
1183
+ text: msg.text(),
1184
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1185
+ location: formatConsoleLocation(msg)
1186
+ },
1187
+ CONSOLE_BUFFER_CAP
1188
+ );
1189
+ });
1190
+ page.on("request", (req) => {
1191
+ const id = s.networkNextId++;
1192
+ s.networkInflight.set(req.url() + "::" + req.method() + "::" + id, {
1193
+ id,
1194
+ startedAt: Date.now(),
1195
+ resourceType: req.resourceType()
1196
+ });
1197
+ pushRing(
1198
+ s.networkBuffer,
1199
+ {
1200
+ id,
1201
+ url: req.url(),
1202
+ method: req.method(),
1203
+ resource_type: req.resourceType(),
1204
+ ts: (/* @__PURE__ */ new Date()).toISOString()
1205
+ },
1206
+ NETWORK_BUFFER_CAP
1207
+ );
1208
+ });
1209
+ page.on("response", (res) => {
1210
+ const req = res.request();
1211
+ const entry = findNetworkEntry(s.networkBuffer, req);
1212
+ if (entry) {
1213
+ entry.status = res.status();
1214
+ entry.duration_ms = Date.now() - new Date(entry.ts).getTime();
1215
+ }
1216
+ });
1217
+ page.on("requestfailed", (req) => {
1218
+ const entry = findNetworkEntry(s.networkBuffer, req);
1219
+ if (entry) {
1220
+ entry.failure = req.failure()?.errorText ?? "request failed";
1221
+ }
1222
+ });
1223
+ page.on("dialog", (dialog) => {
1224
+ void this.handlePageDialog(s, dialog);
1225
+ });
1226
+ }
1227
+ async handlePageDialog(s, dialog) {
1228
+ const arm = s.dialogArming;
1229
+ if (!arm || Date.now() > arm.expiresAt) {
1230
+ await dialog.dismiss().catch(() => void 0);
1231
+ if (arm) arm.resolve(false);
1232
+ return;
1233
+ }
1234
+ try {
1235
+ if (arm.action === "accept") {
1236
+ await dialog.accept();
1237
+ } else if (arm.action === "accept_with_text") {
1238
+ await dialog.accept(arm.text ?? "");
1239
+ } else {
1240
+ await dialog.dismiss();
1241
+ }
1242
+ arm.resolve(true);
1243
+ } catch (err) {
1244
+ log.warn("dialog handle failed", {
1245
+ session_id: s.session.id,
1246
+ err: String(err)
1247
+ });
1248
+ arm.resolve(false);
1249
+ }
1250
+ }
797
1251
  requireSession(sessionId) {
798
1252
  const s = this.sessions.get(sessionId);
799
1253
  if (!s) {
@@ -822,7 +1276,7 @@ var PlaywrightEngine = class {
822
1276
  if (meta.ref.startsWith("s")) {
823
1277
  throw new UnknownRefError(s.session.id, ref);
824
1278
  }
825
- return s.session.page.locator(`aria-ref=${meta.ref}`);
1279
+ return this.activePage(s).locator(`aria-ref=${meta.ref}`);
826
1280
  }
827
1281
  invalidateRefs(s) {
828
1282
  s.snapshotGeneration += 1;
@@ -1064,6 +1518,143 @@ var browserNavigateShape = {
1064
1518
  url: z.string().url()
1065
1519
  };
1066
1520
  var browserNavigateSchema = z.object(browserNavigateShape);
1521
+ var browserHoverShape = {
1522
+ session_id: z.string().min(1),
1523
+ ref: z.string().min(1)
1524
+ };
1525
+ var browserHoverSchema = z.object(browserHoverShape);
1526
+ var browserDragShape = {
1527
+ session_id: z.string().min(1),
1528
+ from_ref: z.string().min(1),
1529
+ to_ref: z.string().min(1)
1530
+ };
1531
+ var browserDragSchema = z.object(browserDragShape);
1532
+ var fillFieldKindSchema = z.enum([
1533
+ "input",
1534
+ "select",
1535
+ "checkbox",
1536
+ "radio"
1537
+ ]);
1538
+ var fillFormFieldSchema = z.object({
1539
+ ref: z.string().min(1),
1540
+ value: z.union([z.string(), z.boolean()]),
1541
+ kind: fillFieldKindSchema.optional()
1542
+ });
1543
+ var browserFillFormShape = {
1544
+ session_id: z.string().min(1),
1545
+ fields: z.array(fillFormFieldSchema).min(1)
1546
+ };
1547
+ var browserFillFormSchema = z.object(browserFillFormShape);
1548
+ var browserUploadFileShape = {
1549
+ session_id: z.string().min(1),
1550
+ ref: z.string().min(1),
1551
+ file_path: z.string().min(1)
1552
+ };
1553
+ var browserUploadFileSchema = z.object(browserUploadFileShape);
1554
+ var dialogActionSchema = z.enum([
1555
+ "accept",
1556
+ "dismiss",
1557
+ "accept_with_text"
1558
+ ]);
1559
+ var browserHandleDialogShape = {
1560
+ session_id: z.string().min(1),
1561
+ action: dialogActionSchema,
1562
+ /** Only used when action='accept_with_text'. */
1563
+ text: z.string().optional(),
1564
+ /**
1565
+ * Arming behavior: registers a one-shot handler for the NEXT dialog
1566
+ * raised on the page. Call this BEFORE the action that triggers the
1567
+ * dialog (e.g. before clicking the button that calls `confirm()`).
1568
+ * Default 30s if no dialog appears, handler is auto-removed.
1569
+ */
1570
+ timeout_ms: z.number().int().positive().optional()
1571
+ };
1572
+ var browserHandleDialogSchema = z.object(browserHandleDialogShape);
1573
+ var consoleLevelSchema = z.enum([
1574
+ "error",
1575
+ "warning",
1576
+ "info",
1577
+ "log",
1578
+ "debug",
1579
+ "trace"
1580
+ ]);
1581
+ var browserConsoleShape = {
1582
+ session_id: z.string().min(1),
1583
+ /** Filter to only these levels. Default: errors+warnings. */
1584
+ levels: z.array(consoleLevelSchema).optional(),
1585
+ /** Substring match on message text. */
1586
+ contains: z.string().optional(),
1587
+ /** Drop all buffered messages after returning. */
1588
+ clear: z.boolean().default(false),
1589
+ /** Cap on returned messages (artifact still holds full ring buffer). */
1590
+ limit: z.number().int().positive().max(1e3).default(50)
1591
+ };
1592
+ var browserConsoleSchema = z.object(browserConsoleShape);
1593
+ var browserNetworkShape = {
1594
+ session_id: z.string().min(1),
1595
+ /** Substring or regex (per `pattern_kind`) match on URL. */
1596
+ url_pattern: z.string().optional(),
1597
+ pattern_kind: z.enum(["substring", "regex"]).default("substring"),
1598
+ method: z.string().optional(),
1599
+ /** Inclusive range — e.g. `{min: 400, max: 599}` for any error response. */
1600
+ status_range: z.object({
1601
+ min: z.number().int().min(100).max(599),
1602
+ max: z.number().int().min(100).max(599)
1603
+ }).optional(),
1604
+ only_failed: z.boolean().default(false),
1605
+ /** Write the full HAR file for this session to artifacts/{runId}/network.har. */
1606
+ export_har: z.boolean().default(false),
1607
+ /** Drop buffered entries after returning. */
1608
+ clear: z.boolean().default(false),
1609
+ limit: z.number().int().positive().max(1e3).default(50)
1610
+ };
1611
+ var browserNetworkSchema = z.object(browserNetworkShape);
1612
+ var networkPresetSchema = z.enum([
1613
+ "offline",
1614
+ "slow-3g",
1615
+ "fast-3g",
1616
+ "slow-4g",
1617
+ "fast-4g",
1618
+ "no-throttling"
1619
+ ]);
1620
+ var geolocationSchema = z.object({
1621
+ latitude: z.number().min(-90).max(90),
1622
+ longitude: z.number().min(-180).max(180),
1623
+ accuracy: z.number().nonnegative().optional()
1624
+ });
1625
+ var browserSetEnvShape = {
1626
+ session_id: z.string().min(1),
1627
+ viewport: viewportSchema.optional(),
1628
+ offline: z.boolean().optional(),
1629
+ geolocation: geolocationSchema.optional(),
1630
+ color_scheme: z.enum(["light", "dark", "no-preference"]).optional(),
1631
+ reduced_motion: z.enum(["reduce", "no-preference"]).optional(),
1632
+ extra_headers: z.record(z.string(), z.string()).optional(),
1633
+ network_throttle: networkPresetSchema.optional(),
1634
+ /** CPU slowdown multiplier (1 = no throttle, 4 = 4x slower). Chromium only. */
1635
+ cpu_throttle: z.number().min(1).max(20).optional()
1636
+ };
1637
+ var browserSetEnvSchema = z.object(browserSetEnvShape);
1638
+ var browserEvaluateShape = {
1639
+ session_id: z.string().min(1),
1640
+ script: z.string().min(1),
1641
+ args: z.array(z.unknown()).optional()
1642
+ };
1643
+ var browserEvaluateSchema = z.object(browserEvaluateShape);
1644
+ var browserPagesShape = {
1645
+ session_id: z.string().min(1)
1646
+ };
1647
+ var browserPagesSchema = z.object(browserPagesShape);
1648
+ var browserSwitchPageShape = {
1649
+ session_id: z.string().min(1),
1650
+ index: z.number().int().nonnegative()
1651
+ };
1652
+ var browserSwitchPageSchema = z.object(browserSwitchPageShape);
1653
+ var verifyFillFieldSchema = z.object({
1654
+ query: z.string(),
1655
+ value: z.union([z.string(), z.boolean()]),
1656
+ kind: fillFieldKindSchema.optional()
1657
+ });
1067
1658
  var verifyStepSchema = z.discriminatedUnion("kind", [
1068
1659
  z.object({ kind: z.literal("click"), query: z.string() }),
1069
1660
  z.object({
@@ -1074,7 +1665,44 @@ var verifyStepSchema = z.discriminatedUnion("kind", [
1074
1665
  }),
1075
1666
  z.object({ kind: z.literal("key"), key: z.string() }),
1076
1667
  z.object({ kind: z.literal("wait_for"), condition: waitConditionSchema }),
1077
- z.object({ kind: z.literal("navigate"), url: z.string().url() })
1668
+ z.object({ kind: z.literal("navigate"), url: z.string().url() }),
1669
+ // v0.5 additions
1670
+ z.object({ kind: z.literal("hover"), query: z.string() }),
1671
+ z.object({
1672
+ kind: z.literal("drag"),
1673
+ from_query: z.string(),
1674
+ to_query: z.string()
1675
+ }),
1676
+ z.object({
1677
+ kind: z.literal("fill_form"),
1678
+ fields: z.array(verifyFillFieldSchema).min(1)
1679
+ }),
1680
+ z.object({
1681
+ kind: z.literal("upload"),
1682
+ query: z.string(),
1683
+ file_path: z.string().min(1)
1684
+ }),
1685
+ z.object({
1686
+ kind: z.literal("dialog"),
1687
+ action: dialogActionSchema,
1688
+ text: z.string().optional()
1689
+ }),
1690
+ z.object({
1691
+ kind: z.literal("set_env"),
1692
+ viewport: viewportSchema.optional(),
1693
+ offline: z.boolean().optional(),
1694
+ geolocation: geolocationSchema.optional(),
1695
+ color_scheme: z.enum(["light", "dark", "no-preference"]).optional(),
1696
+ reduced_motion: z.enum(["reduce", "no-preference"]).optional(),
1697
+ extra_headers: z.record(z.string(), z.string()).optional(),
1698
+ network_throttle: networkPresetSchema.optional(),
1699
+ cpu_throttle: z.number().min(1).max(20).optional()
1700
+ }),
1701
+ z.object({
1702
+ kind: z.literal("switch_page"),
1703
+ index: z.number().int().nonnegative()
1704
+ }),
1705
+ z.object({ kind: z.literal("evaluate"), script: z.string().min(1) })
1078
1706
  ]);
1079
1707
  var verifyExpectSchema = z.discriminatedUnion("kind", [
1080
1708
  z.object({ kind: z.literal("text_visible"), text: z.string() }),
@@ -1084,6 +1712,28 @@ var verifyExpectSchema = z.discriminatedUnion("kind", [
1084
1712
  kind: z.literal("ref_in_state"),
1085
1713
  query: z.string(),
1086
1714
  state: z.enum(["visible", "enabled", "focused"])
1715
+ }),
1716
+ // v0.5 additions
1717
+ z.object({
1718
+ kind: z.literal("no_console_errors"),
1719
+ exclude_patterns: z.array(z.string()).optional()
1720
+ }),
1721
+ z.object({
1722
+ kind: z.literal("no_failed_requests"),
1723
+ exclude_patterns: z.array(z.string()).optional(),
1724
+ /** When true, only 5xx counts as a failure. Default false (4xx + 5xx). */
1725
+ allow_4xx: z.boolean().optional()
1726
+ }),
1727
+ z.object({
1728
+ kind: z.literal("request_made"),
1729
+ url_pattern: z.string(),
1730
+ method: z.string().optional(),
1731
+ min_count: z.number().int().positive().optional()
1732
+ }),
1733
+ z.object({
1734
+ kind: z.literal("response_status"),
1735
+ url_pattern: z.string(),
1736
+ status: z.number().int().min(100).max(599)
1087
1737
  })
1088
1738
  ]);
1089
1739
  var captureKindSchema = z.enum([
@@ -1091,7 +1741,8 @@ var captureKindSchema = z.enum([
1091
1741
  "har",
1092
1742
  "console",
1093
1743
  "a11y_tree",
1094
- "video"
1744
+ "video",
1745
+ "trace"
1095
1746
  ]);
1096
1747
  var verifyUiFlowShape = {
1097
1748
  mode: z.enum(["assert", "reproduce"]).default("assert"),
@@ -1169,6 +1820,19 @@ var ToolNames = {
1169
1820
  browserWaitFor: "rolepod_browser_wait_for",
1170
1821
  browserScreenshot: "rolepod_browser_screenshot",
1171
1822
  browserNavigate: "rolepod_browser_navigate",
1823
+ // v0.5 atomics
1824
+ browserHover: "rolepod_browser_hover",
1825
+ browserDrag: "rolepod_browser_drag",
1826
+ browserFillForm: "rolepod_browser_fill_form",
1827
+ browserUploadFile: "rolepod_browser_upload_file",
1828
+ browserHandleDialog: "rolepod_browser_handle_dialog",
1829
+ browserConsole: "rolepod_browser_console",
1830
+ browserNetwork: "rolepod_browser_network",
1831
+ browserSetEnv: "rolepod_browser_set_env",
1832
+ browserEvaluate: "rolepod_browser_evaluate",
1833
+ browserPages: "rolepod_browser_pages",
1834
+ browserSwitchPage: "rolepod_browser_switch_page",
1835
+ // composite
1172
1836
  verifyUiFlow: "rolepod_verify_ui_flow",
1173
1837
  auditA11y: "rolepod_audit_a11y",
1174
1838
  visualDiff: "rolepod_visual_diff",
@@ -1242,6 +1906,151 @@ var browserCloseTool = {
1242
1906
  }
1243
1907
  };
1244
1908
 
1909
+ // src/tools/atomic/browser_console.ts
1910
+ var browserConsoleTool = {
1911
+ name: ToolNames.browserConsole,
1912
+ 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.",
1913
+ inputShape: browserConsoleShape,
1914
+ build(ctx) {
1915
+ return safeHandler(async (args) => {
1916
+ const engine = ctx.registry.engineFor(args.session_id);
1917
+ if (!(engine instanceof PlaywrightEngine)) {
1918
+ throw new RolepodMcpError(
1919
+ "unsupported_engine",
1920
+ "console is web-only and requires PlaywrightEngine."
1921
+ );
1922
+ }
1923
+ const levels = args.levels && args.levels.length > 0 ? args.levels : ["error", "warning"];
1924
+ const messages = engine.getConsole(args.session_id, {
1925
+ levels,
1926
+ ...args.contains !== void 0 ? { contains: args.contains } : {},
1927
+ clear: args.clear,
1928
+ limit: args.limit
1929
+ });
1930
+ return ok({
1931
+ count: messages.length,
1932
+ messages
1933
+ });
1934
+ });
1935
+ }
1936
+ };
1937
+
1938
+ // src/tools/atomic/browser_drag.ts
1939
+ var browserDragTool = {
1940
+ name: ToolNames.browserDrag,
1941
+ 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.",
1942
+ inputShape: browserDragShape,
1943
+ build(ctx) {
1944
+ return safeHandler(async (args) => {
1945
+ const engine = ctx.registry.engineFor(args.session_id);
1946
+ await engine.drag(
1947
+ {
1948
+ id: args.session_id,
1949
+ platform: ctx.registry.platformOf(args.session_id)
1950
+ },
1951
+ args.from_ref,
1952
+ args.to_ref
1953
+ );
1954
+ return ok({ dragged: true });
1955
+ });
1956
+ }
1957
+ };
1958
+
1959
+ // src/tools/atomic/browser_evaluate.ts
1960
+ var browserEvaluateTool = {
1961
+ name: ToolNames.browserEvaluate,
1962
+ 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).",
1963
+ inputShape: browserEvaluateShape,
1964
+ build(ctx) {
1965
+ const allowed = process.env.ROLEPOD_ALLOW_EVAL === "1";
1966
+ return safeHandler(async (args) => {
1967
+ if (!allowed) {
1968
+ throw new RolepodMcpError(
1969
+ "engine_error",
1970
+ "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."
1971
+ );
1972
+ }
1973
+ const engine = ctx.registry.engineFor(args.session_id);
1974
+ if (!(engine instanceof PlaywrightEngine)) {
1975
+ throw new RolepodMcpError(
1976
+ "unsupported_engine",
1977
+ "evaluate is web-only and requires PlaywrightEngine."
1978
+ );
1979
+ }
1980
+ const result = await engine.evaluate(
1981
+ args.session_id,
1982
+ args.script,
1983
+ args.args
1984
+ );
1985
+ return ok({ result });
1986
+ });
1987
+ }
1988
+ };
1989
+
1990
+ // src/tools/atomic/browser_fill_form.ts
1991
+ var browserFillFormTool = {
1992
+ name: ToolNames.browserFillForm,
1993
+ 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.",
1994
+ inputShape: browserFillFormShape,
1995
+ build(ctx) {
1996
+ return safeHandler(async (args) => {
1997
+ const engine = ctx.registry.engineFor(args.session_id);
1998
+ await engine.fillForm(
1999
+ {
2000
+ id: args.session_id,
2001
+ platform: ctx.registry.platformOf(args.session_id)
2002
+ },
2003
+ args.fields
2004
+ );
2005
+ return ok({ filled: args.fields.length });
2006
+ });
2007
+ }
2008
+ };
2009
+
2010
+ // src/tools/atomic/browser_handle_dialog.ts
2011
+ var browserHandleDialogTool = {
2012
+ name: ToolNames.browserHandleDialog,
2013
+ 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.",
2014
+ inputShape: browserHandleDialogShape,
2015
+ build(ctx) {
2016
+ return safeHandler(async (args) => {
2017
+ const engine = ctx.registry.engineFor(args.session_id);
2018
+ if (!(engine instanceof PlaywrightEngine)) {
2019
+ throw new RolepodMcpError(
2020
+ "unsupported_engine",
2021
+ "handle_dialog is web-only and requires PlaywrightEngine."
2022
+ );
2023
+ }
2024
+ const { handled } = await engine.handleDialog(args.session_id, {
2025
+ action: args.action,
2026
+ ...args.text !== void 0 ? { text: args.text } : {},
2027
+ ...args.timeout_ms !== void 0 ? { timeoutMs: args.timeout_ms } : {}
2028
+ });
2029
+ return ok({ handled, action: args.action });
2030
+ });
2031
+ }
2032
+ };
2033
+
2034
+ // src/tools/atomic/browser_hover.ts
2035
+ var browserHoverTool = {
2036
+ name: ToolNames.browserHover,
2037
+ description: "Hover the pointer over the element identified by `ref`. Refs remain valid afterwards (read-mostly).",
2038
+ inputShape: browserHoverShape,
2039
+ build(ctx) {
2040
+ return safeHandler(async (args) => {
2041
+ const engine = ctx.registry.engineFor(args.session_id);
2042
+ await engine.hover(
2043
+ {
2044
+ id: args.session_id,
2045
+ platform: ctx.registry.platformOf(args.session_id)
2046
+ },
2047
+ args.ref
2048
+ );
2049
+ return ok({ hovered: true });
2050
+ });
2051
+ }
2052
+ };
2053
+
1245
2054
  // src/tools/atomic/browser_key.ts
1246
2055
  var browserKeyTool = {
1247
2056
  name: ToolNames.browserKey,
@@ -1270,6 +2079,46 @@ var browserNavigateTool = {
1270
2079
  }
1271
2080
  };
1272
2081
 
2082
+ // src/tools/atomic/browser_network.ts
2083
+ var browserNetworkTool = {
2084
+ name: ToolNames.browserNetwork,
2085
+ 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`.',
2086
+ inputShape: browserNetworkShape,
2087
+ build(ctx) {
2088
+ return safeHandler(async (args) => {
2089
+ const engine = ctx.registry.engineFor(args.session_id);
2090
+ if (!(engine instanceof PlaywrightEngine)) {
2091
+ throw new RolepodMcpError(
2092
+ "unsupported_engine",
2093
+ "network is web-only and requires PlaywrightEngine."
2094
+ );
2095
+ }
2096
+ const requests = engine.getNetwork(args.session_id, {
2097
+ ...args.url_pattern !== void 0 ? { urlPattern: args.url_pattern } : {},
2098
+ patternKind: args.pattern_kind,
2099
+ ...args.method !== void 0 ? { method: args.method } : {},
2100
+ ...args.status_range !== void 0 ? { statusRange: args.status_range } : {},
2101
+ onlyFailed: args.only_failed,
2102
+ clear: args.clear,
2103
+ limit: args.limit
2104
+ });
2105
+ const failed = requests.filter(
2106
+ (r) => !!r.failure || r.status !== void 0 && r.status >= 400
2107
+ ).length;
2108
+ return ok({
2109
+ count: requests.length,
2110
+ failed_count: failed,
2111
+ requests,
2112
+ // HAR file lives wherever the session was opened with
2113
+ // `capture.har.path`. We don't echo it here to avoid leaking
2114
+ // filesystem paths into untrusted logs; the verify_ui_flow run
2115
+ // result surfaces it in `evidence_paths.har`.
2116
+ har_recording: args.export_har ? "HAR is written at session close to the path passed via capture.har at open time." : void 0
2117
+ });
2118
+ });
2119
+ }
2120
+ };
2121
+
1273
2122
  // src/tools/atomic/browser_open.ts
1274
2123
  var browserOpenTool = {
1275
2124
  name: ToolNames.browserOpen,
@@ -1283,6 +2132,34 @@ var browserOpenTool = {
1283
2132
  }
1284
2133
  };
1285
2134
 
2135
+ // src/tools/atomic/browser_pages.ts
2136
+ var browserPagesTool = {
2137
+ name: ToolNames.browserPages,
2138
+ 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.",
2139
+ inputShape: browserPagesShape,
2140
+ build(ctx) {
2141
+ return safeHandler(async (args) => {
2142
+ const engine = ctx.registry.engineFor(args.session_id);
2143
+ if (!(engine instanceof PlaywrightEngine)) {
2144
+ throw new RolepodMcpError(
2145
+ "unsupported_engine",
2146
+ "pages is web-only and requires PlaywrightEngine."
2147
+ );
2148
+ }
2149
+ const raw = engine.listPages(args.session_id);
2150
+ const pages = await Promise.all(
2151
+ raw.map(async (p) => ({
2152
+ index: p.index,
2153
+ url: p.url,
2154
+ title: await p.title_promise.catch(() => ""),
2155
+ active: p.active
2156
+ }))
2157
+ );
2158
+ return ok({ count: pages.length, pages });
2159
+ });
2160
+ }
2161
+ };
2162
+
1286
2163
  // src/tools/atomic/browser_screenshot.ts
1287
2164
  var browserScreenshotTool = {
1288
2165
  name: ToolNames.browserScreenshot,
@@ -1327,6 +2204,35 @@ var browserScrollTool = {
1327
2204
  }
1328
2205
  };
1329
2206
 
2207
+ // src/tools/atomic/browser_set_env.ts
2208
+ var browserSetEnvTool = {
2209
+ name: ToolNames.browserSetEnv,
2210
+ 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.",
2211
+ inputShape: browserSetEnvShape,
2212
+ build(ctx) {
2213
+ return safeHandler(async (args) => {
2214
+ const engine = ctx.registry.engineFor(args.session_id);
2215
+ if (!(engine instanceof PlaywrightEngine)) {
2216
+ throw new RolepodMcpError(
2217
+ "unsupported_engine",
2218
+ "set_env is web-only and requires PlaywrightEngine."
2219
+ );
2220
+ }
2221
+ await engine.setEnv(args.session_id, {
2222
+ ...args.viewport !== void 0 ? { viewport: args.viewport } : {},
2223
+ ...args.offline !== void 0 ? { offline: args.offline } : {},
2224
+ ...args.geolocation !== void 0 ? { geolocation: args.geolocation } : {},
2225
+ ...args.color_scheme !== void 0 ? { colorScheme: args.color_scheme } : {},
2226
+ ...args.reduced_motion !== void 0 ? { reducedMotion: args.reduced_motion } : {},
2227
+ ...args.extra_headers !== void 0 ? { extraHeaders: args.extra_headers } : {},
2228
+ ...args.network_throttle !== void 0 ? { networkThrottle: args.network_throttle } : {},
2229
+ ...args.cpu_throttle !== void 0 ? { cpuThrottle: args.cpu_throttle } : {}
2230
+ });
2231
+ return ok({ applied: true });
2232
+ });
2233
+ }
2234
+ };
2235
+
1330
2236
  // src/tools/atomic/browser_snapshot.ts
1331
2237
  var browserSnapshotTool = {
1332
2238
  name: ToolNames.browserSnapshot,
@@ -1349,6 +2255,26 @@ var browserSnapshotTool = {
1349
2255
  }
1350
2256
  };
1351
2257
 
2258
+ // src/tools/atomic/browser_switch_page.ts
2259
+ var browserSwitchPageTool = {
2260
+ name: ToolNames.browserSwitchPage,
2261
+ 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.",
2262
+ inputShape: browserSwitchPageShape,
2263
+ build(ctx) {
2264
+ return safeHandler(async (args) => {
2265
+ const engine = ctx.registry.engineFor(args.session_id);
2266
+ if (!(engine instanceof PlaywrightEngine)) {
2267
+ throw new RolepodMcpError(
2268
+ "unsupported_engine",
2269
+ "switch_page is web-only and requires PlaywrightEngine."
2270
+ );
2271
+ }
2272
+ await engine.switchPage(args.session_id, args.index);
2273
+ return ok({ active_index: args.index });
2274
+ });
2275
+ }
2276
+ };
2277
+
1352
2278
  // src/tools/atomic/browser_type.ts
1353
2279
  var browserTypeTool = {
1354
2280
  name: ToolNames.browserType,
@@ -1368,6 +2294,27 @@ var browserTypeTool = {
1368
2294
  }
1369
2295
  };
1370
2296
 
2297
+ // src/tools/atomic/browser_upload_file.ts
2298
+ var browserUploadFileTool = {
2299
+ name: ToolNames.browserUploadFile,
2300
+ 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.",
2301
+ inputShape: browserUploadFileShape,
2302
+ build(ctx) {
2303
+ return safeHandler(async (args) => {
2304
+ const engine = ctx.registry.engineFor(args.session_id);
2305
+ await engine.uploadFile(
2306
+ {
2307
+ id: args.session_id,
2308
+ platform: ctx.registry.platformOf(args.session_id)
2309
+ },
2310
+ args.ref,
2311
+ args.file_path
2312
+ );
2313
+ return ok({ uploaded: true, file_path: args.file_path });
2314
+ });
2315
+ }
2316
+ };
2317
+
1371
2318
  // src/tools/atomic/browser_wait_for.ts
1372
2319
  var browserWaitForTool = {
1373
2320
  name: ToolNames.browserWaitFor,
@@ -1740,6 +2687,67 @@ function playwrightStepLine(step) {
1740
2687
  return ` await page.goto(${JSON.stringify(step.url)});`;
1741
2688
  case "wait_for":
1742
2689
  return ` // wait_for: ${JSON.stringify(step.condition)} \u2014 translate to page.waitForXxx()`;
2690
+ case "hover":
2691
+ return ` await page.getByText(${JSON.stringify(step.query)}, { exact: false }).first().hover();`;
2692
+ case "drag":
2693
+ return [
2694
+ ` await page`,
2695
+ ` .getByText(${JSON.stringify(step.from_query)}, { exact: false })`,
2696
+ ` .first()`,
2697
+ ` .dragTo(page.getByText(${JSON.stringify(step.to_query)}, { exact: false }).first());`
2698
+ ].join("\n");
2699
+ case "fill_form": {
2700
+ const fields = Array.isArray(step.fields) ? step.fields : [];
2701
+ return fields.map((f) => {
2702
+ const q = JSON.stringify(f.query);
2703
+ if (f.kind === "select") {
2704
+ return ` await page.getByLabel(${q}).selectOption(${JSON.stringify(String(f.value))});`;
2705
+ }
2706
+ if (f.kind === "checkbox" || f.kind === "radio") {
2707
+ const checked = typeof f.value === "boolean" ? f.value : String(f.value) === "true" || String(f.value) === "on";
2708
+ return ` await page.getByLabel(${q}).setChecked(${checked});`;
2709
+ }
2710
+ return ` await page.getByLabel(${q}).fill(${JSON.stringify(String(f.value))});`;
2711
+ }).join("\n");
2712
+ }
2713
+ case "upload":
2714
+ return ` await page.getByLabel(${JSON.stringify(step.query)}).setInputFiles(${JSON.stringify(step.file_path)});`;
2715
+ case "dialog":
2716
+ return [
2717
+ ` page.once("dialog", async (dialog) => {`,
2718
+ step.action === "accept" ? ` await dialog.accept();` : step.action === "accept_with_text" ? ` await dialog.accept(${JSON.stringify(step.text ?? "")});` : ` await dialog.dismiss();`,
2719
+ ` });`
2720
+ ].join("\n");
2721
+ case "set_env": {
2722
+ const lines = [];
2723
+ if (step.viewport && typeof step.viewport === "object") {
2724
+ const v = step.viewport;
2725
+ lines.push(` await page.setViewportSize({ width: ${v.width}, height: ${v.height} });`);
2726
+ }
2727
+ if (step.offline !== void 0) {
2728
+ lines.push(` await page.context().setOffline(${Boolean(step.offline)});`);
2729
+ }
2730
+ if (step.geolocation) {
2731
+ lines.push(` await page.context().setGeolocation(${JSON.stringify(step.geolocation)});`);
2732
+ }
2733
+ if (step.color_scheme || step.reduced_motion) {
2734
+ const opts = {};
2735
+ if (step.color_scheme) opts.colorScheme = step.color_scheme;
2736
+ if (step.reduced_motion) opts.reducedMotion = step.reduced_motion;
2737
+ lines.push(` await page.emulateMedia(${JSON.stringify(opts)});`);
2738
+ }
2739
+ if (step.extra_headers) {
2740
+ lines.push(` await page.context().setExtraHTTPHeaders(${JSON.stringify(step.extra_headers)});`);
2741
+ }
2742
+ if (step.network_throttle || step.cpu_throttle !== void 0) {
2743
+ lines.push(` // network/cpu throttle requires CDP \u2014 see Playwright docs (chromium only)`);
2744
+ }
2745
+ return lines.length > 0 ? lines.join("\n") : ` // set_env: nothing to apply`;
2746
+ }
2747
+ case "switch_page":
2748
+ return ` const allPages = page.context().pages(); /* switch to index ${step.index} */ if (allPages[${step.index}]) await allPages[${step.index}].bringToFront();`;
2749
+ case "evaluate":
2750
+ return ` await page.evaluate(${JSON.stringify(step.script)});`;
1743
2751
  default:
1744
2752
  return ` // unsupported step kind: ${step.kind}`;
1745
2753
  }
@@ -1754,6 +2762,20 @@ function playwrightExpectLine(exp) {
1754
2762
  return ` await expect(page).toHaveURL(new RegExp(${JSON.stringify(exp.pattern)}));`;
1755
2763
  case "ref_in_state":
1756
2764
  return ` // ref_in_state ${JSON.stringify(exp.query)} \u2192 ${String(exp.state)} \u2014 translate as needed`;
2765
+ case "no_console_errors":
2766
+ return [
2767
+ ` // no_console_errors \u2014 collect via page.on('console') before the steps, then:`,
2768
+ ` // expect(consoleErrors).toEqual([]);`
2769
+ ].join("\n");
2770
+ case "no_failed_requests":
2771
+ return [
2772
+ ` // no_failed_requests \u2014 collect via page.on('requestfailed'/'response') before the steps, then:`,
2773
+ ` // expect(failedRequests).toEqual([]);`
2774
+ ].join("\n");
2775
+ case "request_made":
2776
+ return ` await page.waitForRequest(new RegExp(${JSON.stringify(exp.url_pattern)}));`;
2777
+ case "response_status":
2778
+ return ` await page.waitForResponse((r) => new RegExp(${JSON.stringify(exp.url_pattern)}).test(r.url()) && r.status() === ${Number(exp.status)});`;
1757
2779
  default:
1758
2780
  return ` // unsupported expect kind: ${exp.kind}`;
1759
2781
  }
@@ -1770,6 +2792,56 @@ function seleniumStepLine(step) {
1770
2792
  return ` driver.get(${JSON.stringify(step.url)})`;
1771
2793
  case "wait_for":
1772
2794
  return ` # wait_for: ${JSON.stringify(step.condition)} \u2014 translate to WebDriverWait`;
2795
+ case "hover":
2796
+ return [
2797
+ ` from selenium.webdriver.common.action_chains import ActionChains`,
2798
+ ` target = driver.find_element(By.XPATH, f"//*[contains(text(), \\"${escapePy(String(step.query))}\\")]")`,
2799
+ ` ActionChains(driver).move_to_element(target).perform()`
2800
+ ].join("\n");
2801
+ case "drag":
2802
+ return [
2803
+ ` from selenium.webdriver.common.action_chains import ActionChains`,
2804
+ ` src = driver.find_element(By.XPATH, f"//*[contains(text(), \\"${escapePy(String(step.from_query))}\\")]")`,
2805
+ ` dst = driver.find_element(By.XPATH, f"//*[contains(text(), \\"${escapePy(String(step.to_query))}\\")]")`,
2806
+ ` ActionChains(driver).drag_and_drop(src, dst).perform()`
2807
+ ].join("\n");
2808
+ case "fill_form": {
2809
+ const fields = Array.isArray(step.fields) ? step.fields : [];
2810
+ return fields.map((f) => {
2811
+ const q = escapePy(f.query);
2812
+ if (f.kind === "select") {
2813
+ return [
2814
+ ` from selenium.webdriver.support.ui import Select`,
2815
+ ` Select(driver.find_element(By.XPATH, f"//*[@aria-label=\\"${q}\\"]")).select_by_visible_text(${JSON.stringify(String(f.value))})`
2816
+ ].join("\n");
2817
+ }
2818
+ if (f.kind === "checkbox" || f.kind === "radio") {
2819
+ const checked = typeof f.value === "boolean" ? f.value : String(f.value) === "true";
2820
+ return ` el = driver.find_element(By.XPATH, f"//*[@aria-label=\\"${q}\\"]"); el.click() if el.is_selected() != ${checked ? "True" : "False"} else None`;
2821
+ }
2822
+ return ` driver.find_element(By.XPATH, f"//*[@aria-label=\\"${q}\\"]").send_keys(${JSON.stringify(String(f.value))})`;
2823
+ }).join("\n");
2824
+ }
2825
+ case "upload":
2826
+ return ` driver.find_element(By.XPATH, f"//*[@aria-label=\\"${escapePy(String(step.query))}\\"]").send_keys(${JSON.stringify(step.file_path)})`;
2827
+ case "dialog":
2828
+ return [
2829
+ ` alert = driver.switch_to.alert`,
2830
+ step.action === "accept" ? ` alert.accept()` : step.action === "accept_with_text" ? ` alert.send_keys(${JSON.stringify(step.text ?? "")}); alert.accept()` : ` alert.dismiss()`
2831
+ ].join("\n");
2832
+ case "set_env": {
2833
+ const lines = [];
2834
+ if (step.viewport && typeof step.viewport === "object") {
2835
+ const v = step.viewport;
2836
+ lines.push(` driver.set_window_size(${v.width}, ${v.height})`);
2837
+ }
2838
+ lines.push(` # set_env partially supported in Selenium \u2014 see selenium docs for offline/geolocation/colorScheme via CDP`);
2839
+ return lines.join("\n");
2840
+ }
2841
+ case "switch_page":
2842
+ return ` driver.switch_to.window(driver.window_handles[${step.index}])`;
2843
+ case "evaluate":
2844
+ return ` driver.execute_script(${JSON.stringify(step.script)})`;
1773
2845
  default:
1774
2846
  return ` # unsupported step kind: ${step.kind}`;
1775
2847
  }
@@ -1784,6 +2856,18 @@ function seleniumExpectLine(exp) {
1784
2856
  return ` import re; assert re.search(${JSON.stringify(exp.pattern)}, driver.current_url)`;
1785
2857
  case "ref_in_state":
1786
2858
  return ` # ref_in_state ${JSON.stringify(exp.query)} \u2192 ${String(exp.state)}`;
2859
+ case "no_console_errors":
2860
+ return [
2861
+ ` # no_console_errors \u2014 read browser logs via driver.get_log("browser")`,
2862
+ ` errors = [l for l in driver.get_log("browser") if l.get("level") == "SEVERE"]`,
2863
+ ` assert errors == [], f"console errors: {errors}"`
2864
+ ].join("\n");
2865
+ case "no_failed_requests":
2866
+ return ` # no_failed_requests \u2014 selenium has no built-in network capture. Enable selenium-wire or BiDi for this.`;
2867
+ case "request_made":
2868
+ return ` # request_made ${JSON.stringify(exp.url_pattern)} \u2014 use selenium-wire (driver.requests) or BiDi`;
2869
+ case "response_status":
2870
+ return ` # response_status ${JSON.stringify(exp.url_pattern)} == ${Number(exp.status)} \u2014 use selenium-wire (driver.requests) or BiDi`;
1787
2871
  default:
1788
2872
  return ` # unsupported expect kind: ${exp.kind}`;
1789
2873
  }
@@ -1812,6 +2896,10 @@ function indent(block, n) {
1812
2896
  return block.split("\n").map((l) => l.length > 0 ? pad + l : l).join("\n");
1813
2897
  }
1814
2898
 
2899
+ // src/tools/composite/verify_ui_flow.ts
2900
+ import { readdir } from "fs/promises";
2901
+ import { resolve as resolvePath2 } from "path";
2902
+
1815
2903
  // src/replay/minimize.ts
1816
2904
  async function ddmin(input, reproduces) {
1817
2905
  let current = [...input];
@@ -1875,13 +2963,32 @@ var verifyUiFlowTool = {
1875
2963
  });
1876
2964
  }
1877
2965
  };
2966
+ function buildCaptureOptions(captures, runDir) {
2967
+ const cap = {};
2968
+ if (captures.has("har")) {
2969
+ cap.har = { path: resolvePath2(runDir, "network.har") };
2970
+ }
2971
+ if (captures.has("video")) {
2972
+ cap.video = { dir: resolvePath2(runDir, "videos") };
2973
+ }
2974
+ if (captures.has("trace")) {
2975
+ cap.trace = { artifactDir: runDir };
2976
+ }
2977
+ return Object.keys(cap).length > 0 ? cap : void 0;
2978
+ }
1878
2979
  async function runFlow(ctx, args, steps, runDir, opts) {
1879
2980
  const evidence = { screenshots: [] };
2981
+ const captures = new Set(args.capture ?? ["screenshot"]);
1880
2982
  let passed = false;
1881
2983
  let failedAtStep;
1882
2984
  let failureReason;
1883
2985
  let finalSnapshot;
1884
- const session = await ctx.registry.open(args.open);
2986
+ const openOpts = { ...args.open };
2987
+ const captureCfg = buildCaptureOptions(captures, runDir);
2988
+ if (captureCfg) {
2989
+ openOpts.capture = captureCfg;
2990
+ }
2991
+ const session = await ctx.registry.open(openOpts);
1885
2992
  const engine = ctx.registry.engineFor(session.id);
1886
2993
  const sessionHandle = { id: session.id, platform: session.platform };
1887
2994
  try {
@@ -1900,7 +3007,7 @@ async function runFlow(ctx, args, steps, runDir, opts) {
1900
3007
  const failures = [];
1901
3008
  for (let i = 0; i < args.expect.length; i++) {
1902
3009
  const expectation = args.expect[i];
1903
- if (!evaluateExpect(expectation, finalSnapshot)) {
3010
+ if (!evaluateExpect(expectation, finalSnapshot, engine, session.id)) {
1904
3011
  failures.push(`expect[${i}] ${describeExpect(expectation)}`);
1905
3012
  }
1906
3013
  }
@@ -1914,8 +3021,7 @@ async function runFlow(ctx, args, steps, runDir, opts) {
1914
3021
  passed = false;
1915
3022
  } finally {
1916
3023
  if (opts.captureEvidence) {
1917
- const wantScreenshot = !args.capture || args.capture.includes("screenshot");
1918
- if (wantScreenshot) {
3024
+ if (captures.has("screenshot")) {
1919
3025
  try {
1920
3026
  const buf = await engine.screenshot(sessionHandle, true);
1921
3027
  const p = await ctx.store.writeScreenshot(runDir, buf, "final");
@@ -1924,6 +3030,37 @@ async function runFlow(ctx, args, steps, runDir, opts) {
1924
3030
  failureReason ??= `screenshot capture failed: ${describeError(err)}`;
1925
3031
  }
1926
3032
  }
3033
+ if (captures.has("console") && engine instanceof PlaywrightEngine) {
3034
+ try {
3035
+ const messages = engine.peekBuffers(session.id).console;
3036
+ evidence.console = await ctx.store.writeReport(
3037
+ runDir,
3038
+ "console.json",
3039
+ JSON.stringify(
3040
+ {
3041
+ count: messages.length,
3042
+ by_level: countByLevel(messages),
3043
+ messages
3044
+ },
3045
+ null,
3046
+ 2
3047
+ )
3048
+ );
3049
+ } catch (err) {
3050
+ failureReason ??= `console capture failed: ${describeError(err)}`;
3051
+ }
3052
+ }
3053
+ if (captures.has("a11y_tree") && finalSnapshot) {
3054
+ try {
3055
+ evidence.a11y_tree = await ctx.store.writeReport(
3056
+ runDir,
3057
+ "a11y_tree.json",
3058
+ JSON.stringify(finalSnapshot, null, 2)
3059
+ );
3060
+ } catch (err) {
3061
+ failureReason ??= `a11y_tree capture failed: ${describeError(err)}`;
3062
+ }
3063
+ }
1927
3064
  try {
1928
3065
  evidence.replay_bundle = await ctx.store.writeReplayBundle(
1929
3066
  runDir,
@@ -1944,12 +3081,32 @@ async function runFlow(ctx, args, steps, runDir, opts) {
1944
3081
  await ctx.registry.close(sessionHandle).catch(() => void 0);
1945
3082
  }
1946
3083
  }
3084
+ if (opts.captureEvidence) {
3085
+ if (captureCfg?.har) evidence.har = captureCfg.har.path;
3086
+ if (captureCfg?.trace) {
3087
+ evidence.trace = resolvePath2(captureCfg.trace.artifactDir, "trace.zip");
3088
+ }
3089
+ if (captureCfg?.video) {
3090
+ try {
3091
+ const files = await readdir(captureCfg.video.dir).catch(() => []);
3092
+ evidence.video = files.filter((f) => f.endsWith(".webm")).map((f) => resolvePath2(captureCfg.video.dir, f));
3093
+ } catch {
3094
+ }
3095
+ }
3096
+ }
1947
3097
  const out = { passed, evidence };
1948
3098
  if (failedAtStep !== void 0) out.failedAtStep = failedAtStep;
1949
3099
  if (failureReason !== void 0) out.failureReason = failureReason;
1950
3100
  if (finalSnapshot) out.finalUrl = finalSnapshot.url_or_screen;
1951
3101
  return out;
1952
3102
  }
3103
+ function countByLevel(messages) {
3104
+ const counts = {};
3105
+ for (const m of messages) {
3106
+ counts[m.level] = (counts[m.level] ?? 0) + 1;
3107
+ }
3108
+ return counts;
3109
+ }
1953
3110
  async function minimize(ctx, args, initialSteps, runDir) {
1954
3111
  const tagged = initialSteps.map((step, origIndex) => ({ step, origIndex }));
1955
3112
  let attempts = 0;
@@ -2013,9 +3170,84 @@ async function runStep(engine, session, step, snap) {
2013
3170
  case "navigate":
2014
3171
  await engine.navigate(session, step.url);
2015
3172
  return;
3173
+ case "hover": {
3174
+ const ref = findRefByQuery(snap.tree, step.query);
3175
+ if (!ref) throw missingQuery(step.query);
3176
+ await engine.hover(session, ref);
3177
+ return;
3178
+ }
3179
+ case "drag": {
3180
+ const fromRef = findRefByQuery(snap.tree, step.from_query);
3181
+ if (!fromRef) throw missingQuery(step.from_query);
3182
+ const toRef = findRefByQuery(snap.tree, step.to_query);
3183
+ if (!toRef) throw missingQuery(step.to_query);
3184
+ await engine.drag(session, fromRef, toRef);
3185
+ return;
3186
+ }
3187
+ case "fill_form": {
3188
+ const resolved = step.fields.map((f) => {
3189
+ const ref = findRefByQuery(snap.tree, f.query);
3190
+ if (!ref) throw missingQuery(f.query);
3191
+ return f.kind !== void 0 ? { ref, value: f.value, kind: f.kind } : { ref, value: f.value };
3192
+ });
3193
+ await engine.fillForm(session, resolved);
3194
+ return;
3195
+ }
3196
+ case "upload": {
3197
+ const ref = findRefByQuery(snap.tree, step.query);
3198
+ if (!ref) throw missingQuery(step.query);
3199
+ await engine.uploadFile(session, ref, step.file_path);
3200
+ return;
3201
+ }
3202
+ case "dialog": {
3203
+ requirePlaywright(engine, "dialog");
3204
+ void engine.handleDialog(session.id, {
3205
+ action: step.action,
3206
+ ...step.text !== void 0 ? { text: step.text } : {}
3207
+ }).catch(() => void 0);
3208
+ return;
3209
+ }
3210
+ case "set_env": {
3211
+ requirePlaywright(engine, "set_env");
3212
+ await engine.setEnv(session.id, {
3213
+ ...step.viewport !== void 0 ? { viewport: step.viewport } : {},
3214
+ ...step.offline !== void 0 ? { offline: step.offline } : {},
3215
+ ...step.geolocation !== void 0 ? { geolocation: step.geolocation } : {},
3216
+ ...step.color_scheme !== void 0 ? { colorScheme: step.color_scheme } : {},
3217
+ ...step.reduced_motion !== void 0 ? { reducedMotion: step.reduced_motion } : {},
3218
+ ...step.extra_headers !== void 0 ? { extraHeaders: step.extra_headers } : {},
3219
+ ...step.network_throttle !== void 0 ? { networkThrottle: step.network_throttle } : {},
3220
+ ...step.cpu_throttle !== void 0 ? { cpuThrottle: step.cpu_throttle } : {}
3221
+ });
3222
+ return;
3223
+ }
3224
+ case "switch_page": {
3225
+ requirePlaywright(engine, "switch_page");
3226
+ await engine.switchPage(session.id, step.index);
3227
+ return;
3228
+ }
3229
+ case "evaluate": {
3230
+ requirePlaywright(engine, "evaluate");
3231
+ if (process.env.ROLEPOD_ALLOW_EVAL !== "1") {
3232
+ throw new RolepodMcpError(
3233
+ "engine_error",
3234
+ "verify_ui_flow step kind 'evaluate' is disabled. Restart the rolepod-uiproof MCP server with ROLEPOD_ALLOW_EVAL=1 to enable."
3235
+ );
3236
+ }
3237
+ await engine.evaluate(session.id, step.script);
3238
+ return;
3239
+ }
2016
3240
  }
2017
3241
  }
2018
- function evaluateExpect(exp, snap) {
3242
+ function requirePlaywright(engine, stepKind) {
3243
+ if (!(engine instanceof PlaywrightEngine)) {
3244
+ throw new RolepodMcpError(
3245
+ "unsupported_engine",
3246
+ `verify_ui_flow step kind "${stepKind}" is web-only and requires PlaywrightEngine.`
3247
+ );
3248
+ }
3249
+ }
3250
+ function evaluateExpect(exp, snap, engine, sessionId) {
2019
3251
  switch (exp.kind) {
2020
3252
  case "text_visible":
2021
3253
  return treeHasText(snap.tree, exp.text);
@@ -2035,6 +3267,49 @@ function evaluateExpect(exp, snap) {
2035
3267
  return node.state?.focused === true;
2036
3268
  }
2037
3269
  }
3270
+ case "no_console_errors": {
3271
+ if (!(engine instanceof PlaywrightEngine)) return true;
3272
+ const msgs = engine.peekBuffers(sessionId).console.filter(
3273
+ (m) => m.level === "error"
3274
+ );
3275
+ const excludes = exp.exclude_patterns ?? [];
3276
+ const remaining = msgs.filter(
3277
+ (m) => !excludes.some((p) => m.text.includes(p))
3278
+ );
3279
+ return remaining.length === 0;
3280
+ }
3281
+ case "no_failed_requests": {
3282
+ if (!(engine instanceof PlaywrightEngine)) return true;
3283
+ const reqs = engine.peekBuffers(sessionId).network.filter((r) => {
3284
+ if (r.failure) return true;
3285
+ if (r.status === void 0) return false;
3286
+ if (exp.allow_4xx) return r.status >= 500;
3287
+ return r.status >= 400;
3288
+ });
3289
+ const excludes = exp.exclude_patterns ?? [];
3290
+ const remaining = reqs.filter(
3291
+ (r) => !excludes.some((p) => r.url.includes(p))
3292
+ );
3293
+ return remaining.length === 0;
3294
+ }
3295
+ case "request_made": {
3296
+ if (!(engine instanceof PlaywrightEngine)) return false;
3297
+ const re = new RegExp(exp.url_pattern);
3298
+ const wantMethod = exp.method?.toUpperCase();
3299
+ const matches = engine.peekBuffers(sessionId).network.filter((r) => {
3300
+ if (!re.test(r.url)) return false;
3301
+ if (wantMethod && r.method.toUpperCase() !== wantMethod) return false;
3302
+ return true;
3303
+ });
3304
+ const min = exp.min_count ?? 1;
3305
+ return matches.length >= min;
3306
+ }
3307
+ case "response_status": {
3308
+ if (!(engine instanceof PlaywrightEngine)) return false;
3309
+ const re = new RegExp(exp.url_pattern);
3310
+ const match = engine.peekBuffers(sessionId).network.find((r) => re.test(r.url) && r.status === exp.status);
3311
+ return match !== void 0;
3312
+ }
2038
3313
  }
2039
3314
  }
2040
3315
  function describeExpect(exp) {
@@ -2047,6 +3322,14 @@ function describeExpect(exp) {
2047
3322
  return `url_matches /${exp.pattern}/`;
2048
3323
  case "ref_in_state":
2049
3324
  return `ref_in_state "${exp.query}" \u2192 ${exp.state}`;
3325
+ case "no_console_errors":
3326
+ return "no_console_errors";
3327
+ case "no_failed_requests":
3328
+ return "no_failed_requests";
3329
+ case "request_made":
3330
+ return `request_made ${exp.method ?? ""} ${exp.url_pattern}`.trim();
3331
+ case "response_status":
3332
+ return `response_status ${exp.url_pattern} = ${exp.status}`;
2050
3333
  }
2051
3334
  }
2052
3335
  function missingQuery(query) {
@@ -2194,9 +3477,265 @@ var visualDiffTool = {
2194
3477
  }
2195
3478
  };
2196
3479
 
3480
+ // src/tools/metadata.ts
3481
+ var toolMetadata = {
3482
+ // ---------- atomic ----------
3483
+ [ToolNames.browserOpen]: {
3484
+ title: "Open Browser/Mobile Session",
3485
+ annotations: {
3486
+ title: "Open Browser/Mobile Session",
3487
+ readOnlyHint: false,
3488
+ destructiveHint: true,
3489
+ idempotentHint: false,
3490
+ openWorldHint: true
3491
+ }
3492
+ },
3493
+ [ToolNames.browserClose]: {
3494
+ title: "Close Session",
3495
+ annotations: {
3496
+ title: "Close Session",
3497
+ readOnlyHint: false,
3498
+ destructiveHint: true,
3499
+ idempotentHint: true,
3500
+ openWorldHint: false
3501
+ }
3502
+ },
3503
+ [ToolNames.browserSnapshot]: {
3504
+ title: "Capture Accessibility Snapshot",
3505
+ annotations: {
3506
+ title: "Capture Accessibility Snapshot",
3507
+ readOnlyHint: true,
3508
+ openWorldHint: true
3509
+ }
3510
+ },
3511
+ [ToolNames.browserClick]: {
3512
+ title: "Click Element",
3513
+ annotations: {
3514
+ title: "Click Element",
3515
+ readOnlyHint: false,
3516
+ destructiveHint: true,
3517
+ idempotentHint: false,
3518
+ openWorldHint: true
3519
+ }
3520
+ },
3521
+ [ToolNames.browserType]: {
3522
+ title: "Type Text",
3523
+ annotations: {
3524
+ title: "Type Text",
3525
+ readOnlyHint: false,
3526
+ destructiveHint: true,
3527
+ idempotentHint: false,
3528
+ openWorldHint: true
3529
+ }
3530
+ },
3531
+ [ToolNames.browserKey]: {
3532
+ title: "Press Key",
3533
+ annotations: {
3534
+ title: "Press Key",
3535
+ readOnlyHint: false,
3536
+ destructiveHint: true,
3537
+ idempotentHint: false,
3538
+ openWorldHint: true
3539
+ }
3540
+ },
3541
+ [ToolNames.browserScroll]: {
3542
+ title: "Scroll Viewport",
3543
+ annotations: {
3544
+ title: "Scroll Viewport",
3545
+ readOnlyHint: false,
3546
+ destructiveHint: false,
3547
+ idempotentHint: true,
3548
+ openWorldHint: true
3549
+ }
3550
+ },
3551
+ [ToolNames.browserWaitFor]: {
3552
+ title: "Wait For Condition",
3553
+ annotations: {
3554
+ title: "Wait For Condition",
3555
+ readOnlyHint: true,
3556
+ openWorldHint: true
3557
+ }
3558
+ },
3559
+ [ToolNames.browserScreenshot]: {
3560
+ title: "Take Screenshot",
3561
+ annotations: {
3562
+ title: "Take Screenshot",
3563
+ readOnlyHint: true,
3564
+ openWorldHint: true
3565
+ }
3566
+ },
3567
+ [ToolNames.browserNavigate]: {
3568
+ title: "Navigate URL",
3569
+ annotations: {
3570
+ title: "Navigate URL",
3571
+ readOnlyHint: false,
3572
+ destructiveHint: true,
3573
+ idempotentHint: false,
3574
+ openWorldHint: true
3575
+ }
3576
+ },
3577
+ // ---------- composite ----------
3578
+ [ToolNames.verifyUiFlow]: {
3579
+ title: "Verify UI Flow",
3580
+ annotations: {
3581
+ title: "Verify UI Flow",
3582
+ readOnlyHint: false,
3583
+ destructiveHint: true,
3584
+ idempotentHint: false,
3585
+ openWorldHint: true
3586
+ }
3587
+ },
3588
+ [ToolNames.auditA11y]: {
3589
+ title: "Audit Accessibility (axe-core)",
3590
+ annotations: {
3591
+ title: "Audit Accessibility (axe-core)",
3592
+ readOnlyHint: true,
3593
+ openWorldHint: true
3594
+ }
3595
+ },
3596
+ [ToolNames.visualDiff]: {
3597
+ title: "Visual Diff vs Baseline",
3598
+ annotations: {
3599
+ title: "Visual Diff vs Baseline",
3600
+ // Writes to ./.rolepod-uiproof/{baselines,artifacts}/ but only adds files —
3601
+ // never destroys an existing baseline silently.
3602
+ readOnlyHint: false,
3603
+ destructiveHint: false,
3604
+ idempotentHint: true,
3605
+ openWorldHint: true
3606
+ }
3607
+ },
3608
+ [ToolNames.scaffoldE2e]: {
3609
+ title: "Scaffold E2E Test File",
3610
+ annotations: {
3611
+ title: "Scaffold E2E Test File",
3612
+ // Writes a test file to the local repo.
3613
+ readOnlyHint: false,
3614
+ destructiveHint: true,
3615
+ idempotentHint: false,
3616
+ openWorldHint: false
3617
+ }
3618
+ },
3619
+ [ToolNames.extractUiState]: {
3620
+ title: "Extract UI State (NL Query)",
3621
+ annotations: {
3622
+ title: "Extract UI State (NL Query)",
3623
+ readOnlyHint: true,
3624
+ openWorldHint: true
3625
+ }
3626
+ },
3627
+ // ---------- v0.5 atomic additions ----------
3628
+ [ToolNames.browserHover]: {
3629
+ title: "Hover Element",
3630
+ annotations: {
3631
+ title: "Hover Element",
3632
+ readOnlyHint: false,
3633
+ destructiveHint: false,
3634
+ idempotentHint: true,
3635
+ openWorldHint: true
3636
+ }
3637
+ },
3638
+ [ToolNames.browserDrag]: {
3639
+ title: "Drag Element",
3640
+ annotations: {
3641
+ title: "Drag Element",
3642
+ readOnlyHint: false,
3643
+ destructiveHint: true,
3644
+ idempotentHint: false,
3645
+ openWorldHint: true
3646
+ }
3647
+ },
3648
+ [ToolNames.browserFillForm]: {
3649
+ title: "Fill Form (Batch)",
3650
+ annotations: {
3651
+ title: "Fill Form (Batch)",
3652
+ readOnlyHint: false,
3653
+ destructiveHint: true,
3654
+ idempotentHint: false,
3655
+ openWorldHint: true
3656
+ }
3657
+ },
3658
+ [ToolNames.browserUploadFile]: {
3659
+ title: "Upload File",
3660
+ annotations: {
3661
+ title: "Upload File",
3662
+ readOnlyHint: false,
3663
+ destructiveHint: true,
3664
+ idempotentHint: false,
3665
+ openWorldHint: true
3666
+ }
3667
+ },
3668
+ [ToolNames.browserHandleDialog]: {
3669
+ title: "Pre-arm Dialog Handler",
3670
+ annotations: {
3671
+ title: "Pre-arm Dialog Handler",
3672
+ readOnlyHint: false,
3673
+ destructiveHint: true,
3674
+ idempotentHint: false,
3675
+ openWorldHint: false
3676
+ }
3677
+ },
3678
+ [ToolNames.browserConsole]: {
3679
+ title: "Inspect Console Logs",
3680
+ annotations: {
3681
+ title: "Inspect Console Logs",
3682
+ readOnlyHint: true,
3683
+ openWorldHint: false
3684
+ }
3685
+ },
3686
+ [ToolNames.browserNetwork]: {
3687
+ title: "Inspect Network Requests",
3688
+ annotations: {
3689
+ title: "Inspect Network Requests",
3690
+ readOnlyHint: true,
3691
+ openWorldHint: false
3692
+ }
3693
+ },
3694
+ [ToolNames.browserSetEnv]: {
3695
+ title: "Set Browser Environment",
3696
+ annotations: {
3697
+ title: "Set Browser Environment",
3698
+ readOnlyHint: false,
3699
+ destructiveHint: true,
3700
+ idempotentHint: true,
3701
+ openWorldHint: false
3702
+ }
3703
+ },
3704
+ [ToolNames.browserEvaluate]: {
3705
+ title: "Evaluate JavaScript (gated; arbitrary code execution)",
3706
+ annotations: {
3707
+ title: "Evaluate JavaScript",
3708
+ // Arbitrary code execution in the page context. Gated by
3709
+ // ROLEPOD_ALLOW_EVAL=1 server-side. Always treat as destructive.
3710
+ readOnlyHint: false,
3711
+ destructiveHint: true,
3712
+ idempotentHint: false,
3713
+ openWorldHint: true
3714
+ }
3715
+ },
3716
+ [ToolNames.browserPages]: {
3717
+ title: "List Open Pages",
3718
+ annotations: {
3719
+ title: "List Open Pages",
3720
+ readOnlyHint: true,
3721
+ openWorldHint: false
3722
+ }
3723
+ },
3724
+ [ToolNames.browserSwitchPage]: {
3725
+ title: "Switch Active Page",
3726
+ annotations: {
3727
+ title: "Switch Active Page",
3728
+ readOnlyHint: false,
3729
+ destructiveHint: false,
3730
+ idempotentHint: true,
3731
+ openWorldHint: false
3732
+ }
3733
+ }
3734
+ };
3735
+
2197
3736
  // src/server.ts
2198
3737
  var SERVER_NAME = "rolepod-uiproof";
2199
- var SERVER_VERSION = "0.4.0";
3738
+ var SERVER_VERSION = "0.5.0";
2200
3739
  function buildServer(opts = {}) {
2201
3740
  const webEngine = createWebEngine();
2202
3741
  const registry = new SessionRegistry({ idleTimeoutMs: opts.idleTimeoutMs });
@@ -2213,7 +3752,7 @@ function buildServer(opts = {}) {
2213
3752
  version: SERVER_VERSION
2214
3753
  });
2215
3754
  const tools = [
2216
- // atomic
3755
+ // atomic (v0.1-v0.4)
2217
3756
  browserOpenTool,
2218
3757
  browserCloseTool,
2219
3758
  browserSnapshotTool,
@@ -2224,6 +3763,18 @@ function buildServer(opts = {}) {
2224
3763
  browserWaitForTool,
2225
3764
  browserScreenshotTool,
2226
3765
  browserNavigateTool,
3766
+ // atomic (v0.5)
3767
+ browserHoverTool,
3768
+ browserDragTool,
3769
+ browserFillFormTool,
3770
+ browserUploadFileTool,
3771
+ browserHandleDialogTool,
3772
+ browserConsoleTool,
3773
+ browserNetworkTool,
3774
+ browserSetEnvTool,
3775
+ browserEvaluateTool,
3776
+ browserPagesTool,
3777
+ browserSwitchPageTool,
2227
3778
  // composite
2228
3779
  verifyUiFlowTool,
2229
3780
  auditA11yTool,
@@ -2232,9 +3783,15 @@ function buildServer(opts = {}) {
2232
3783
  extractUiStateTool
2233
3784
  ];
2234
3785
  for (const t of tools) {
3786
+ const meta = toolMetadata[t.name];
2235
3787
  mcp.registerTool(
2236
3788
  t.name,
2237
- { description: t.description, inputSchema: t.inputShape },
3789
+ {
3790
+ title: meta?.title,
3791
+ description: t.description,
3792
+ inputSchema: t.inputShape,
3793
+ annotations: meta?.annotations
3794
+ },
2238
3795
  t.build(ctx)
2239
3796
  );
2240
3797
  }
@@ -2271,20 +3828,42 @@ export {
2271
3828
  browserClickShape,
2272
3829
  browserCloseSchema,
2273
3830
  browserCloseShape,
3831
+ browserConsoleSchema,
3832
+ browserConsoleShape,
3833
+ browserDragSchema,
3834
+ browserDragShape,
3835
+ browserEvaluateSchema,
3836
+ browserEvaluateShape,
3837
+ browserFillFormSchema,
3838
+ browserFillFormShape,
3839
+ browserHandleDialogSchema,
3840
+ browserHandleDialogShape,
3841
+ browserHoverSchema,
3842
+ browserHoverShape,
2274
3843
  browserKeySchema,
2275
3844
  browserKeyShape,
2276
3845
  browserNavigateSchema,
2277
3846
  browserNavigateShape,
3847
+ browserNetworkSchema,
3848
+ browserNetworkShape,
2278
3849
  browserOpenSchema,
2279
3850
  browserOpenShape,
3851
+ browserPagesSchema,
3852
+ browserPagesShape,
2280
3853
  browserScreenshotSchema,
2281
3854
  browserScreenshotShape,
2282
3855
  browserScrollSchema,
2283
3856
  browserScrollShape,
3857
+ browserSetEnvSchema,
3858
+ browserSetEnvShape,
2284
3859
  browserSnapshotSchema,
2285
3860
  browserSnapshotShape,
3861
+ browserSwitchPageSchema,
3862
+ browserSwitchPageShape,
2286
3863
  browserTypeSchema,
2287
3864
  browserTypeShape,
3865
+ browserUploadFileSchema,
3866
+ browserUploadFileShape,
2288
3867
  browserWaitForSchema,
2289
3868
  browserWaitForShape,
2290
3869
  buildServer,