@rolepod/uiproof 0.4.1 → 0.6.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
@@ -28,16 +28,49 @@ var log = {
28
28
  // src/artifact/ArtifactStore.ts
29
29
  var ArtifactStore = class {
30
30
  rootDir;
31
+ mode;
32
+ baselineRoot;
31
33
  constructor(opts = {}) {
32
- this.rootDir = opts.rootDir ?? resolve(process.cwd(), ".rolepod-uiproof", "artifacts");
34
+ const detectedParent = process.env.ROLEPOD_PARENT === "1";
35
+ this.mode = opts.mode ?? (detectedParent ? "with-parent" : "standalone");
36
+ if (opts.rootDir !== void 0) {
37
+ this.rootDir = opts.rootDir;
38
+ } else if (this.mode === "with-parent") {
39
+ this.rootDir = resolve(process.cwd(), ".rolepod", "evidence");
40
+ } else {
41
+ this.rootDir = resolve(process.cwd(), ".rolepod-uiproof", "artifacts");
42
+ }
43
+ this.baselineRoot = resolve(process.cwd(), ".rolepod-uiproof", "baselines");
33
44
  }
34
- /** Allocate a fresh run id and ensure its directory exists. */
35
- async startRun(prefix = "run") {
36
- const runId = `${prefix}_${this.timestampSlug()}_${randomUUID().slice(0, 8)}`;
45
+ /**
46
+ * Allocate a fresh run dir and ensure it exists.
47
+ *
48
+ * - standalone: `./.rolepod-uiproof/artifacts/{prefix}_{ts}_{uuid}/`
49
+ * - with-parent: `./.rolepod/evidence/{ts}-rolepod-uiproof-{skill}/`
50
+ *
51
+ * `prefix` is preserved for back-compat with v0.5 callers; new callers
52
+ * should also pass `opts.skill` so the with-parent path can be derived
53
+ * unambiguously and the manifest can be emitted with the canonical
54
+ * skill name.
55
+ */
56
+ async startRun(prefix = "run", opts = {}) {
57
+ const ts = this.timestampSlug();
58
+ const skill = opts.skill ?? prefix;
59
+ let runId;
60
+ if (this.mode === "with-parent") {
61
+ runId = `${ts}-rolepod-uiproof-${skill}`;
62
+ } else {
63
+ runId = `${prefix}_${ts}_${randomUUID().slice(0, 8)}`;
64
+ }
37
65
  const runDir = resolve(this.rootDir, runId);
38
66
  await mkdir(runDir, { recursive: true });
39
- log.debug("artifact run started", { run_id: runId, dir: runDir });
40
- return { runId, runDir };
67
+ log.debug("artifact run started", {
68
+ run_id: runId,
69
+ dir: runDir,
70
+ mode: this.mode,
71
+ skill
72
+ });
73
+ return { runId, runDir, skill, mode: this.mode };
41
74
  }
42
75
  async writeScreenshot(runDir, buf, name) {
43
76
  const path = resolve(runDir, `${name}.png`);
@@ -65,7 +98,7 @@ var ArtifactStore = class {
65
98
  }
66
99
  /** Root for stored visual baselines: `./.rolepod-uiproof/baselines/`. */
67
100
  get baselineDir() {
68
- return resolve(this.rootDir, "..", "baselines");
101
+ return this.baselineRoot;
69
102
  }
70
103
  timestampSlug() {
71
104
  const d = /* @__PURE__ */ new Date();
@@ -415,6 +448,34 @@ var AppiumEngine = class {
415
448
  throw new UnsupportedPlatformError(_session.platform);
416
449
  }
417
450
  // -------------------------------------------------------------------------
451
+ // v0.5 cross-platform additions — mobile stubs.
452
+ // These ship as `not_implemented_in_v05` until the mobile gesture work lands.
453
+ // -------------------------------------------------------------------------
454
+ async hover(_session, _ref) {
455
+ throw new RolepodMcpError(
456
+ "engine_error",
457
+ "hover is not yet implemented for mobile (Appium). Use long-press via custom gesture if needed."
458
+ );
459
+ }
460
+ async drag(_session, _fromRef, _toRef) {
461
+ throw new RolepodMcpError(
462
+ "engine_error",
463
+ "drag is not yet implemented for mobile (Appium). Use the W3C Actions API directly if needed."
464
+ );
465
+ }
466
+ async fillForm(session, fields) {
467
+ for (const f of fields) {
468
+ const v = typeof f.value === "boolean" ? String(f.value) : f.value;
469
+ await this.type(session, f.ref, v);
470
+ }
471
+ }
472
+ async uploadFile(_session, _ref, _filePath) {
473
+ throw new RolepodMcpError(
474
+ "engine_error",
475
+ "upload_file is not supported on mobile (Appium)."
476
+ );
477
+ }
478
+ // -------------------------------------------------------------------------
418
479
  // Internals
419
480
  // -------------------------------------------------------------------------
420
481
  async loadWdio() {
@@ -529,6 +590,7 @@ function treeIncludesText(node, text) {
529
590
 
530
591
  // src/engine/PlaywrightEngine.ts
531
592
  import { randomUUID as randomUUID3 } from "crypto";
593
+ import { resolve as resolvePath, isAbsolute } from "path";
532
594
  import {
533
595
  chromium,
534
596
  firefox,
@@ -638,6 +700,83 @@ function parseAriaSnapshot(snapshotYaml) {
638
700
  }
639
701
 
640
702
  // src/engine/PlaywrightEngine.ts
703
+ var CONSOLE_BUFFER_CAP = 1e3;
704
+ var NETWORK_BUFFER_CAP = 1e3;
705
+ var NETWORK_PRESETS = {
706
+ offline: { offline: true, downloadThroughput: 0, uploadThroughput: 0, latency: 0 },
707
+ "slow-3g": {
708
+ offline: false,
709
+ // 500 Kbps down / 500 Kbps up / 400ms RTT
710
+ downloadThroughput: 500 * 1024 / 8,
711
+ uploadThroughput: 500 * 1024 / 8,
712
+ latency: 400
713
+ },
714
+ "fast-3g": {
715
+ offline: false,
716
+ downloadThroughput: 1.5 * 1024 * 1024 / 8,
717
+ uploadThroughput: 750 * 1024 / 8,
718
+ latency: 150
719
+ },
720
+ "slow-4g": {
721
+ offline: false,
722
+ downloadThroughput: 4 * 1024 * 1024 / 8,
723
+ uploadThroughput: 3 * 1024 * 1024 / 8,
724
+ latency: 100
725
+ },
726
+ "fast-4g": {
727
+ offline: false,
728
+ downloadThroughput: 9 * 1024 * 1024 / 8,
729
+ uploadThroughput: 4.5 * 1024 * 1024 / 8,
730
+ latency: 60
731
+ },
732
+ "no-throttling": {
733
+ offline: false,
734
+ downloadThroughput: -1,
735
+ uploadThroughput: -1,
736
+ latency: 0
737
+ }
738
+ };
739
+ function pushRing(buf, entry, cap) {
740
+ buf.push(entry);
741
+ if (buf.length > cap) buf.splice(0, buf.length - cap);
742
+ }
743
+ function mapConsoleLevel(t) {
744
+ switch (t) {
745
+ case "error":
746
+ return "error";
747
+ case "warning":
748
+ return "warning";
749
+ case "info":
750
+ return "info";
751
+ case "debug":
752
+ return "debug";
753
+ case "trace":
754
+ return "trace";
755
+ default:
756
+ return "log";
757
+ }
758
+ }
759
+ function formatConsoleLocation(msg) {
760
+ try {
761
+ const loc = msg.location();
762
+ if (!loc?.url) return void 0;
763
+ return `${loc.url}:${loc.lineNumber}:${loc.columnNumber}`;
764
+ } catch {
765
+ return void 0;
766
+ }
767
+ }
768
+ function findNetworkEntry(buf, req) {
769
+ const url = req.url();
770
+ const method = req.method();
771
+ for (let i = buf.length - 1; i >= 0; i--) {
772
+ const e = buf[i];
773
+ if (!e) continue;
774
+ if (e.url === url && e.method === method && e.status === void 0 && !e.failure) {
775
+ return e;
776
+ }
777
+ }
778
+ return void 0;
779
+ }
641
780
  var PlaywrightEngine = class {
642
781
  id = "playwright";
643
782
  sessions = /* @__PURE__ */ new Map();
@@ -653,35 +792,80 @@ var PlaywrightEngine = class {
653
792
  if (opts.viewport) contextOptions.viewport = opts.viewport;
654
793
  if (opts.user_agent) contextOptions.userAgent = opts.user_agent;
655
794
  if (opts.locale) contextOptions.locale = opts.locale;
795
+ if (opts.capture?.har) {
796
+ contextOptions.recordHar = { path: opts.capture.har.path };
797
+ }
798
+ if (opts.capture?.video) {
799
+ contextOptions.recordVideo = {
800
+ dir: opts.capture.video.dir,
801
+ size: opts.capture.video.sizeWidth && opts.capture.video.sizeHeight ? {
802
+ width: opts.capture.video.sizeWidth,
803
+ height: opts.capture.video.sizeHeight
804
+ } : void 0
805
+ };
806
+ }
656
807
  const context = await browser.newContext(contextOptions);
657
- const page = await context.newPage();
658
- if (opts.url) {
659
- await page.goto(opts.url, { waitUntil: "domcontentloaded" });
808
+ if (opts.capture?.trace) {
809
+ await context.tracing.start({
810
+ screenshots: true,
811
+ snapshots: true,
812
+ sources: false
813
+ });
660
814
  }
815
+ const page = await context.newPage();
661
816
  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,
817
+ const internals = {
818
+ session: {
819
+ id: sessionId,
820
+ platform: "web",
821
+ browser,
822
+ context,
823
+ mainPage: page
824
+ },
671
825
  refIndex: /* @__PURE__ */ new Map(),
672
826
  snapshotGeneration: 0,
673
827
  refGeneration: -1,
674
- lastSnapshotAt: null
828
+ lastSnapshotAt: null,
829
+ pages: [page],
830
+ activePageIndex: 0,
831
+ consoleBuffer: [],
832
+ networkBuffer: [],
833
+ networkInflight: /* @__PURE__ */ new Map(),
834
+ networkNextId: 1,
835
+ dialogArming: null,
836
+ captureOpts: opts.capture,
837
+ traceStarted: !!opts.capture?.trace
838
+ };
839
+ this.attachPageListeners(internals, page);
840
+ context.on("page", (newPage) => {
841
+ internals.pages.push(newPage);
842
+ this.attachPageListeners(internals, newPage);
675
843
  });
844
+ if (opts.url) {
845
+ await page.goto(opts.url, { waitUntil: "domcontentloaded" });
846
+ }
847
+ this.sessions.set(sessionId, internals);
676
848
  log.info("session opened", {
677
849
  session_id: sessionId,
678
850
  browser: browserName,
679
- url: opts.url ?? null
851
+ url: opts.url ?? null,
852
+ capture: opts.capture ? Object.keys(opts.capture).filter(
853
+ (k) => opts.capture[k]
854
+ ) : []
680
855
  });
681
856
  return { id: sessionId, platform: "web" };
682
857
  }
683
858
  async close(session) {
684
859
  const s = this.requireSession(session.id);
860
+ if (s.traceStarted && s.captureOpts?.trace) {
861
+ const tracePath = resolvePath(s.captureOpts.trace.artifactDir, "trace.zip");
862
+ await s.session.context.tracing.stop({ path: tracePath }).catch((err) => {
863
+ log.warn("trace stop failed", {
864
+ session_id: session.id,
865
+ err: String(err)
866
+ });
867
+ });
868
+ }
685
869
  await s.session.context.close().catch((err) => {
686
870
  log.warn("context close failed", { session_id: session.id, err: String(err) });
687
871
  });
@@ -693,7 +877,7 @@ var PlaywrightEngine = class {
693
877
  }
694
878
  async snapshot(session, mode = "visible") {
695
879
  const s = this.requireSession(session.id);
696
- const ariaYaml = await s.session.page.ariaSnapshot({ mode: "ai" });
880
+ const ariaYaml = await this.activePage(s).ariaSnapshot({ mode: "ai" });
697
881
  const { tree, refIndex } = parseAriaSnapshot(ariaYaml);
698
882
  void mode;
699
883
  s.snapshotGeneration += 1;
@@ -703,7 +887,7 @@ var PlaywrightEngine = class {
703
887
  return {
704
888
  session_id: session.id,
705
889
  platform: "web",
706
- url_or_screen: s.session.page.url(),
890
+ url_or_screen: this.activePage(s).url(),
707
891
  taken_at: s.lastSnapshotAt,
708
892
  tree
709
893
  };
@@ -723,7 +907,7 @@ var PlaywrightEngine = class {
723
907
  }
724
908
  async key(session, key) {
725
909
  const s = this.requireSession(session.id);
726
- await s.session.page.keyboard.press(key);
910
+ await this.activePage(s).keyboard.press(key);
727
911
  this.invalidateRefs(s);
728
912
  }
729
913
  async scroll(session, dir, amount = 400, ref) {
@@ -737,13 +921,13 @@ var PlaywrightEngine = class {
737
921
  [dx, dy]
738
922
  );
739
923
  } else {
740
- await s.session.page.mouse.wheel(dx, dy);
924
+ await this.activePage(s).mouse.wheel(dx, dy);
741
925
  }
742
926
  this.invalidateRefs(s);
743
927
  }
744
928
  async waitFor(session, cond, timeoutMs = 1e4) {
745
929
  const s = this.requireSession(session.id);
746
- const page = s.session.page;
930
+ const page = this.activePage(s);
747
931
  switch (cond.kind) {
748
932
  case "text_visible":
749
933
  await page.getByText(cond.text, { exact: false }).first().waitFor({ state: "visible", timeout: timeoutMs });
@@ -763,14 +947,14 @@ var PlaywrightEngine = class {
763
947
  }
764
948
  async screenshot(session, fullPage = false) {
765
949
  const s = this.requireSession(session.id);
766
- return s.session.page.screenshot({ fullPage });
950
+ return this.activePage(s).screenshot({ fullPage });
767
951
  }
768
952
  async navigate(session, url) {
769
953
  const s = this.requireSession(session.id);
770
954
  if (s.session.platform !== "web") {
771
955
  throw new UnsupportedPlatformError(s.session.platform);
772
956
  }
773
- await s.session.page.goto(url, { waitUntil: "domcontentloaded" });
957
+ await this.activePage(s).goto(url, { waitUntil: "domcontentloaded" });
774
958
  this.invalidateRefs(s);
775
959
  }
776
960
  /**
@@ -784,7 +968,7 @@ var PlaywrightEngine = class {
784
968
  if (s.session.platform !== "web") {
785
969
  throw new UnsupportedPlatformError(s.session.platform);
786
970
  }
787
- return s.session.page;
971
+ return this.activePage(s);
788
972
  }
789
973
  /** Increment generation; the next ref-using call will see them as stale. */
790
974
  bumpGeneration(sessionId) {
@@ -792,8 +976,311 @@ var PlaywrightEngine = class {
792
976
  this.invalidateRefs(s);
793
977
  }
794
978
  // -------------------------------------------------------------------------
979
+ // v0.5 — input additions
980
+ // -------------------------------------------------------------------------
981
+ async hover(session, ref) {
982
+ const s = this.requireSession(session.id);
983
+ const locator = this.resolveLocator(s, ref);
984
+ await locator.hover();
985
+ }
986
+ async drag(session, fromRef, toRef) {
987
+ const s = this.requireSession(session.id);
988
+ const from = this.resolveLocator(s, fromRef);
989
+ const to = this.resolveLocator(s, toRef);
990
+ await from.dragTo(to);
991
+ this.invalidateRefs(s);
992
+ }
993
+ async fillForm(session, fields) {
994
+ const s = this.requireSession(session.id);
995
+ for (const field of fields) {
996
+ const locator = this.resolveLocator(s, field.ref);
997
+ const kind = field.kind;
998
+ if (kind === "checkbox" || kind === "radio") {
999
+ const checked = typeof field.value === "boolean" ? field.value : field.value === "true" || field.value === "on";
1000
+ await locator.setChecked(checked);
1001
+ } else if (kind === "select") {
1002
+ await locator.selectOption(String(field.value));
1003
+ } else {
1004
+ await locator.fill(String(field.value));
1005
+ }
1006
+ }
1007
+ this.invalidateRefs(s);
1008
+ }
1009
+ async uploadFile(session, ref, filePath) {
1010
+ const s = this.requireSession(session.id);
1011
+ if (!isAbsolute(filePath)) {
1012
+ throw new RolepodMcpError(
1013
+ "invalid_input",
1014
+ `upload_file requires an absolute path; got "${filePath}".`,
1015
+ { file_path: filePath }
1016
+ );
1017
+ }
1018
+ const locator = this.resolveLocator(s, ref);
1019
+ await locator.setInputFiles(filePath);
1020
+ this.invalidateRefs(s);
1021
+ }
1022
+ // -------------------------------------------------------------------------
1023
+ // v0.5 — web-only extensions (not on Engine interface; tools cast to
1024
+ // PlaywrightEngine before calling).
1025
+ // -------------------------------------------------------------------------
1026
+ /**
1027
+ * Pre-arm a one-shot dialog handler for the next dialog raised on the
1028
+ * active page. Returns when either the dialog fires (and is handled)
1029
+ * or the timeout elapses. The caller is expected to trigger the
1030
+ * dialog (via click etc.) AFTER arming.
1031
+ */
1032
+ async handleDialog(sessionId, opts) {
1033
+ const s = this.requireSession(sessionId);
1034
+ const timeoutMs = opts.timeoutMs ?? 3e4;
1035
+ const expiresAt = Date.now() + timeoutMs;
1036
+ if (s.dialogArming) {
1037
+ s.dialogArming.resolve(false);
1038
+ }
1039
+ return new Promise((resolve5) => {
1040
+ const arming = {
1041
+ action: opts.action,
1042
+ text: opts.text,
1043
+ expiresAt,
1044
+ resolve: (handled) => {
1045
+ s.dialogArming = null;
1046
+ resolve5({ handled });
1047
+ }
1048
+ };
1049
+ s.dialogArming = arming;
1050
+ const timer = setTimeout(() => {
1051
+ if (s.dialogArming === arming) {
1052
+ s.dialogArming = null;
1053
+ resolve5({ handled: false });
1054
+ }
1055
+ }, timeoutMs);
1056
+ timer.unref?.();
1057
+ });
1058
+ }
1059
+ getConsole(sessionId, opts) {
1060
+ const s = this.requireSession(sessionId);
1061
+ const levels = opts?.levels;
1062
+ const contains = opts?.contains;
1063
+ const limit = opts?.limit ?? 50;
1064
+ let entries = s.consoleBuffer;
1065
+ if (levels && levels.length > 0) {
1066
+ entries = entries.filter((e) => levels.includes(e.level));
1067
+ }
1068
+ if (contains) {
1069
+ entries = entries.filter((e) => e.text.includes(contains));
1070
+ }
1071
+ const result = entries.slice(-limit);
1072
+ if (opts?.clear) s.consoleBuffer = [];
1073
+ return result;
1074
+ }
1075
+ getNetwork(sessionId, opts) {
1076
+ const s = this.requireSession(sessionId);
1077
+ let entries = s.networkBuffer;
1078
+ if (opts?.urlPattern) {
1079
+ if (opts.patternKind === "regex") {
1080
+ const re = new RegExp(opts.urlPattern);
1081
+ entries = entries.filter((e) => re.test(e.url));
1082
+ } else {
1083
+ entries = entries.filter((e) => e.url.includes(opts.urlPattern));
1084
+ }
1085
+ }
1086
+ if (opts?.method) {
1087
+ const m = opts.method.toUpperCase();
1088
+ entries = entries.filter((e) => e.method.toUpperCase() === m);
1089
+ }
1090
+ if (opts?.statusRange) {
1091
+ const { min, max } = opts.statusRange;
1092
+ entries = entries.filter(
1093
+ (e) => e.status !== void 0 && e.status >= min && e.status <= max
1094
+ );
1095
+ }
1096
+ if (opts?.onlyFailed) {
1097
+ entries = entries.filter(
1098
+ (e) => !!e.failure || e.status !== void 0 && e.status >= 400
1099
+ );
1100
+ }
1101
+ const limit = opts?.limit ?? 50;
1102
+ const result = entries.slice(-limit);
1103
+ if (opts?.clear) s.networkBuffer = [];
1104
+ return result;
1105
+ }
1106
+ /**
1107
+ * Read the consoleBuffer/networkBuffer directly without filtering —
1108
+ * used by verify_ui_flow expect evaluators.
1109
+ */
1110
+ peekBuffers(sessionId) {
1111
+ const s = this.requireSession(sessionId);
1112
+ return { console: s.consoleBuffer, network: s.networkBuffer };
1113
+ }
1114
+ /**
1115
+ * Runtime mutation of context-level emulation. CPU + network throttle
1116
+ * use CDP and only work on chromium; everything else is cross-browser.
1117
+ */
1118
+ async setEnv(sessionId, opts) {
1119
+ const s = this.requireSession(sessionId);
1120
+ const page = this.activePage(s);
1121
+ const ctx = s.session.context;
1122
+ if (opts.viewport) {
1123
+ await page.setViewportSize(opts.viewport);
1124
+ }
1125
+ if (opts.offline !== void 0) {
1126
+ await ctx.setOffline(opts.offline);
1127
+ }
1128
+ if (opts.geolocation) {
1129
+ await ctx.setGeolocation(opts.geolocation);
1130
+ }
1131
+ if (opts.extraHeaders) {
1132
+ await ctx.setExtraHTTPHeaders(opts.extraHeaders);
1133
+ }
1134
+ if (opts.colorScheme || opts.reducedMotion) {
1135
+ await page.emulateMedia({
1136
+ ...opts.colorScheme ? { colorScheme: opts.colorScheme } : {},
1137
+ ...opts.reducedMotion ? { reducedMotion: opts.reducedMotion } : {}
1138
+ });
1139
+ }
1140
+ if (opts.networkThrottle || opts.cpuThrottle !== void 0) {
1141
+ const browserName = ctx.browser()?.browserType().name();
1142
+ if (browserName !== "chromium") {
1143
+ throw new RolepodMcpError(
1144
+ "unsupported_engine",
1145
+ `networkThrottle / cpuThrottle require chromium (CDP-backed); current browser is "${browserName}".`
1146
+ );
1147
+ }
1148
+ const cdp = await ctx.newCDPSession(page);
1149
+ try {
1150
+ if (opts.networkThrottle) {
1151
+ const preset = NETWORK_PRESETS[opts.networkThrottle];
1152
+ await cdp.send("Network.enable");
1153
+ await cdp.send("Network.emulateNetworkConditions", preset);
1154
+ }
1155
+ if (opts.cpuThrottle !== void 0) {
1156
+ await cdp.send("Emulation.setCPUThrottlingRate", {
1157
+ rate: opts.cpuThrottle
1158
+ });
1159
+ }
1160
+ } finally {
1161
+ await cdp.detach().catch(() => void 0);
1162
+ }
1163
+ }
1164
+ this.invalidateRefs(s);
1165
+ }
1166
+ /**
1167
+ * Execute a JavaScript function in the page context. ALWAYS gated by
1168
+ * the tool layer (`ROLEPOD_ALLOW_EVAL=1`); this method does not enforce
1169
+ * the env check.
1170
+ */
1171
+ async evaluate(sessionId, script, args) {
1172
+ const s = this.requireSession(sessionId);
1173
+ const page = this.activePage(s);
1174
+ return page.evaluate(
1175
+ ({ src, a }) => (
1176
+ // eslint-disable-next-line no-new-func
1177
+ new Function("args", `return (async () => { ${src} })();`)(a)
1178
+ ),
1179
+ { src: script, a: args ?? [] }
1180
+ );
1181
+ }
1182
+ listPages(sessionId) {
1183
+ const s = this.requireSession(sessionId);
1184
+ return s.pages.map((p, i) => ({
1185
+ index: i,
1186
+ url: p.url(),
1187
+ title_promise: p.title(),
1188
+ active: i === s.activePageIndex
1189
+ }));
1190
+ }
1191
+ async switchPage(sessionId, index) {
1192
+ const s = this.requireSession(sessionId);
1193
+ if (index < 0 || index >= s.pages.length) {
1194
+ throw new RolepodMcpError(
1195
+ "invalid_input",
1196
+ `Page index ${index} out of range (have ${s.pages.length} page(s)).`,
1197
+ { index, available: s.pages.length }
1198
+ );
1199
+ }
1200
+ s.activePageIndex = index;
1201
+ this.invalidateRefs(s);
1202
+ }
1203
+ // -------------------------------------------------------------------------
795
1204
  // Internal helpers
796
1205
  // -------------------------------------------------------------------------
1206
+ activePage(s) {
1207
+ return s.pages[s.activePageIndex] ?? s.session.mainPage;
1208
+ }
1209
+ attachPageListeners(s, page) {
1210
+ page.on("console", (msg) => {
1211
+ const level = mapConsoleLevel(msg.type());
1212
+ pushRing(
1213
+ s.consoleBuffer,
1214
+ {
1215
+ level,
1216
+ text: msg.text(),
1217
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1218
+ location: formatConsoleLocation(msg)
1219
+ },
1220
+ CONSOLE_BUFFER_CAP
1221
+ );
1222
+ });
1223
+ page.on("request", (req) => {
1224
+ const id = s.networkNextId++;
1225
+ s.networkInflight.set(req.url() + "::" + req.method() + "::" + id, {
1226
+ id,
1227
+ startedAt: Date.now(),
1228
+ resourceType: req.resourceType()
1229
+ });
1230
+ pushRing(
1231
+ s.networkBuffer,
1232
+ {
1233
+ id,
1234
+ url: req.url(),
1235
+ method: req.method(),
1236
+ resource_type: req.resourceType(),
1237
+ ts: (/* @__PURE__ */ new Date()).toISOString()
1238
+ },
1239
+ NETWORK_BUFFER_CAP
1240
+ );
1241
+ });
1242
+ page.on("response", (res) => {
1243
+ const req = res.request();
1244
+ const entry = findNetworkEntry(s.networkBuffer, req);
1245
+ if (entry) {
1246
+ entry.status = res.status();
1247
+ entry.duration_ms = Date.now() - new Date(entry.ts).getTime();
1248
+ }
1249
+ });
1250
+ page.on("requestfailed", (req) => {
1251
+ const entry = findNetworkEntry(s.networkBuffer, req);
1252
+ if (entry) {
1253
+ entry.failure = req.failure()?.errorText ?? "request failed";
1254
+ }
1255
+ });
1256
+ page.on("dialog", (dialog) => {
1257
+ void this.handlePageDialog(s, dialog);
1258
+ });
1259
+ }
1260
+ async handlePageDialog(s, dialog) {
1261
+ const arm = s.dialogArming;
1262
+ if (!arm || Date.now() > arm.expiresAt) {
1263
+ await dialog.dismiss().catch(() => void 0);
1264
+ if (arm) arm.resolve(false);
1265
+ return;
1266
+ }
1267
+ try {
1268
+ if (arm.action === "accept") {
1269
+ await dialog.accept();
1270
+ } else if (arm.action === "accept_with_text") {
1271
+ await dialog.accept(arm.text ?? "");
1272
+ } else {
1273
+ await dialog.dismiss();
1274
+ }
1275
+ arm.resolve(true);
1276
+ } catch (err) {
1277
+ log.warn("dialog handle failed", {
1278
+ session_id: s.session.id,
1279
+ err: String(err)
1280
+ });
1281
+ arm.resolve(false);
1282
+ }
1283
+ }
797
1284
  requireSession(sessionId) {
798
1285
  const s = this.sessions.get(sessionId);
799
1286
  if (!s) {
@@ -822,7 +1309,7 @@ var PlaywrightEngine = class {
822
1309
  if (meta.ref.startsWith("s")) {
823
1310
  throw new UnknownRefError(s.session.id, ref);
824
1311
  }
825
- return s.session.page.locator(`aria-ref=${meta.ref}`);
1312
+ return this.activePage(s).locator(`aria-ref=${meta.ref}`);
826
1313
  }
827
1314
  invalidateRefs(s) {
828
1315
  s.snapshotGeneration += 1;
@@ -1064,6 +1551,143 @@ var browserNavigateShape = {
1064
1551
  url: z.string().url()
1065
1552
  };
1066
1553
  var browserNavigateSchema = z.object(browserNavigateShape);
1554
+ var browserHoverShape = {
1555
+ session_id: z.string().min(1),
1556
+ ref: z.string().min(1)
1557
+ };
1558
+ var browserHoverSchema = z.object(browserHoverShape);
1559
+ var browserDragShape = {
1560
+ session_id: z.string().min(1),
1561
+ from_ref: z.string().min(1),
1562
+ to_ref: z.string().min(1)
1563
+ };
1564
+ var browserDragSchema = z.object(browserDragShape);
1565
+ var fillFieldKindSchema = z.enum([
1566
+ "input",
1567
+ "select",
1568
+ "checkbox",
1569
+ "radio"
1570
+ ]);
1571
+ var fillFormFieldSchema = z.object({
1572
+ ref: z.string().min(1),
1573
+ value: z.union([z.string(), z.boolean()]),
1574
+ kind: fillFieldKindSchema.optional()
1575
+ });
1576
+ var browserFillFormShape = {
1577
+ session_id: z.string().min(1),
1578
+ fields: z.array(fillFormFieldSchema).min(1)
1579
+ };
1580
+ var browserFillFormSchema = z.object(browserFillFormShape);
1581
+ var browserUploadFileShape = {
1582
+ session_id: z.string().min(1),
1583
+ ref: z.string().min(1),
1584
+ file_path: z.string().min(1)
1585
+ };
1586
+ var browserUploadFileSchema = z.object(browserUploadFileShape);
1587
+ var dialogActionSchema = z.enum([
1588
+ "accept",
1589
+ "dismiss",
1590
+ "accept_with_text"
1591
+ ]);
1592
+ var browserHandleDialogShape = {
1593
+ session_id: z.string().min(1),
1594
+ action: dialogActionSchema,
1595
+ /** Only used when action='accept_with_text'. */
1596
+ text: z.string().optional(),
1597
+ /**
1598
+ * Arming behavior: registers a one-shot handler for the NEXT dialog
1599
+ * raised on the page. Call this BEFORE the action that triggers the
1600
+ * dialog (e.g. before clicking the button that calls `confirm()`).
1601
+ * Default 30s if no dialog appears, handler is auto-removed.
1602
+ */
1603
+ timeout_ms: z.number().int().positive().optional()
1604
+ };
1605
+ var browserHandleDialogSchema = z.object(browserHandleDialogShape);
1606
+ var consoleLevelSchema = z.enum([
1607
+ "error",
1608
+ "warning",
1609
+ "info",
1610
+ "log",
1611
+ "debug",
1612
+ "trace"
1613
+ ]);
1614
+ var browserConsoleShape = {
1615
+ session_id: z.string().min(1),
1616
+ /** Filter to only these levels. Default: errors+warnings. */
1617
+ levels: z.array(consoleLevelSchema).optional(),
1618
+ /** Substring match on message text. */
1619
+ contains: z.string().optional(),
1620
+ /** Drop all buffered messages after returning. */
1621
+ clear: z.boolean().default(false),
1622
+ /** Cap on returned messages (artifact still holds full ring buffer). */
1623
+ limit: z.number().int().positive().max(1e3).default(50)
1624
+ };
1625
+ var browserConsoleSchema = z.object(browserConsoleShape);
1626
+ var browserNetworkShape = {
1627
+ session_id: z.string().min(1),
1628
+ /** Substring or regex (per `pattern_kind`) match on URL. */
1629
+ url_pattern: z.string().optional(),
1630
+ pattern_kind: z.enum(["substring", "regex"]).default("substring"),
1631
+ method: z.string().optional(),
1632
+ /** Inclusive range — e.g. `{min: 400, max: 599}` for any error response. */
1633
+ status_range: z.object({
1634
+ min: z.number().int().min(100).max(599),
1635
+ max: z.number().int().min(100).max(599)
1636
+ }).optional(),
1637
+ only_failed: z.boolean().default(false),
1638
+ /** Write the full HAR file for this session to artifacts/{runId}/network.har. */
1639
+ export_har: z.boolean().default(false),
1640
+ /** Drop buffered entries after returning. */
1641
+ clear: z.boolean().default(false),
1642
+ limit: z.number().int().positive().max(1e3).default(50)
1643
+ };
1644
+ var browserNetworkSchema = z.object(browserNetworkShape);
1645
+ var networkPresetSchema = z.enum([
1646
+ "offline",
1647
+ "slow-3g",
1648
+ "fast-3g",
1649
+ "slow-4g",
1650
+ "fast-4g",
1651
+ "no-throttling"
1652
+ ]);
1653
+ var geolocationSchema = z.object({
1654
+ latitude: z.number().min(-90).max(90),
1655
+ longitude: z.number().min(-180).max(180),
1656
+ accuracy: z.number().nonnegative().optional()
1657
+ });
1658
+ var browserSetEnvShape = {
1659
+ session_id: z.string().min(1),
1660
+ viewport: viewportSchema.optional(),
1661
+ offline: z.boolean().optional(),
1662
+ geolocation: geolocationSchema.optional(),
1663
+ color_scheme: z.enum(["light", "dark", "no-preference"]).optional(),
1664
+ reduced_motion: z.enum(["reduce", "no-preference"]).optional(),
1665
+ extra_headers: z.record(z.string(), z.string()).optional(),
1666
+ network_throttle: networkPresetSchema.optional(),
1667
+ /** CPU slowdown multiplier (1 = no throttle, 4 = 4x slower). Chromium only. */
1668
+ cpu_throttle: z.number().min(1).max(20).optional()
1669
+ };
1670
+ var browserSetEnvSchema = z.object(browserSetEnvShape);
1671
+ var browserEvaluateShape = {
1672
+ session_id: z.string().min(1),
1673
+ script: z.string().min(1),
1674
+ args: z.array(z.unknown()).optional()
1675
+ };
1676
+ var browserEvaluateSchema = z.object(browserEvaluateShape);
1677
+ var browserPagesShape = {
1678
+ session_id: z.string().min(1)
1679
+ };
1680
+ var browserPagesSchema = z.object(browserPagesShape);
1681
+ var browserSwitchPageShape = {
1682
+ session_id: z.string().min(1),
1683
+ index: z.number().int().nonnegative()
1684
+ };
1685
+ var browserSwitchPageSchema = z.object(browserSwitchPageShape);
1686
+ var verifyFillFieldSchema = z.object({
1687
+ query: z.string(),
1688
+ value: z.union([z.string(), z.boolean()]),
1689
+ kind: fillFieldKindSchema.optional()
1690
+ });
1067
1691
  var verifyStepSchema = z.discriminatedUnion("kind", [
1068
1692
  z.object({ kind: z.literal("click"), query: z.string() }),
1069
1693
  z.object({
@@ -1074,7 +1698,44 @@ var verifyStepSchema = z.discriminatedUnion("kind", [
1074
1698
  }),
1075
1699
  z.object({ kind: z.literal("key"), key: z.string() }),
1076
1700
  z.object({ kind: z.literal("wait_for"), condition: waitConditionSchema }),
1077
- z.object({ kind: z.literal("navigate"), url: z.string().url() })
1701
+ z.object({ kind: z.literal("navigate"), url: z.string().url() }),
1702
+ // v0.5 additions
1703
+ z.object({ kind: z.literal("hover"), query: z.string() }),
1704
+ z.object({
1705
+ kind: z.literal("drag"),
1706
+ from_query: z.string(),
1707
+ to_query: z.string()
1708
+ }),
1709
+ z.object({
1710
+ kind: z.literal("fill_form"),
1711
+ fields: z.array(verifyFillFieldSchema).min(1)
1712
+ }),
1713
+ z.object({
1714
+ kind: z.literal("upload"),
1715
+ query: z.string(),
1716
+ file_path: z.string().min(1)
1717
+ }),
1718
+ z.object({
1719
+ kind: z.literal("dialog"),
1720
+ action: dialogActionSchema,
1721
+ text: z.string().optional()
1722
+ }),
1723
+ z.object({
1724
+ kind: z.literal("set_env"),
1725
+ viewport: viewportSchema.optional(),
1726
+ offline: z.boolean().optional(),
1727
+ geolocation: geolocationSchema.optional(),
1728
+ color_scheme: z.enum(["light", "dark", "no-preference"]).optional(),
1729
+ reduced_motion: z.enum(["reduce", "no-preference"]).optional(),
1730
+ extra_headers: z.record(z.string(), z.string()).optional(),
1731
+ network_throttle: networkPresetSchema.optional(),
1732
+ cpu_throttle: z.number().min(1).max(20).optional()
1733
+ }),
1734
+ z.object({
1735
+ kind: z.literal("switch_page"),
1736
+ index: z.number().int().nonnegative()
1737
+ }),
1738
+ z.object({ kind: z.literal("evaluate"), script: z.string().min(1) })
1078
1739
  ]);
1079
1740
  var verifyExpectSchema = z.discriminatedUnion("kind", [
1080
1741
  z.object({ kind: z.literal("text_visible"), text: z.string() }),
@@ -1084,6 +1745,28 @@ var verifyExpectSchema = z.discriminatedUnion("kind", [
1084
1745
  kind: z.literal("ref_in_state"),
1085
1746
  query: z.string(),
1086
1747
  state: z.enum(["visible", "enabled", "focused"])
1748
+ }),
1749
+ // v0.5 additions
1750
+ z.object({
1751
+ kind: z.literal("no_console_errors"),
1752
+ exclude_patterns: z.array(z.string()).optional()
1753
+ }),
1754
+ z.object({
1755
+ kind: z.literal("no_failed_requests"),
1756
+ exclude_patterns: z.array(z.string()).optional(),
1757
+ /** When true, only 5xx counts as a failure. Default false (4xx + 5xx). */
1758
+ allow_4xx: z.boolean().optional()
1759
+ }),
1760
+ z.object({
1761
+ kind: z.literal("request_made"),
1762
+ url_pattern: z.string(),
1763
+ method: z.string().optional(),
1764
+ min_count: z.number().int().positive().optional()
1765
+ }),
1766
+ z.object({
1767
+ kind: z.literal("response_status"),
1768
+ url_pattern: z.string(),
1769
+ status: z.number().int().min(100).max(599)
1087
1770
  })
1088
1771
  ]);
1089
1772
  var captureKindSchema = z.enum([
@@ -1091,7 +1774,8 @@ var captureKindSchema = z.enum([
1091
1774
  "har",
1092
1775
  "console",
1093
1776
  "a11y_tree",
1094
- "video"
1777
+ "video",
1778
+ "trace"
1095
1779
  ]);
1096
1780
  var verifyUiFlowShape = {
1097
1781
  mode: z.enum(["assert", "reproduce"]).default("assert"),
@@ -1169,6 +1853,19 @@ var ToolNames = {
1169
1853
  browserWaitFor: "rolepod_browser_wait_for",
1170
1854
  browserScreenshot: "rolepod_browser_screenshot",
1171
1855
  browserNavigate: "rolepod_browser_navigate",
1856
+ // v0.5 atomics
1857
+ browserHover: "rolepod_browser_hover",
1858
+ browserDrag: "rolepod_browser_drag",
1859
+ browserFillForm: "rolepod_browser_fill_form",
1860
+ browserUploadFile: "rolepod_browser_upload_file",
1861
+ browserHandleDialog: "rolepod_browser_handle_dialog",
1862
+ browserConsole: "rolepod_browser_console",
1863
+ browserNetwork: "rolepod_browser_network",
1864
+ browserSetEnv: "rolepod_browser_set_env",
1865
+ browserEvaluate: "rolepod_browser_evaluate",
1866
+ browserPages: "rolepod_browser_pages",
1867
+ browserSwitchPage: "rolepod_browser_switch_page",
1868
+ // composite
1172
1869
  verifyUiFlow: "rolepod_verify_ui_flow",
1173
1870
  auditA11y: "rolepod_audit_a11y",
1174
1871
  visualDiff: "rolepod_visual_diff",
@@ -1242,43 +1939,256 @@ var browserCloseTool = {
1242
1939
  }
1243
1940
  };
1244
1941
 
1245
- // src/tools/atomic/browser_key.ts
1246
- var browserKeyTool = {
1247
- name: ToolNames.browserKey,
1248
- description: "Press a single key (e.g. 'Enter', 'Tab', 'Escape'). Invalidates all refs on success.",
1249
- inputShape: browserKeyShape,
1942
+ // src/tools/atomic/browser_console.ts
1943
+ var browserConsoleTool = {
1944
+ name: ToolNames.browserConsole,
1945
+ 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.",
1946
+ inputShape: browserConsoleShape,
1250
1947
  build(ctx) {
1251
1948
  return safeHandler(async (args) => {
1252
1949
  const engine = ctx.registry.engineFor(args.session_id);
1253
- await engine.key({ id: args.session_id, platform: ctx.registry.platformOf(args.session_id) }, args.key);
1254
- return ok({ pressed: true });
1950
+ if (!(engine instanceof PlaywrightEngine)) {
1951
+ throw new RolepodMcpError(
1952
+ "unsupported_engine",
1953
+ "console is web-only and requires PlaywrightEngine."
1954
+ );
1955
+ }
1956
+ const levels = args.levels && args.levels.length > 0 ? args.levels : ["error", "warning"];
1957
+ const messages = engine.getConsole(args.session_id, {
1958
+ levels,
1959
+ ...args.contains !== void 0 ? { contains: args.contains } : {},
1960
+ clear: args.clear,
1961
+ limit: args.limit
1962
+ });
1963
+ return ok({
1964
+ count: messages.length,
1965
+ messages
1966
+ });
1255
1967
  });
1256
1968
  }
1257
1969
  };
1258
1970
 
1259
- // src/tools/atomic/browser_navigate.ts
1260
- var browserNavigateTool = {
1261
- name: ToolNames.browserNavigate,
1262
- description: "Navigate the session to a new URL (web only). Invalidates all refs on success.",
1263
- inputShape: browserNavigateShape,
1971
+ // src/tools/atomic/browser_drag.ts
1972
+ var browserDragTool = {
1973
+ name: ToolNames.browserDrag,
1974
+ 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.",
1975
+ inputShape: browserDragShape,
1264
1976
  build(ctx) {
1265
1977
  return safeHandler(async (args) => {
1266
1978
  const engine = ctx.registry.engineFor(args.session_id);
1267
- await engine.navigate({ id: args.session_id, platform: ctx.registry.platformOf(args.session_id) }, args.url);
1268
- return ok({ navigated: true, url: args.url });
1979
+ await engine.drag(
1980
+ {
1981
+ id: args.session_id,
1982
+ platform: ctx.registry.platformOf(args.session_id)
1983
+ },
1984
+ args.from_ref,
1985
+ args.to_ref
1986
+ );
1987
+ return ok({ dragged: true });
1269
1988
  });
1270
1989
  }
1271
1990
  };
1272
1991
 
1273
- // src/tools/atomic/browser_open.ts
1274
- var browserOpenTool = {
1275
- name: ToolNames.browserOpen,
1276
- description: "Open a new browser or mobile session against a target. v0.1 supports platform='web' only; mobile lands in v0.3.",
1277
- inputShape: browserOpenShape,
1992
+ // src/tools/atomic/browser_evaluate.ts
1993
+ var browserEvaluateTool = {
1994
+ name: ToolNames.browserEvaluate,
1995
+ 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).",
1996
+ inputShape: browserEvaluateShape,
1278
1997
  build(ctx) {
1998
+ const allowed = process.env.ROLEPOD_ALLOW_EVAL === "1";
1279
1999
  return safeHandler(async (args) => {
1280
- const session = await ctx.registry.open(args);
1281
- return ok({ session_id: session.id, platform: session.platform });
2000
+ if (!allowed) {
2001
+ throw new RolepodMcpError(
2002
+ "engine_error",
2003
+ "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."
2004
+ );
2005
+ }
2006
+ const engine = ctx.registry.engineFor(args.session_id);
2007
+ if (!(engine instanceof PlaywrightEngine)) {
2008
+ throw new RolepodMcpError(
2009
+ "unsupported_engine",
2010
+ "evaluate is web-only and requires PlaywrightEngine."
2011
+ );
2012
+ }
2013
+ const result = await engine.evaluate(
2014
+ args.session_id,
2015
+ args.script,
2016
+ args.args
2017
+ );
2018
+ return ok({ result });
2019
+ });
2020
+ }
2021
+ };
2022
+
2023
+ // src/tools/atomic/browser_fill_form.ts
2024
+ var browserFillFormTool = {
2025
+ name: ToolNames.browserFillForm,
2026
+ 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.",
2027
+ inputShape: browserFillFormShape,
2028
+ build(ctx) {
2029
+ return safeHandler(async (args) => {
2030
+ const engine = ctx.registry.engineFor(args.session_id);
2031
+ await engine.fillForm(
2032
+ {
2033
+ id: args.session_id,
2034
+ platform: ctx.registry.platformOf(args.session_id)
2035
+ },
2036
+ args.fields
2037
+ );
2038
+ return ok({ filled: args.fields.length });
2039
+ });
2040
+ }
2041
+ };
2042
+
2043
+ // src/tools/atomic/browser_handle_dialog.ts
2044
+ var browserHandleDialogTool = {
2045
+ name: ToolNames.browserHandleDialog,
2046
+ 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.",
2047
+ inputShape: browserHandleDialogShape,
2048
+ build(ctx) {
2049
+ return safeHandler(async (args) => {
2050
+ const engine = ctx.registry.engineFor(args.session_id);
2051
+ if (!(engine instanceof PlaywrightEngine)) {
2052
+ throw new RolepodMcpError(
2053
+ "unsupported_engine",
2054
+ "handle_dialog is web-only and requires PlaywrightEngine."
2055
+ );
2056
+ }
2057
+ const { handled } = await engine.handleDialog(args.session_id, {
2058
+ action: args.action,
2059
+ ...args.text !== void 0 ? { text: args.text } : {},
2060
+ ...args.timeout_ms !== void 0 ? { timeoutMs: args.timeout_ms } : {}
2061
+ });
2062
+ return ok({ handled, action: args.action });
2063
+ });
2064
+ }
2065
+ };
2066
+
2067
+ // src/tools/atomic/browser_hover.ts
2068
+ var browserHoverTool = {
2069
+ name: ToolNames.browserHover,
2070
+ description: "Hover the pointer over the element identified by `ref`. Refs remain valid afterwards (read-mostly).",
2071
+ inputShape: browserHoverShape,
2072
+ build(ctx) {
2073
+ return safeHandler(async (args) => {
2074
+ const engine = ctx.registry.engineFor(args.session_id);
2075
+ await engine.hover(
2076
+ {
2077
+ id: args.session_id,
2078
+ platform: ctx.registry.platformOf(args.session_id)
2079
+ },
2080
+ args.ref
2081
+ );
2082
+ return ok({ hovered: true });
2083
+ });
2084
+ }
2085
+ };
2086
+
2087
+ // src/tools/atomic/browser_key.ts
2088
+ var browserKeyTool = {
2089
+ name: ToolNames.browserKey,
2090
+ description: "Press a single key (e.g. 'Enter', 'Tab', 'Escape'). Invalidates all refs on success.",
2091
+ inputShape: browserKeyShape,
2092
+ build(ctx) {
2093
+ return safeHandler(async (args) => {
2094
+ const engine = ctx.registry.engineFor(args.session_id);
2095
+ await engine.key({ id: args.session_id, platform: ctx.registry.platformOf(args.session_id) }, args.key);
2096
+ return ok({ pressed: true });
2097
+ });
2098
+ }
2099
+ };
2100
+
2101
+ // src/tools/atomic/browser_navigate.ts
2102
+ var browserNavigateTool = {
2103
+ name: ToolNames.browserNavigate,
2104
+ description: "Navigate the session to a new URL (web only). Invalidates all refs on success.",
2105
+ inputShape: browserNavigateShape,
2106
+ build(ctx) {
2107
+ return safeHandler(async (args) => {
2108
+ const engine = ctx.registry.engineFor(args.session_id);
2109
+ await engine.navigate({ id: args.session_id, platform: ctx.registry.platformOf(args.session_id) }, args.url);
2110
+ return ok({ navigated: true, url: args.url });
2111
+ });
2112
+ }
2113
+ };
2114
+
2115
+ // src/tools/atomic/browser_network.ts
2116
+ var browserNetworkTool = {
2117
+ name: ToolNames.browserNetwork,
2118
+ 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`.',
2119
+ inputShape: browserNetworkShape,
2120
+ build(ctx) {
2121
+ return safeHandler(async (args) => {
2122
+ const engine = ctx.registry.engineFor(args.session_id);
2123
+ if (!(engine instanceof PlaywrightEngine)) {
2124
+ throw new RolepodMcpError(
2125
+ "unsupported_engine",
2126
+ "network is web-only and requires PlaywrightEngine."
2127
+ );
2128
+ }
2129
+ const requests = engine.getNetwork(args.session_id, {
2130
+ ...args.url_pattern !== void 0 ? { urlPattern: args.url_pattern } : {},
2131
+ patternKind: args.pattern_kind,
2132
+ ...args.method !== void 0 ? { method: args.method } : {},
2133
+ ...args.status_range !== void 0 ? { statusRange: args.status_range } : {},
2134
+ onlyFailed: args.only_failed,
2135
+ clear: args.clear,
2136
+ limit: args.limit
2137
+ });
2138
+ const failed = requests.filter(
2139
+ (r) => !!r.failure || r.status !== void 0 && r.status >= 400
2140
+ ).length;
2141
+ return ok({
2142
+ count: requests.length,
2143
+ failed_count: failed,
2144
+ requests,
2145
+ // HAR file lives wherever the session was opened with
2146
+ // `capture.har.path`. We don't echo it here to avoid leaking
2147
+ // filesystem paths into untrusted logs; the verify_ui_flow run
2148
+ // result surfaces it in `evidence_paths.har`.
2149
+ har_recording: args.export_har ? "HAR is written at session close to the path passed via capture.har at open time." : void 0
2150
+ });
2151
+ });
2152
+ }
2153
+ };
2154
+
2155
+ // src/tools/atomic/browser_open.ts
2156
+ var browserOpenTool = {
2157
+ name: ToolNames.browserOpen,
2158
+ description: "Open a new browser or mobile session against a target. v0.1 supports platform='web' only; mobile lands in v0.3.",
2159
+ inputShape: browserOpenShape,
2160
+ build(ctx) {
2161
+ return safeHandler(async (args) => {
2162
+ const session = await ctx.registry.open(args);
2163
+ return ok({ session_id: session.id, platform: session.platform });
2164
+ });
2165
+ }
2166
+ };
2167
+
2168
+ // src/tools/atomic/browser_pages.ts
2169
+ var browserPagesTool = {
2170
+ name: ToolNames.browserPages,
2171
+ 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.",
2172
+ inputShape: browserPagesShape,
2173
+ build(ctx) {
2174
+ return safeHandler(async (args) => {
2175
+ const engine = ctx.registry.engineFor(args.session_id);
2176
+ if (!(engine instanceof PlaywrightEngine)) {
2177
+ throw new RolepodMcpError(
2178
+ "unsupported_engine",
2179
+ "pages is web-only and requires PlaywrightEngine."
2180
+ );
2181
+ }
2182
+ const raw = engine.listPages(args.session_id);
2183
+ const pages = await Promise.all(
2184
+ raw.map(async (p) => ({
2185
+ index: p.index,
2186
+ url: p.url,
2187
+ title: await p.title_promise.catch(() => ""),
2188
+ active: p.active
2189
+ }))
2190
+ );
2191
+ return ok({ count: pages.length, pages });
1282
2192
  });
1283
2193
  }
1284
2194
  };
@@ -1327,6 +2237,35 @@ var browserScrollTool = {
1327
2237
  }
1328
2238
  };
1329
2239
 
2240
+ // src/tools/atomic/browser_set_env.ts
2241
+ var browserSetEnvTool = {
2242
+ name: ToolNames.browserSetEnv,
2243
+ 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.",
2244
+ inputShape: browserSetEnvShape,
2245
+ build(ctx) {
2246
+ return safeHandler(async (args) => {
2247
+ const engine = ctx.registry.engineFor(args.session_id);
2248
+ if (!(engine instanceof PlaywrightEngine)) {
2249
+ throw new RolepodMcpError(
2250
+ "unsupported_engine",
2251
+ "set_env is web-only and requires PlaywrightEngine."
2252
+ );
2253
+ }
2254
+ await engine.setEnv(args.session_id, {
2255
+ ...args.viewport !== void 0 ? { viewport: args.viewport } : {},
2256
+ ...args.offline !== void 0 ? { offline: args.offline } : {},
2257
+ ...args.geolocation !== void 0 ? { geolocation: args.geolocation } : {},
2258
+ ...args.color_scheme !== void 0 ? { colorScheme: args.color_scheme } : {},
2259
+ ...args.reduced_motion !== void 0 ? { reducedMotion: args.reduced_motion } : {},
2260
+ ...args.extra_headers !== void 0 ? { extraHeaders: args.extra_headers } : {},
2261
+ ...args.network_throttle !== void 0 ? { networkThrottle: args.network_throttle } : {},
2262
+ ...args.cpu_throttle !== void 0 ? { cpuThrottle: args.cpu_throttle } : {}
2263
+ });
2264
+ return ok({ applied: true });
2265
+ });
2266
+ }
2267
+ };
2268
+
1330
2269
  // src/tools/atomic/browser_snapshot.ts
1331
2270
  var browserSnapshotTool = {
1332
2271
  name: ToolNames.browserSnapshot,
@@ -1349,6 +2288,26 @@ var browserSnapshotTool = {
1349
2288
  }
1350
2289
  };
1351
2290
 
2291
+ // src/tools/atomic/browser_switch_page.ts
2292
+ var browserSwitchPageTool = {
2293
+ name: ToolNames.browserSwitchPage,
2294
+ 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.",
2295
+ inputShape: browserSwitchPageShape,
2296
+ build(ctx) {
2297
+ return safeHandler(async (args) => {
2298
+ const engine = ctx.registry.engineFor(args.session_id);
2299
+ if (!(engine instanceof PlaywrightEngine)) {
2300
+ throw new RolepodMcpError(
2301
+ "unsupported_engine",
2302
+ "switch_page is web-only and requires PlaywrightEngine."
2303
+ );
2304
+ }
2305
+ await engine.switchPage(args.session_id, args.index);
2306
+ return ok({ active_index: args.index });
2307
+ });
2308
+ }
2309
+ };
2310
+
1352
2311
  // src/tools/atomic/browser_type.ts
1353
2312
  var browserTypeTool = {
1354
2313
  name: ToolNames.browserType,
@@ -1368,6 +2327,27 @@ var browserTypeTool = {
1368
2327
  }
1369
2328
  };
1370
2329
 
2330
+ // src/tools/atomic/browser_upload_file.ts
2331
+ var browserUploadFileTool = {
2332
+ name: ToolNames.browserUploadFile,
2333
+ 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.",
2334
+ inputShape: browserUploadFileShape,
2335
+ build(ctx) {
2336
+ return safeHandler(async (args) => {
2337
+ const engine = ctx.registry.engineFor(args.session_id);
2338
+ await engine.uploadFile(
2339
+ {
2340
+ id: args.session_id,
2341
+ platform: ctx.registry.platformOf(args.session_id)
2342
+ },
2343
+ args.ref,
2344
+ args.file_path
2345
+ );
2346
+ return ok({ uploaded: true, file_path: args.file_path });
2347
+ });
2348
+ }
2349
+ };
2350
+
1371
2351
  // src/tools/atomic/browser_wait_for.ts
1372
2352
  var browserWaitForTool = {
1373
2353
  name: ToolNames.browserWaitFor,
@@ -1389,6 +2369,39 @@ var browserWaitForTool = {
1389
2369
 
1390
2370
  // src/tools/composite/audit_a11y.ts
1391
2371
  import AxeBuilder from "@axe-core/playwright";
2372
+
2373
+ // src/util/manifest.ts
2374
+ import { writeFile as writeFile2 } from "fs/promises";
2375
+ import { resolve as resolve2 } from "path";
2376
+ var ROLEPOD_PROTOCOL_VERSION = "rolepod/v1";
2377
+ async function writeManifest(input) {
2378
+ const manifest = {
2379
+ protocol: ROLEPOD_PROTOCOL_VERSION,
2380
+ plugin: "rolepod-uiproof",
2381
+ skill: input.skill,
2382
+ phase: input.phase,
2383
+ status: input.status,
2384
+ summary: input.summary,
2385
+ started_at: input.startedAt,
2386
+ finished_at: input.finishedAt,
2387
+ artifacts: input.artifacts,
2388
+ metadata: input.metadata ?? {}
2389
+ };
2390
+ const path = resolve2(input.runDir, "manifest.json");
2391
+ try {
2392
+ await writeFile2(path, JSON.stringify(manifest, null, 2), "utf8");
2393
+ return path;
2394
+ } catch (err) {
2395
+ log.warn("manifest write failed", {
2396
+ run_dir: input.runDir,
2397
+ skill: input.skill,
2398
+ err: String(err)
2399
+ });
2400
+ return void 0;
2401
+ }
2402
+ }
2403
+
2404
+ // src/tools/composite/audit_a11y.ts
1392
2405
  var TAGS_BY_LEVEL = {
1393
2406
  "wcag-a": ["wcag2a", "wcag21a"],
1394
2407
  "wcag-aa": ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"],
@@ -1413,7 +2426,11 @@ var auditA11yTool = {
1413
2426
  inputShape: auditA11yShape,
1414
2427
  build(ctx) {
1415
2428
  return safeHandler(async (args) => {
1416
- const { runId, runDir } = await ctx.store.startRun("audit");
2429
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2430
+ const { runId, runDir, skill } = await ctx.store.startRun(
2431
+ "audit",
2432
+ { skill: "audit-a11y" }
2433
+ );
1417
2434
  const session = await ctx.registry.open(args.open);
1418
2435
  const engine = ctx.registry.engineFor(session.id);
1419
2436
  if (!(engine instanceof PlaywrightEngine)) {
@@ -1481,15 +2498,45 @@ var auditA11yTool = {
1481
2498
  await ctx.registry.close(session).catch(() => void 0);
1482
2499
  }
1483
2500
  }
2501
+ const counts = countBySeverity(issues);
2502
+ const status = a11yStatus(counts);
2503
+ const artifacts = reportPath ? [{ type: "report", path: reportPath }] : [];
2504
+ const manifestPath = await writeManifest({
2505
+ runDir,
2506
+ skill,
2507
+ phase: "verify",
2508
+ status,
2509
+ summary: buildAuditSummary(args.level, counts, status),
2510
+ startedAt,
2511
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
2512
+ artifacts,
2513
+ metadata: {
2514
+ level: args.level,
2515
+ scope: args.scope,
2516
+ counts,
2517
+ report_format: args.report_format
2518
+ }
2519
+ });
1484
2520
  return ok({
1485
2521
  run_id: runId,
1486
- counts: countBySeverity(issues),
2522
+ counts,
1487
2523
  issues,
1488
- report_path: reportPath
2524
+ report_path: reportPath,
2525
+ ...manifestPath ? { manifest: manifestPath } : {}
1489
2526
  });
1490
2527
  });
1491
2528
  }
1492
2529
  };
2530
+ function a11yStatus(counts) {
2531
+ if ((counts.critical ?? 0) + (counts.serious ?? 0) > 0) return "fail";
2532
+ if ((counts.moderate ?? 0) + (counts.minor ?? 0) > 0) return "warn";
2533
+ return "pass";
2534
+ }
2535
+ function buildAuditSummary(level, counts, status) {
2536
+ const total = (counts.critical ?? 0) + (counts.serious ?? 0) + (counts.moderate ?? 0) + (counts.minor ?? 0);
2537
+ if (status === "pass") return `${level}: 0 issues`;
2538
+ return `${level}: ${total} issue(s) \u2014 critical=${counts.critical ?? 0}, serious=${counts.serious ?? 0}, moderate=${counts.moderate ?? 0}, minor=${counts.minor ?? 0}`;
2539
+ }
1493
2540
  function pickWcagRef(tags) {
1494
2541
  return tags.find((t) => /^wcag\d/.test(t));
1495
2542
  }
@@ -1606,14 +2653,18 @@ function scoreTree(root, tokens) {
1606
2653
 
1607
2654
  // src/tools/composite/scaffold_e2e.ts
1608
2655
  import { readFile } from "fs/promises";
1609
- import { resolve as resolve2 } from "path";
2656
+ import { resolve as resolve3 } from "path";
1610
2657
  var scaffoldE2eTool = {
1611
2658
  name: ToolNames.scaffoldE2e,
1612
2659
  description: "Generate a runnable e2e test file (playwright-test, vitest+playwright, or pytest+selenium) from a scenario description and optional replay bundle from a prior verify_ui_flow run.",
1613
2660
  inputShape: scaffoldE2eShape,
1614
2661
  build(ctx) {
1615
2662
  return safeHandler(async (args) => {
1616
- const { runId, runDir } = await ctx.store.startRun("scaffold");
2663
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2664
+ const { runId, runDir, skill } = await ctx.store.startRun(
2665
+ "scaffold",
2666
+ { skill: "scaffold-e2e" }
2667
+ );
1617
2668
  const slug = slugify(args.scenario_nl);
1618
2669
  const bundle = args.recorded_bundle ? await loadReplay(args.recorded_bundle) : null;
1619
2670
  const ctxObj = { args, slug, bundle };
@@ -1651,19 +2702,35 @@ var scaffoldE2eTool = {
1651
2702
  );
1652
2703
  }
1653
2704
  const path = await ctx.store.writeReport(runDir, filename, body);
2705
+ const manifestPath = await writeManifest({
2706
+ runDir,
2707
+ skill,
2708
+ phase: "build",
2709
+ status: "pass",
2710
+ summary: `generated ${args.framework} test "${filename}" from ${bundle ? "replay bundle" : "scenario"}`,
2711
+ startedAt,
2712
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
2713
+ artifacts: [{ type: "test_file", path }],
2714
+ metadata: {
2715
+ framework: args.framework,
2716
+ language,
2717
+ from_replay_bundle: Boolean(bundle)
2718
+ }
2719
+ });
1654
2720
  return ok({
1655
2721
  run_id: runId,
1656
2722
  test_file_path: path,
1657
2723
  language,
1658
2724
  dependencies,
1659
2725
  setup_notes: setupNotes,
1660
- from_replay_bundle: Boolean(bundle)
2726
+ from_replay_bundle: Boolean(bundle),
2727
+ ...manifestPath ? { manifest: manifestPath } : {}
1661
2728
  });
1662
2729
  });
1663
2730
  }
1664
2731
  };
1665
2732
  async function loadReplay(bundlePath) {
1666
- const raw = await readFile(resolve2(bundlePath), "utf8");
2733
+ const raw = await readFile(resolve3(bundlePath), "utf8");
1667
2734
  return JSON.parse(raw);
1668
2735
  }
1669
2736
  function slugify(s) {
@@ -1740,6 +2807,67 @@ function playwrightStepLine(step) {
1740
2807
  return ` await page.goto(${JSON.stringify(step.url)});`;
1741
2808
  case "wait_for":
1742
2809
  return ` // wait_for: ${JSON.stringify(step.condition)} \u2014 translate to page.waitForXxx()`;
2810
+ case "hover":
2811
+ return ` await page.getByText(${JSON.stringify(step.query)}, { exact: false }).first().hover();`;
2812
+ case "drag":
2813
+ return [
2814
+ ` await page`,
2815
+ ` .getByText(${JSON.stringify(step.from_query)}, { exact: false })`,
2816
+ ` .first()`,
2817
+ ` .dragTo(page.getByText(${JSON.stringify(step.to_query)}, { exact: false }).first());`
2818
+ ].join("\n");
2819
+ case "fill_form": {
2820
+ const fields = Array.isArray(step.fields) ? step.fields : [];
2821
+ return fields.map((f) => {
2822
+ const q = JSON.stringify(f.query);
2823
+ if (f.kind === "select") {
2824
+ return ` await page.getByLabel(${q}).selectOption(${JSON.stringify(String(f.value))});`;
2825
+ }
2826
+ if (f.kind === "checkbox" || f.kind === "radio") {
2827
+ const checked = typeof f.value === "boolean" ? f.value : String(f.value) === "true" || String(f.value) === "on";
2828
+ return ` await page.getByLabel(${q}).setChecked(${checked});`;
2829
+ }
2830
+ return ` await page.getByLabel(${q}).fill(${JSON.stringify(String(f.value))});`;
2831
+ }).join("\n");
2832
+ }
2833
+ case "upload":
2834
+ return ` await page.getByLabel(${JSON.stringify(step.query)}).setInputFiles(${JSON.stringify(step.file_path)});`;
2835
+ case "dialog":
2836
+ return [
2837
+ ` page.once("dialog", async (dialog) => {`,
2838
+ step.action === "accept" ? ` await dialog.accept();` : step.action === "accept_with_text" ? ` await dialog.accept(${JSON.stringify(step.text ?? "")});` : ` await dialog.dismiss();`,
2839
+ ` });`
2840
+ ].join("\n");
2841
+ case "set_env": {
2842
+ const lines = [];
2843
+ if (step.viewport && typeof step.viewport === "object") {
2844
+ const v = step.viewport;
2845
+ lines.push(` await page.setViewportSize({ width: ${v.width}, height: ${v.height} });`);
2846
+ }
2847
+ if (step.offline !== void 0) {
2848
+ lines.push(` await page.context().setOffline(${Boolean(step.offline)});`);
2849
+ }
2850
+ if (step.geolocation) {
2851
+ lines.push(` await page.context().setGeolocation(${JSON.stringify(step.geolocation)});`);
2852
+ }
2853
+ if (step.color_scheme || step.reduced_motion) {
2854
+ const opts = {};
2855
+ if (step.color_scheme) opts.colorScheme = step.color_scheme;
2856
+ if (step.reduced_motion) opts.reducedMotion = step.reduced_motion;
2857
+ lines.push(` await page.emulateMedia(${JSON.stringify(opts)});`);
2858
+ }
2859
+ if (step.extra_headers) {
2860
+ lines.push(` await page.context().setExtraHTTPHeaders(${JSON.stringify(step.extra_headers)});`);
2861
+ }
2862
+ if (step.network_throttle || step.cpu_throttle !== void 0) {
2863
+ lines.push(` // network/cpu throttle requires CDP \u2014 see Playwright docs (chromium only)`);
2864
+ }
2865
+ return lines.length > 0 ? lines.join("\n") : ` // set_env: nothing to apply`;
2866
+ }
2867
+ case "switch_page":
2868
+ return ` const allPages = page.context().pages(); /* switch to index ${step.index} */ if (allPages[${step.index}]) await allPages[${step.index}].bringToFront();`;
2869
+ case "evaluate":
2870
+ return ` await page.evaluate(${JSON.stringify(step.script)});`;
1743
2871
  default:
1744
2872
  return ` // unsupported step kind: ${step.kind}`;
1745
2873
  }
@@ -1754,6 +2882,20 @@ function playwrightExpectLine(exp) {
1754
2882
  return ` await expect(page).toHaveURL(new RegExp(${JSON.stringify(exp.pattern)}));`;
1755
2883
  case "ref_in_state":
1756
2884
  return ` // ref_in_state ${JSON.stringify(exp.query)} \u2192 ${String(exp.state)} \u2014 translate as needed`;
2885
+ case "no_console_errors":
2886
+ return [
2887
+ ` // no_console_errors \u2014 collect via page.on('console') before the steps, then:`,
2888
+ ` // expect(consoleErrors).toEqual([]);`
2889
+ ].join("\n");
2890
+ case "no_failed_requests":
2891
+ return [
2892
+ ` // no_failed_requests \u2014 collect via page.on('requestfailed'/'response') before the steps, then:`,
2893
+ ` // expect(failedRequests).toEqual([]);`
2894
+ ].join("\n");
2895
+ case "request_made":
2896
+ return ` await page.waitForRequest(new RegExp(${JSON.stringify(exp.url_pattern)}));`;
2897
+ case "response_status":
2898
+ return ` await page.waitForResponse((r) => new RegExp(${JSON.stringify(exp.url_pattern)}).test(r.url()) && r.status() === ${Number(exp.status)});`;
1757
2899
  default:
1758
2900
  return ` // unsupported expect kind: ${exp.kind}`;
1759
2901
  }
@@ -1770,6 +2912,56 @@ function seleniumStepLine(step) {
1770
2912
  return ` driver.get(${JSON.stringify(step.url)})`;
1771
2913
  case "wait_for":
1772
2914
  return ` # wait_for: ${JSON.stringify(step.condition)} \u2014 translate to WebDriverWait`;
2915
+ case "hover":
2916
+ return [
2917
+ ` from selenium.webdriver.common.action_chains import ActionChains`,
2918
+ ` target = driver.find_element(By.XPATH, f"//*[contains(text(), \\"${escapePy(String(step.query))}\\")]")`,
2919
+ ` ActionChains(driver).move_to_element(target).perform()`
2920
+ ].join("\n");
2921
+ case "drag":
2922
+ return [
2923
+ ` from selenium.webdriver.common.action_chains import ActionChains`,
2924
+ ` src = driver.find_element(By.XPATH, f"//*[contains(text(), \\"${escapePy(String(step.from_query))}\\")]")`,
2925
+ ` dst = driver.find_element(By.XPATH, f"//*[contains(text(), \\"${escapePy(String(step.to_query))}\\")]")`,
2926
+ ` ActionChains(driver).drag_and_drop(src, dst).perform()`
2927
+ ].join("\n");
2928
+ case "fill_form": {
2929
+ const fields = Array.isArray(step.fields) ? step.fields : [];
2930
+ return fields.map((f) => {
2931
+ const q = escapePy(f.query);
2932
+ if (f.kind === "select") {
2933
+ return [
2934
+ ` from selenium.webdriver.support.ui import Select`,
2935
+ ` Select(driver.find_element(By.XPATH, f"//*[@aria-label=\\"${q}\\"]")).select_by_visible_text(${JSON.stringify(String(f.value))})`
2936
+ ].join("\n");
2937
+ }
2938
+ if (f.kind === "checkbox" || f.kind === "radio") {
2939
+ const checked = typeof f.value === "boolean" ? f.value : String(f.value) === "true";
2940
+ return ` el = driver.find_element(By.XPATH, f"//*[@aria-label=\\"${q}\\"]"); el.click() if el.is_selected() != ${checked ? "True" : "False"} else None`;
2941
+ }
2942
+ return ` driver.find_element(By.XPATH, f"//*[@aria-label=\\"${q}\\"]").send_keys(${JSON.stringify(String(f.value))})`;
2943
+ }).join("\n");
2944
+ }
2945
+ case "upload":
2946
+ return ` driver.find_element(By.XPATH, f"//*[@aria-label=\\"${escapePy(String(step.query))}\\"]").send_keys(${JSON.stringify(step.file_path)})`;
2947
+ case "dialog":
2948
+ return [
2949
+ ` alert = driver.switch_to.alert`,
2950
+ step.action === "accept" ? ` alert.accept()` : step.action === "accept_with_text" ? ` alert.send_keys(${JSON.stringify(step.text ?? "")}); alert.accept()` : ` alert.dismiss()`
2951
+ ].join("\n");
2952
+ case "set_env": {
2953
+ const lines = [];
2954
+ if (step.viewport && typeof step.viewport === "object") {
2955
+ const v = step.viewport;
2956
+ lines.push(` driver.set_window_size(${v.width}, ${v.height})`);
2957
+ }
2958
+ lines.push(` # set_env partially supported in Selenium \u2014 see selenium docs for offline/geolocation/colorScheme via CDP`);
2959
+ return lines.join("\n");
2960
+ }
2961
+ case "switch_page":
2962
+ return ` driver.switch_to.window(driver.window_handles[${step.index}])`;
2963
+ case "evaluate":
2964
+ return ` driver.execute_script(${JSON.stringify(step.script)})`;
1773
2965
  default:
1774
2966
  return ` # unsupported step kind: ${step.kind}`;
1775
2967
  }
@@ -1784,6 +2976,18 @@ function seleniumExpectLine(exp) {
1784
2976
  return ` import re; assert re.search(${JSON.stringify(exp.pattern)}, driver.current_url)`;
1785
2977
  case "ref_in_state":
1786
2978
  return ` # ref_in_state ${JSON.stringify(exp.query)} \u2192 ${String(exp.state)}`;
2979
+ case "no_console_errors":
2980
+ return [
2981
+ ` # no_console_errors \u2014 read browser logs via driver.get_log("browser")`,
2982
+ ` errors = [l for l in driver.get_log("browser") if l.get("level") == "SEVERE"]`,
2983
+ ` assert errors == [], f"console errors: {errors}"`
2984
+ ].join("\n");
2985
+ case "no_failed_requests":
2986
+ return ` # no_failed_requests \u2014 selenium has no built-in network capture. Enable selenium-wire or BiDi for this.`;
2987
+ case "request_made":
2988
+ return ` # request_made ${JSON.stringify(exp.url_pattern)} \u2014 use selenium-wire (driver.requests) or BiDi`;
2989
+ case "response_status":
2990
+ return ` # response_status ${JSON.stringify(exp.url_pattern)} == ${Number(exp.status)} \u2014 use selenium-wire (driver.requests) or BiDi`;
1787
2991
  default:
1788
2992
  return ` # unsupported expect kind: ${exp.kind}`;
1789
2993
  }
@@ -1812,6 +3016,10 @@ function indent(block, n) {
1812
3016
  return block.split("\n").map((l) => l.length > 0 ? pad + l : l).join("\n");
1813
3017
  }
1814
3018
 
3019
+ // src/tools/composite/verify_ui_flow.ts
3020
+ import { readdir } from "fs/promises";
3021
+ import { resolve as resolvePath2 } from "path";
3022
+
1815
3023
  // src/replay/minimize.ts
1816
3024
  async function ddmin(input, reproduces) {
1817
3025
  let current = [...input];
@@ -1846,7 +3054,11 @@ var verifyUiFlowTool = {
1846
3054
  inputShape: verifyUiFlowShape,
1847
3055
  build(ctx) {
1848
3056
  return safeHandler(async (args) => {
1849
- const { runId, runDir } = await ctx.store.startRun("verify");
3057
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3058
+ const { runId, runDir, skill } = await ctx.store.startRun(
3059
+ "verify",
3060
+ { skill: "verify-ui" }
3061
+ );
1850
3062
  const initial = await runFlow(ctx, args, args.steps, runDir, {
1851
3063
  captureEvidence: true,
1852
3064
  bundleName: "replay.json"
@@ -1871,17 +3083,75 @@ var verifyUiFlowTool = {
1871
3083
  attempts: min.attempts
1872
3084
  };
1873
3085
  }
3086
+ const manifestPath = await writeManifest({
3087
+ runDir,
3088
+ skill,
3089
+ phase: "verify",
3090
+ status: initial.passed ? "pass" : "fail",
3091
+ summary: buildVerifySummary(args, initial),
3092
+ startedAt,
3093
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3094
+ artifacts: flattenVerifyEvidence(initial.evidence),
3095
+ metadata: {
3096
+ mode: args.mode,
3097
+ step_count: args.steps.length,
3098
+ expect_count: args.expect.length,
3099
+ ...initial.finalUrl !== void 0 ? { final_url: initial.finalUrl } : {}
3100
+ }
3101
+ });
3102
+ if (manifestPath) result.manifest = manifestPath;
1874
3103
  return ok(result);
1875
3104
  });
1876
3105
  }
1877
3106
  };
3107
+ function buildVerifySummary(args, outcome) {
3108
+ const stepCount = args.steps.length;
3109
+ const expectCount = args.expect.length;
3110
+ if (outcome.passed) {
3111
+ return `${stepCount} step(s), ${expectCount} expect(s) passed`;
3112
+ }
3113
+ if (outcome.failedAtStep !== void 0) {
3114
+ return `failed at step ${outcome.failedAtStep}: ${outcome.failureReason ?? "unknown"}`;
3115
+ }
3116
+ return `failed: ${outcome.failureReason ?? "unknown"}`;
3117
+ }
3118
+ function flattenVerifyEvidence(ev) {
3119
+ const out = [];
3120
+ for (const s of ev.screenshots) out.push({ type: "screenshot", path: s });
3121
+ if (ev.replay_bundle) out.push({ type: "replay_bundle", path: ev.replay_bundle });
3122
+ if (ev.console) out.push({ type: "console", path: ev.console });
3123
+ if (ev.a11y_tree) out.push({ type: "a11y_tree", path: ev.a11y_tree });
3124
+ if (ev.har) out.push({ type: "har", path: ev.har });
3125
+ if (ev.trace) out.push({ type: "trace", path: ev.trace });
3126
+ if (ev.video) for (const v of ev.video) out.push({ type: "video", path: v });
3127
+ return out;
3128
+ }
3129
+ function buildCaptureOptions(captures, runDir) {
3130
+ const cap = {};
3131
+ if (captures.has("har")) {
3132
+ cap.har = { path: resolvePath2(runDir, "network.har") };
3133
+ }
3134
+ if (captures.has("video")) {
3135
+ cap.video = { dir: resolvePath2(runDir, "videos") };
3136
+ }
3137
+ if (captures.has("trace")) {
3138
+ cap.trace = { artifactDir: runDir };
3139
+ }
3140
+ return Object.keys(cap).length > 0 ? cap : void 0;
3141
+ }
1878
3142
  async function runFlow(ctx, args, steps, runDir, opts) {
1879
3143
  const evidence = { screenshots: [] };
3144
+ const captures = new Set(args.capture ?? ["screenshot"]);
1880
3145
  let passed = false;
1881
3146
  let failedAtStep;
1882
3147
  let failureReason;
1883
3148
  let finalSnapshot;
1884
- const session = await ctx.registry.open(args.open);
3149
+ const openOpts = { ...args.open };
3150
+ const captureCfg = buildCaptureOptions(captures, runDir);
3151
+ if (captureCfg) {
3152
+ openOpts.capture = captureCfg;
3153
+ }
3154
+ const session = await ctx.registry.open(openOpts);
1885
3155
  const engine = ctx.registry.engineFor(session.id);
1886
3156
  const sessionHandle = { id: session.id, platform: session.platform };
1887
3157
  try {
@@ -1900,7 +3170,7 @@ async function runFlow(ctx, args, steps, runDir, opts) {
1900
3170
  const failures = [];
1901
3171
  for (let i = 0; i < args.expect.length; i++) {
1902
3172
  const expectation = args.expect[i];
1903
- if (!evaluateExpect(expectation, finalSnapshot)) {
3173
+ if (!evaluateExpect(expectation, finalSnapshot, engine, session.id)) {
1904
3174
  failures.push(`expect[${i}] ${describeExpect(expectation)}`);
1905
3175
  }
1906
3176
  }
@@ -1914,8 +3184,7 @@ async function runFlow(ctx, args, steps, runDir, opts) {
1914
3184
  passed = false;
1915
3185
  } finally {
1916
3186
  if (opts.captureEvidence) {
1917
- const wantScreenshot = !args.capture || args.capture.includes("screenshot");
1918
- if (wantScreenshot) {
3187
+ if (captures.has("screenshot")) {
1919
3188
  try {
1920
3189
  const buf = await engine.screenshot(sessionHandle, true);
1921
3190
  const p = await ctx.store.writeScreenshot(runDir, buf, "final");
@@ -1924,6 +3193,37 @@ async function runFlow(ctx, args, steps, runDir, opts) {
1924
3193
  failureReason ??= `screenshot capture failed: ${describeError(err)}`;
1925
3194
  }
1926
3195
  }
3196
+ if (captures.has("console") && engine instanceof PlaywrightEngine) {
3197
+ try {
3198
+ const messages = engine.peekBuffers(session.id).console;
3199
+ evidence.console = await ctx.store.writeReport(
3200
+ runDir,
3201
+ "console.json",
3202
+ JSON.stringify(
3203
+ {
3204
+ count: messages.length,
3205
+ by_level: countByLevel(messages),
3206
+ messages
3207
+ },
3208
+ null,
3209
+ 2
3210
+ )
3211
+ );
3212
+ } catch (err) {
3213
+ failureReason ??= `console capture failed: ${describeError(err)}`;
3214
+ }
3215
+ }
3216
+ if (captures.has("a11y_tree") && finalSnapshot) {
3217
+ try {
3218
+ evidence.a11y_tree = await ctx.store.writeReport(
3219
+ runDir,
3220
+ "a11y_tree.json",
3221
+ JSON.stringify(finalSnapshot, null, 2)
3222
+ );
3223
+ } catch (err) {
3224
+ failureReason ??= `a11y_tree capture failed: ${describeError(err)}`;
3225
+ }
3226
+ }
1927
3227
  try {
1928
3228
  evidence.replay_bundle = await ctx.store.writeReplayBundle(
1929
3229
  runDir,
@@ -1944,12 +3244,32 @@ async function runFlow(ctx, args, steps, runDir, opts) {
1944
3244
  await ctx.registry.close(sessionHandle).catch(() => void 0);
1945
3245
  }
1946
3246
  }
3247
+ if (opts.captureEvidence) {
3248
+ if (captureCfg?.har) evidence.har = captureCfg.har.path;
3249
+ if (captureCfg?.trace) {
3250
+ evidence.trace = resolvePath2(captureCfg.trace.artifactDir, "trace.zip");
3251
+ }
3252
+ if (captureCfg?.video) {
3253
+ try {
3254
+ const files = await readdir(captureCfg.video.dir).catch(() => []);
3255
+ evidence.video = files.filter((f) => f.endsWith(".webm")).map((f) => resolvePath2(captureCfg.video.dir, f));
3256
+ } catch {
3257
+ }
3258
+ }
3259
+ }
1947
3260
  const out = { passed, evidence };
1948
3261
  if (failedAtStep !== void 0) out.failedAtStep = failedAtStep;
1949
3262
  if (failureReason !== void 0) out.failureReason = failureReason;
1950
3263
  if (finalSnapshot) out.finalUrl = finalSnapshot.url_or_screen;
1951
3264
  return out;
1952
3265
  }
3266
+ function countByLevel(messages) {
3267
+ const counts = {};
3268
+ for (const m of messages) {
3269
+ counts[m.level] = (counts[m.level] ?? 0) + 1;
3270
+ }
3271
+ return counts;
3272
+ }
1953
3273
  async function minimize(ctx, args, initialSteps, runDir) {
1954
3274
  const tagged = initialSteps.map((step, origIndex) => ({ step, origIndex }));
1955
3275
  let attempts = 0;
@@ -2013,9 +3333,84 @@ async function runStep(engine, session, step, snap) {
2013
3333
  case "navigate":
2014
3334
  await engine.navigate(session, step.url);
2015
3335
  return;
3336
+ case "hover": {
3337
+ const ref = findRefByQuery(snap.tree, step.query);
3338
+ if (!ref) throw missingQuery(step.query);
3339
+ await engine.hover(session, ref);
3340
+ return;
3341
+ }
3342
+ case "drag": {
3343
+ const fromRef = findRefByQuery(snap.tree, step.from_query);
3344
+ if (!fromRef) throw missingQuery(step.from_query);
3345
+ const toRef = findRefByQuery(snap.tree, step.to_query);
3346
+ if (!toRef) throw missingQuery(step.to_query);
3347
+ await engine.drag(session, fromRef, toRef);
3348
+ return;
3349
+ }
3350
+ case "fill_form": {
3351
+ const resolved = step.fields.map((f) => {
3352
+ const ref = findRefByQuery(snap.tree, f.query);
3353
+ if (!ref) throw missingQuery(f.query);
3354
+ return f.kind !== void 0 ? { ref, value: f.value, kind: f.kind } : { ref, value: f.value };
3355
+ });
3356
+ await engine.fillForm(session, resolved);
3357
+ return;
3358
+ }
3359
+ case "upload": {
3360
+ const ref = findRefByQuery(snap.tree, step.query);
3361
+ if (!ref) throw missingQuery(step.query);
3362
+ await engine.uploadFile(session, ref, step.file_path);
3363
+ return;
3364
+ }
3365
+ case "dialog": {
3366
+ requirePlaywright(engine, "dialog");
3367
+ void engine.handleDialog(session.id, {
3368
+ action: step.action,
3369
+ ...step.text !== void 0 ? { text: step.text } : {}
3370
+ }).catch(() => void 0);
3371
+ return;
3372
+ }
3373
+ case "set_env": {
3374
+ requirePlaywright(engine, "set_env");
3375
+ await engine.setEnv(session.id, {
3376
+ ...step.viewport !== void 0 ? { viewport: step.viewport } : {},
3377
+ ...step.offline !== void 0 ? { offline: step.offline } : {},
3378
+ ...step.geolocation !== void 0 ? { geolocation: step.geolocation } : {},
3379
+ ...step.color_scheme !== void 0 ? { colorScheme: step.color_scheme } : {},
3380
+ ...step.reduced_motion !== void 0 ? { reducedMotion: step.reduced_motion } : {},
3381
+ ...step.extra_headers !== void 0 ? { extraHeaders: step.extra_headers } : {},
3382
+ ...step.network_throttle !== void 0 ? { networkThrottle: step.network_throttle } : {},
3383
+ ...step.cpu_throttle !== void 0 ? { cpuThrottle: step.cpu_throttle } : {}
3384
+ });
3385
+ return;
3386
+ }
3387
+ case "switch_page": {
3388
+ requirePlaywright(engine, "switch_page");
3389
+ await engine.switchPage(session.id, step.index);
3390
+ return;
3391
+ }
3392
+ case "evaluate": {
3393
+ requirePlaywright(engine, "evaluate");
3394
+ if (process.env.ROLEPOD_ALLOW_EVAL !== "1") {
3395
+ throw new RolepodMcpError(
3396
+ "engine_error",
3397
+ "verify_ui_flow step kind 'evaluate' is disabled. Restart the rolepod-uiproof MCP server with ROLEPOD_ALLOW_EVAL=1 to enable."
3398
+ );
3399
+ }
3400
+ await engine.evaluate(session.id, step.script);
3401
+ return;
3402
+ }
2016
3403
  }
2017
3404
  }
2018
- function evaluateExpect(exp, snap) {
3405
+ function requirePlaywright(engine, stepKind) {
3406
+ if (!(engine instanceof PlaywrightEngine)) {
3407
+ throw new RolepodMcpError(
3408
+ "unsupported_engine",
3409
+ `verify_ui_flow step kind "${stepKind}" is web-only and requires PlaywrightEngine.`
3410
+ );
3411
+ }
3412
+ }
3413
+ function evaluateExpect(exp, snap, engine, sessionId) {
2019
3414
  switch (exp.kind) {
2020
3415
  case "text_visible":
2021
3416
  return treeHasText(snap.tree, exp.text);
@@ -2035,6 +3430,49 @@ function evaluateExpect(exp, snap) {
2035
3430
  return node.state?.focused === true;
2036
3431
  }
2037
3432
  }
3433
+ case "no_console_errors": {
3434
+ if (!(engine instanceof PlaywrightEngine)) return true;
3435
+ const msgs = engine.peekBuffers(sessionId).console.filter(
3436
+ (m) => m.level === "error"
3437
+ );
3438
+ const excludes = exp.exclude_patterns ?? [];
3439
+ const remaining = msgs.filter(
3440
+ (m) => !excludes.some((p) => m.text.includes(p))
3441
+ );
3442
+ return remaining.length === 0;
3443
+ }
3444
+ case "no_failed_requests": {
3445
+ if (!(engine instanceof PlaywrightEngine)) return true;
3446
+ const reqs = engine.peekBuffers(sessionId).network.filter((r) => {
3447
+ if (r.failure) return true;
3448
+ if (r.status === void 0) return false;
3449
+ if (exp.allow_4xx) return r.status >= 500;
3450
+ return r.status >= 400;
3451
+ });
3452
+ const excludes = exp.exclude_patterns ?? [];
3453
+ const remaining = reqs.filter(
3454
+ (r) => !excludes.some((p) => r.url.includes(p))
3455
+ );
3456
+ return remaining.length === 0;
3457
+ }
3458
+ case "request_made": {
3459
+ if (!(engine instanceof PlaywrightEngine)) return false;
3460
+ const re = new RegExp(exp.url_pattern);
3461
+ const wantMethod = exp.method?.toUpperCase();
3462
+ const matches = engine.peekBuffers(sessionId).network.filter((r) => {
3463
+ if (!re.test(r.url)) return false;
3464
+ if (wantMethod && r.method.toUpperCase() !== wantMethod) return false;
3465
+ return true;
3466
+ });
3467
+ const min = exp.min_count ?? 1;
3468
+ return matches.length >= min;
3469
+ }
3470
+ case "response_status": {
3471
+ if (!(engine instanceof PlaywrightEngine)) return false;
3472
+ const re = new RegExp(exp.url_pattern);
3473
+ const match = engine.peekBuffers(sessionId).network.find((r) => re.test(r.url) && r.status === exp.status);
3474
+ return match !== void 0;
3475
+ }
2038
3476
  }
2039
3477
  }
2040
3478
  function describeExpect(exp) {
@@ -2047,6 +3485,14 @@ function describeExpect(exp) {
2047
3485
  return `url_matches /${exp.pattern}/`;
2048
3486
  case "ref_in_state":
2049
3487
  return `ref_in_state "${exp.query}" \u2192 ${exp.state}`;
3488
+ case "no_console_errors":
3489
+ return "no_console_errors";
3490
+ case "no_failed_requests":
3491
+ return "no_failed_requests";
3492
+ case "request_made":
3493
+ return `request_made ${exp.method ?? ""} ${exp.url_pattern}`.trim();
3494
+ case "response_status":
3495
+ return `response_status ${exp.url_pattern} = ${exp.status}`;
2050
3496
  }
2051
3497
  }
2052
3498
  function missingQuery(query) {
@@ -2094,7 +3540,7 @@ function treeHasText(tree, text) {
2094
3540
  // src/tools/composite/visual_diff.ts
2095
3541
  import { existsSync } from "fs";
2096
3542
  import { readFile as readFile2 } from "fs/promises";
2097
- import { resolve as resolve3 } from "path";
3543
+ import { resolve as resolve4 } from "path";
2098
3544
  import pixelmatch from "pixelmatch";
2099
3545
  import { PNG } from "pngjs";
2100
3546
  var visualDiffTool = {
@@ -2103,7 +3549,11 @@ var visualDiffTool = {
2103
3549
  inputShape: visualDiffShape,
2104
3550
  build(ctx) {
2105
3551
  return safeHandler(async (args) => {
2106
- const { runId, runDir } = await ctx.store.startRun("vdiff");
3552
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3553
+ const { runId, runDir, skill } = await ctx.store.startRun(
3554
+ "vdiff",
3555
+ { skill: "visual-diff" }
3556
+ );
2107
3557
  const session = await ctx.registry.open({
2108
3558
  ...args.open,
2109
3559
  ...args.viewport ? { viewport: args.viewport } : {}
@@ -2122,7 +3572,7 @@ var visualDiffTool = {
2122
3572
  );
2123
3573
  const currentPath = await ctx.store.writeScreenshot(runDir, buf, "current");
2124
3574
  await ctx.store.ensureDir(ctx.store.baselineDir);
2125
- const baselinePath = resolve3(
3575
+ const baselinePath = resolve4(
2126
3576
  ctx.store.baselineDir,
2127
3577
  `${args.baseline_id}.png`
2128
3578
  );
@@ -2132,6 +3582,20 @@ var visualDiffTool = {
2132
3582
  `${args.baseline_id}.png`,
2133
3583
  buf
2134
3584
  );
3585
+ const manifestPath2 = await writeManifest({
3586
+ runDir,
3587
+ skill,
3588
+ phase: "verify",
3589
+ status: "pass",
3590
+ summary: `baseline "${args.baseline_id}" seeded from current capture`,
3591
+ startedAt,
3592
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3593
+ artifacts: [
3594
+ { type: "baseline", path: baselinePath },
3595
+ { type: "screenshot", path: currentPath }
3596
+ ],
3597
+ metadata: { baseline_id: args.baseline_id, seeded: true }
3598
+ });
2135
3599
  return ok({
2136
3600
  run_id: runId,
2137
3601
  baseline_id: args.baseline_id,
@@ -2139,6 +3603,7 @@ var visualDiffTool = {
2139
3603
  passed: true,
2140
3604
  baseline_path: baselinePath,
2141
3605
  current_path: currentPath,
3606
+ ...manifestPath2 ? { manifest: manifestPath2 } : {},
2142
3607
  note: "Baseline did not exist \u2014 current capture saved as the new baseline."
2143
3608
  });
2144
3609
  }
@@ -2169,21 +3634,45 @@ var visualDiffTool = {
2169
3634
  );
2170
3635
  const total = baseline.width * baseline.height;
2171
3636
  const diffPct = diffPixels / total;
3637
+ const passed = diffPct <= args.threshold_pct;
2172
3638
  const diffImagePath = await ctx.store.writeBytes(
2173
3639
  runDir,
2174
3640
  "diff.png",
2175
3641
  PNG.sync.write(diff)
2176
3642
  );
3643
+ const artifacts = [
3644
+ { type: "baseline", path: baselinePath },
3645
+ { type: "screenshot", path: currentPath },
3646
+ { type: "diff", path: diffImagePath }
3647
+ ];
3648
+ const manifestPath = await writeManifest({
3649
+ runDir,
3650
+ skill,
3651
+ phase: "verify",
3652
+ status: passed ? "pass" : "fail",
3653
+ summary: `diff ${(diffPct * 100).toFixed(3)}% vs baseline "${args.baseline_id}" (threshold ${(args.threshold_pct * 100).toFixed(3)}%)`,
3654
+ startedAt,
3655
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3656
+ artifacts,
3657
+ metadata: {
3658
+ baseline_id: args.baseline_id,
3659
+ diff_pct: Number(diffPct.toFixed(6)),
3660
+ diff_pixels: diffPixels,
3661
+ total_pixels: total,
3662
+ threshold_pct: args.threshold_pct
3663
+ }
3664
+ });
2177
3665
  return ok({
2178
3666
  run_id: runId,
2179
3667
  baseline_id: args.baseline_id,
2180
3668
  diff_pct: Number(diffPct.toFixed(6)),
2181
3669
  diff_pixels: diffPixels,
2182
3670
  total_pixels: total,
2183
- passed: diffPct <= args.threshold_pct,
3671
+ passed,
2184
3672
  baseline_path: baselinePath,
2185
3673
  current_path: currentPath,
2186
- diff_image_path: diffImagePath
3674
+ diff_image_path: diffImagePath,
3675
+ ...manifestPath ? { manifest: manifestPath } : {}
2187
3676
  });
2188
3677
  } finally {
2189
3678
  if (args.close_on_finish) {
@@ -2340,13 +3829,131 @@ var toolMetadata = {
2340
3829
  readOnlyHint: true,
2341
3830
  openWorldHint: true
2342
3831
  }
3832
+ },
3833
+ // ---------- v0.5 atomic additions ----------
3834
+ [ToolNames.browserHover]: {
3835
+ title: "Hover Element",
3836
+ annotations: {
3837
+ title: "Hover Element",
3838
+ readOnlyHint: false,
3839
+ destructiveHint: false,
3840
+ idempotentHint: true,
3841
+ openWorldHint: true
3842
+ }
3843
+ },
3844
+ [ToolNames.browserDrag]: {
3845
+ title: "Drag Element",
3846
+ annotations: {
3847
+ title: "Drag Element",
3848
+ readOnlyHint: false,
3849
+ destructiveHint: true,
3850
+ idempotentHint: false,
3851
+ openWorldHint: true
3852
+ }
3853
+ },
3854
+ [ToolNames.browserFillForm]: {
3855
+ title: "Fill Form (Batch)",
3856
+ annotations: {
3857
+ title: "Fill Form (Batch)",
3858
+ readOnlyHint: false,
3859
+ destructiveHint: true,
3860
+ idempotentHint: false,
3861
+ openWorldHint: true
3862
+ }
3863
+ },
3864
+ [ToolNames.browserUploadFile]: {
3865
+ title: "Upload File",
3866
+ annotations: {
3867
+ title: "Upload File",
3868
+ readOnlyHint: false,
3869
+ destructiveHint: true,
3870
+ idempotentHint: false,
3871
+ openWorldHint: true
3872
+ }
3873
+ },
3874
+ [ToolNames.browserHandleDialog]: {
3875
+ title: "Pre-arm Dialog Handler",
3876
+ annotations: {
3877
+ title: "Pre-arm Dialog Handler",
3878
+ readOnlyHint: false,
3879
+ destructiveHint: true,
3880
+ idempotentHint: false,
3881
+ openWorldHint: false
3882
+ }
3883
+ },
3884
+ [ToolNames.browserConsole]: {
3885
+ title: "Inspect Console Logs",
3886
+ annotations: {
3887
+ title: "Inspect Console Logs",
3888
+ readOnlyHint: true,
3889
+ openWorldHint: false
3890
+ }
3891
+ },
3892
+ [ToolNames.browserNetwork]: {
3893
+ title: "Inspect Network Requests",
3894
+ annotations: {
3895
+ title: "Inspect Network Requests",
3896
+ readOnlyHint: true,
3897
+ openWorldHint: false
3898
+ }
3899
+ },
3900
+ [ToolNames.browserSetEnv]: {
3901
+ title: "Set Browser Environment",
3902
+ annotations: {
3903
+ title: "Set Browser Environment",
3904
+ readOnlyHint: false,
3905
+ destructiveHint: true,
3906
+ idempotentHint: true,
3907
+ openWorldHint: false
3908
+ }
3909
+ },
3910
+ [ToolNames.browserEvaluate]: {
3911
+ title: "Evaluate JavaScript (gated; arbitrary code execution)",
3912
+ annotations: {
3913
+ title: "Evaluate JavaScript",
3914
+ // Arbitrary code execution in the page context. Gated by
3915
+ // ROLEPOD_ALLOW_EVAL=1 server-side. Always treat as destructive.
3916
+ readOnlyHint: false,
3917
+ destructiveHint: true,
3918
+ idempotentHint: false,
3919
+ openWorldHint: true
3920
+ }
3921
+ },
3922
+ [ToolNames.browserPages]: {
3923
+ title: "List Open Pages",
3924
+ annotations: {
3925
+ title: "List Open Pages",
3926
+ readOnlyHint: true,
3927
+ openWorldHint: false
3928
+ }
3929
+ },
3930
+ [ToolNames.browserSwitchPage]: {
3931
+ title: "Switch Active Page",
3932
+ annotations: {
3933
+ title: "Switch Active Page",
3934
+ readOnlyHint: false,
3935
+ destructiveHint: false,
3936
+ idempotentHint: true,
3937
+ openWorldHint: false
3938
+ }
2343
3939
  }
2344
3940
  };
2345
3941
 
2346
3942
  // src/server.ts
2347
3943
  var SERVER_NAME = "rolepod-uiproof";
2348
- var SERVER_VERSION = "0.4.1";
3944
+ var SERVER_VERSION = "0.6.0";
3945
+ var SUPPORTED_PROTOCOL = "v1";
3946
+ function checkProtocolCompat() {
3947
+ const requested = process.env.ROLEPOD_PROTOCOL;
3948
+ if (!requested) return;
3949
+ if (requested !== SUPPORTED_PROTOCOL) {
3950
+ console.warn(
3951
+ `rolepod protocol mismatch: expected ${SUPPORTED_PROTOCOL}, got ${requested}. Manifest will still be written in ${SUPPORTED_PROTOCOL} shape \u2014 parent may not parse it correctly.`
3952
+ );
3953
+ }
3954
+ }
2349
3955
  function buildServer(opts = {}) {
3956
+ checkProtocolCompat();
2350
3957
  const webEngine = createWebEngine();
2351
3958
  const registry = new SessionRegistry({ idleTimeoutMs: opts.idleTimeoutMs });
2352
3959
  registry.register("web", webEngine);
@@ -2362,7 +3969,7 @@ function buildServer(opts = {}) {
2362
3969
  version: SERVER_VERSION
2363
3970
  });
2364
3971
  const tools = [
2365
- // atomic
3972
+ // atomic (v0.1-v0.4)
2366
3973
  browserOpenTool,
2367
3974
  browserCloseTool,
2368
3975
  browserSnapshotTool,
@@ -2373,6 +3980,18 @@ function buildServer(opts = {}) {
2373
3980
  browserWaitForTool,
2374
3981
  browserScreenshotTool,
2375
3982
  browserNavigateTool,
3983
+ // atomic (v0.5)
3984
+ browserHoverTool,
3985
+ browserDragTool,
3986
+ browserFillFormTool,
3987
+ browserUploadFileTool,
3988
+ browserHandleDialogTool,
3989
+ browserConsoleTool,
3990
+ browserNetworkTool,
3991
+ browserSetEnvTool,
3992
+ browserEvaluateTool,
3993
+ browserPagesTool,
3994
+ browserSwitchPageTool,
2376
3995
  // composite
2377
3996
  verifyUiFlowTool,
2378
3997
  auditA11yTool,
@@ -2395,6 +4014,8 @@ function buildServer(opts = {}) {
2395
4014
  }
2396
4015
  log.info("rolepod-uiproof server built", {
2397
4016
  version: SERVER_VERSION,
4017
+ protocol: SUPPORTED_PROTOCOL,
4018
+ mode: store.mode,
2398
4019
  tools: tools.map((t) => t.name)
2399
4020
  });
2400
4021
  return {
@@ -2426,20 +4047,42 @@ export {
2426
4047
  browserClickShape,
2427
4048
  browserCloseSchema,
2428
4049
  browserCloseShape,
4050
+ browserConsoleSchema,
4051
+ browserConsoleShape,
4052
+ browserDragSchema,
4053
+ browserDragShape,
4054
+ browserEvaluateSchema,
4055
+ browserEvaluateShape,
4056
+ browserFillFormSchema,
4057
+ browserFillFormShape,
4058
+ browserHandleDialogSchema,
4059
+ browserHandleDialogShape,
4060
+ browserHoverSchema,
4061
+ browserHoverShape,
2429
4062
  browserKeySchema,
2430
4063
  browserKeyShape,
2431
4064
  browserNavigateSchema,
2432
4065
  browserNavigateShape,
4066
+ browserNetworkSchema,
4067
+ browserNetworkShape,
2433
4068
  browserOpenSchema,
2434
4069
  browserOpenShape,
4070
+ browserPagesSchema,
4071
+ browserPagesShape,
2435
4072
  browserScreenshotSchema,
2436
4073
  browserScreenshotShape,
2437
4074
  browserScrollSchema,
2438
4075
  browserScrollShape,
4076
+ browserSetEnvSchema,
4077
+ browserSetEnvShape,
2439
4078
  browserSnapshotSchema,
2440
4079
  browserSnapshotShape,
4080
+ browserSwitchPageSchema,
4081
+ browserSwitchPageShape,
2441
4082
  browserTypeSchema,
2442
4083
  browserTypeShape,
4084
+ browserUploadFileSchema,
4085
+ browserUploadFileShape,
2443
4086
  browserWaitForSchema,
2444
4087
  browserWaitForShape,
2445
4088
  buildServer,