@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.
@@ -175,7 +175,7 @@ function runInstallMobile() {
175
175
 
176
176
  // src/cli/replay.ts
177
177
  import { readFile } from "fs/promises";
178
- import { resolve as resolve3 } from "path";
178
+ import { resolve as resolve4 } from "path";
179
179
 
180
180
  // src/artifact/ArtifactStore.ts
181
181
  import { randomUUID } from "crypto";
@@ -204,16 +204,49 @@ var log = {
204
204
  // src/artifact/ArtifactStore.ts
205
205
  var ArtifactStore = class {
206
206
  rootDir;
207
+ mode;
208
+ baselineRoot;
207
209
  constructor(opts = {}) {
208
- this.rootDir = opts.rootDir ?? resolve2(process.cwd(), ".rolepod-uiproof", "artifacts");
210
+ const detectedParent = process.env.ROLEPOD_PARENT === "1";
211
+ this.mode = opts.mode ?? (detectedParent ? "with-parent" : "standalone");
212
+ if (opts.rootDir !== void 0) {
213
+ this.rootDir = opts.rootDir;
214
+ } else if (this.mode === "with-parent") {
215
+ this.rootDir = resolve2(process.cwd(), ".rolepod", "evidence");
216
+ } else {
217
+ this.rootDir = resolve2(process.cwd(), ".rolepod-uiproof", "artifacts");
218
+ }
219
+ this.baselineRoot = resolve2(process.cwd(), ".rolepod-uiproof", "baselines");
209
220
  }
210
- /** Allocate a fresh run id and ensure its directory exists. */
211
- async startRun(prefix = "run") {
212
- const runId = `${prefix}_${this.timestampSlug()}_${randomUUID().slice(0, 8)}`;
221
+ /**
222
+ * Allocate a fresh run dir and ensure it exists.
223
+ *
224
+ * - standalone: `./.rolepod-uiproof/artifacts/{prefix}_{ts}_{uuid}/`
225
+ * - with-parent: `./.rolepod/evidence/{ts}-rolepod-uiproof-{skill}/`
226
+ *
227
+ * `prefix` is preserved for back-compat with v0.5 callers; new callers
228
+ * should also pass `opts.skill` so the with-parent path can be derived
229
+ * unambiguously and the manifest can be emitted with the canonical
230
+ * skill name.
231
+ */
232
+ async startRun(prefix = "run", opts = {}) {
233
+ const ts = this.timestampSlug();
234
+ const skill = opts.skill ?? prefix;
235
+ let runId;
236
+ if (this.mode === "with-parent") {
237
+ runId = `${ts}-rolepod-uiproof-${skill}`;
238
+ } else {
239
+ runId = `${prefix}_${ts}_${randomUUID().slice(0, 8)}`;
240
+ }
213
241
  const runDir = resolve2(this.rootDir, runId);
214
242
  await mkdir(runDir, { recursive: true });
215
- log.debug("artifact run started", { run_id: runId, dir: runDir });
216
- return { runId, runDir };
243
+ log.debug("artifact run started", {
244
+ run_id: runId,
245
+ dir: runDir,
246
+ mode: this.mode,
247
+ skill
248
+ });
249
+ return { runId, runDir, skill, mode: this.mode };
217
250
  }
218
251
  async writeScreenshot(runDir, buf, name) {
219
252
  const path = resolve2(runDir, `${name}.png`);
@@ -241,7 +274,7 @@ var ArtifactStore = class {
241
274
  }
242
275
  /** Root for stored visual baselines: `./.rolepod-uiproof/baselines/`. */
243
276
  get baselineDir() {
244
- return resolve2(this.rootDir, "..", "baselines");
277
+ return this.baselineRoot;
245
278
  }
246
279
  timestampSlug() {
247
280
  const d = /* @__PURE__ */ new Date();
@@ -582,6 +615,34 @@ var AppiumEngine = class {
582
615
  throw new UnsupportedPlatformError(_session.platform);
583
616
  }
584
617
  // -------------------------------------------------------------------------
618
+ // v0.5 cross-platform additions — mobile stubs.
619
+ // These ship as `not_implemented_in_v05` until the mobile gesture work lands.
620
+ // -------------------------------------------------------------------------
621
+ async hover(_session, _ref) {
622
+ throw new RolepodMcpError(
623
+ "engine_error",
624
+ "hover is not yet implemented for mobile (Appium). Use long-press via custom gesture if needed."
625
+ );
626
+ }
627
+ async drag(_session, _fromRef, _toRef) {
628
+ throw new RolepodMcpError(
629
+ "engine_error",
630
+ "drag is not yet implemented for mobile (Appium). Use the W3C Actions API directly if needed."
631
+ );
632
+ }
633
+ async fillForm(session, fields) {
634
+ for (const f of fields) {
635
+ const v = typeof f.value === "boolean" ? String(f.value) : f.value;
636
+ await this.type(session, f.ref, v);
637
+ }
638
+ }
639
+ async uploadFile(_session, _ref, _filePath) {
640
+ throw new RolepodMcpError(
641
+ "engine_error",
642
+ "upload_file is not supported on mobile (Appium)."
643
+ );
644
+ }
645
+ // -------------------------------------------------------------------------
585
646
  // Internals
586
647
  // -------------------------------------------------------------------------
587
648
  async loadWdio() {
@@ -696,6 +757,7 @@ function treeIncludesText(node, text) {
696
757
 
697
758
  // src/engine/PlaywrightEngine.ts
698
759
  import { randomUUID as randomUUID3 } from "crypto";
760
+ import { resolve as resolvePath, isAbsolute } from "path";
699
761
  import {
700
762
  chromium,
701
763
  firefox,
@@ -805,6 +867,83 @@ function parseAriaSnapshot(snapshotYaml) {
805
867
  }
806
868
 
807
869
  // src/engine/PlaywrightEngine.ts
870
+ var CONSOLE_BUFFER_CAP = 1e3;
871
+ var NETWORK_BUFFER_CAP = 1e3;
872
+ var NETWORK_PRESETS = {
873
+ offline: { offline: true, downloadThroughput: 0, uploadThroughput: 0, latency: 0 },
874
+ "slow-3g": {
875
+ offline: false,
876
+ // 500 Kbps down / 500 Kbps up / 400ms RTT
877
+ downloadThroughput: 500 * 1024 / 8,
878
+ uploadThroughput: 500 * 1024 / 8,
879
+ latency: 400
880
+ },
881
+ "fast-3g": {
882
+ offline: false,
883
+ downloadThroughput: 1.5 * 1024 * 1024 / 8,
884
+ uploadThroughput: 750 * 1024 / 8,
885
+ latency: 150
886
+ },
887
+ "slow-4g": {
888
+ offline: false,
889
+ downloadThroughput: 4 * 1024 * 1024 / 8,
890
+ uploadThroughput: 3 * 1024 * 1024 / 8,
891
+ latency: 100
892
+ },
893
+ "fast-4g": {
894
+ offline: false,
895
+ downloadThroughput: 9 * 1024 * 1024 / 8,
896
+ uploadThroughput: 4.5 * 1024 * 1024 / 8,
897
+ latency: 60
898
+ },
899
+ "no-throttling": {
900
+ offline: false,
901
+ downloadThroughput: -1,
902
+ uploadThroughput: -1,
903
+ latency: 0
904
+ }
905
+ };
906
+ function pushRing(buf, entry, cap) {
907
+ buf.push(entry);
908
+ if (buf.length > cap) buf.splice(0, buf.length - cap);
909
+ }
910
+ function mapConsoleLevel(t) {
911
+ switch (t) {
912
+ case "error":
913
+ return "error";
914
+ case "warning":
915
+ return "warning";
916
+ case "info":
917
+ return "info";
918
+ case "debug":
919
+ return "debug";
920
+ case "trace":
921
+ return "trace";
922
+ default:
923
+ return "log";
924
+ }
925
+ }
926
+ function formatConsoleLocation(msg) {
927
+ try {
928
+ const loc = msg.location();
929
+ if (!loc?.url) return void 0;
930
+ return `${loc.url}:${loc.lineNumber}:${loc.columnNumber}`;
931
+ } catch {
932
+ return void 0;
933
+ }
934
+ }
935
+ function findNetworkEntry(buf, req) {
936
+ const url = req.url();
937
+ const method = req.method();
938
+ for (let i = buf.length - 1; i >= 0; i--) {
939
+ const e = buf[i];
940
+ if (!e) continue;
941
+ if (e.url === url && e.method === method && e.status === void 0 && !e.failure) {
942
+ return e;
943
+ }
944
+ }
945
+ return void 0;
946
+ }
808
947
  var PlaywrightEngine = class {
809
948
  id = "playwright";
810
949
  sessions = /* @__PURE__ */ new Map();
@@ -820,35 +959,80 @@ var PlaywrightEngine = class {
820
959
  if (opts.viewport) contextOptions.viewport = opts.viewport;
821
960
  if (opts.user_agent) contextOptions.userAgent = opts.user_agent;
822
961
  if (opts.locale) contextOptions.locale = opts.locale;
962
+ if (opts.capture?.har) {
963
+ contextOptions.recordHar = { path: opts.capture.har.path };
964
+ }
965
+ if (opts.capture?.video) {
966
+ contextOptions.recordVideo = {
967
+ dir: opts.capture.video.dir,
968
+ size: opts.capture.video.sizeWidth && opts.capture.video.sizeHeight ? {
969
+ width: opts.capture.video.sizeWidth,
970
+ height: opts.capture.video.sizeHeight
971
+ } : void 0
972
+ };
973
+ }
823
974
  const context = await browser.newContext(contextOptions);
824
- const page = await context.newPage();
825
- if (opts.url) {
826
- await page.goto(opts.url, { waitUntil: "domcontentloaded" });
975
+ if (opts.capture?.trace) {
976
+ await context.tracing.start({
977
+ screenshots: true,
978
+ snapshots: true,
979
+ sources: false
980
+ });
827
981
  }
982
+ const page = await context.newPage();
828
983
  const sessionId = randomUUID3();
829
- const session = {
830
- id: sessionId,
831
- platform: "web",
832
- browser,
833
- context,
834
- page
835
- };
836
- this.sessions.set(sessionId, {
837
- session,
984
+ const internals = {
985
+ session: {
986
+ id: sessionId,
987
+ platform: "web",
988
+ browser,
989
+ context,
990
+ mainPage: page
991
+ },
838
992
  refIndex: /* @__PURE__ */ new Map(),
839
993
  snapshotGeneration: 0,
840
994
  refGeneration: -1,
841
- lastSnapshotAt: null
995
+ lastSnapshotAt: null,
996
+ pages: [page],
997
+ activePageIndex: 0,
998
+ consoleBuffer: [],
999
+ networkBuffer: [],
1000
+ networkInflight: /* @__PURE__ */ new Map(),
1001
+ networkNextId: 1,
1002
+ dialogArming: null,
1003
+ captureOpts: opts.capture,
1004
+ traceStarted: !!opts.capture?.trace
1005
+ };
1006
+ this.attachPageListeners(internals, page);
1007
+ context.on("page", (newPage) => {
1008
+ internals.pages.push(newPage);
1009
+ this.attachPageListeners(internals, newPage);
842
1010
  });
1011
+ if (opts.url) {
1012
+ await page.goto(opts.url, { waitUntil: "domcontentloaded" });
1013
+ }
1014
+ this.sessions.set(sessionId, internals);
843
1015
  log.info("session opened", {
844
1016
  session_id: sessionId,
845
1017
  browser: browserName,
846
- url: opts.url ?? null
1018
+ url: opts.url ?? null,
1019
+ capture: opts.capture ? Object.keys(opts.capture).filter(
1020
+ (k) => opts.capture[k]
1021
+ ) : []
847
1022
  });
848
1023
  return { id: sessionId, platform: "web" };
849
1024
  }
850
1025
  async close(session) {
851
1026
  const s = this.requireSession(session.id);
1027
+ if (s.traceStarted && s.captureOpts?.trace) {
1028
+ const tracePath = resolvePath(s.captureOpts.trace.artifactDir, "trace.zip");
1029
+ await s.session.context.tracing.stop({ path: tracePath }).catch((err) => {
1030
+ log.warn("trace stop failed", {
1031
+ session_id: session.id,
1032
+ err: String(err)
1033
+ });
1034
+ });
1035
+ }
852
1036
  await s.session.context.close().catch((err) => {
853
1037
  log.warn("context close failed", { session_id: session.id, err: String(err) });
854
1038
  });
@@ -860,7 +1044,7 @@ var PlaywrightEngine = class {
860
1044
  }
861
1045
  async snapshot(session, mode = "visible") {
862
1046
  const s = this.requireSession(session.id);
863
- const ariaYaml = await s.session.page.ariaSnapshot({ mode: "ai" });
1047
+ const ariaYaml = await this.activePage(s).ariaSnapshot({ mode: "ai" });
864
1048
  const { tree, refIndex } = parseAriaSnapshot(ariaYaml);
865
1049
  void mode;
866
1050
  s.snapshotGeneration += 1;
@@ -870,7 +1054,7 @@ var PlaywrightEngine = class {
870
1054
  return {
871
1055
  session_id: session.id,
872
1056
  platform: "web",
873
- url_or_screen: s.session.page.url(),
1057
+ url_or_screen: this.activePage(s).url(),
874
1058
  taken_at: s.lastSnapshotAt,
875
1059
  tree
876
1060
  };
@@ -890,7 +1074,7 @@ var PlaywrightEngine = class {
890
1074
  }
891
1075
  async key(session, key) {
892
1076
  const s = this.requireSession(session.id);
893
- await s.session.page.keyboard.press(key);
1077
+ await this.activePage(s).keyboard.press(key);
894
1078
  this.invalidateRefs(s);
895
1079
  }
896
1080
  async scroll(session, dir, amount = 400, ref) {
@@ -904,13 +1088,13 @@ var PlaywrightEngine = class {
904
1088
  [dx, dy]
905
1089
  );
906
1090
  } else {
907
- await s.session.page.mouse.wheel(dx, dy);
1091
+ await this.activePage(s).mouse.wheel(dx, dy);
908
1092
  }
909
1093
  this.invalidateRefs(s);
910
1094
  }
911
1095
  async waitFor(session, cond, timeoutMs = 1e4) {
912
1096
  const s = this.requireSession(session.id);
913
- const page = s.session.page;
1097
+ const page = this.activePage(s);
914
1098
  switch (cond.kind) {
915
1099
  case "text_visible":
916
1100
  await page.getByText(cond.text, { exact: false }).first().waitFor({ state: "visible", timeout: timeoutMs });
@@ -930,14 +1114,14 @@ var PlaywrightEngine = class {
930
1114
  }
931
1115
  async screenshot(session, fullPage = false) {
932
1116
  const s = this.requireSession(session.id);
933
- return s.session.page.screenshot({ fullPage });
1117
+ return this.activePage(s).screenshot({ fullPage });
934
1118
  }
935
1119
  async navigate(session, url) {
936
1120
  const s = this.requireSession(session.id);
937
1121
  if (s.session.platform !== "web") {
938
1122
  throw new UnsupportedPlatformError(s.session.platform);
939
1123
  }
940
- await s.session.page.goto(url, { waitUntil: "domcontentloaded" });
1124
+ await this.activePage(s).goto(url, { waitUntil: "domcontentloaded" });
941
1125
  this.invalidateRefs(s);
942
1126
  }
943
1127
  /**
@@ -951,7 +1135,7 @@ var PlaywrightEngine = class {
951
1135
  if (s.session.platform !== "web") {
952
1136
  throw new UnsupportedPlatformError(s.session.platform);
953
1137
  }
954
- return s.session.page;
1138
+ return this.activePage(s);
955
1139
  }
956
1140
  /** Increment generation; the next ref-using call will see them as stale. */
957
1141
  bumpGeneration(sessionId) {
@@ -959,8 +1143,311 @@ var PlaywrightEngine = class {
959
1143
  this.invalidateRefs(s);
960
1144
  }
961
1145
  // -------------------------------------------------------------------------
1146
+ // v0.5 — input additions
1147
+ // -------------------------------------------------------------------------
1148
+ async hover(session, ref) {
1149
+ const s = this.requireSession(session.id);
1150
+ const locator = this.resolveLocator(s, ref);
1151
+ await locator.hover();
1152
+ }
1153
+ async drag(session, fromRef, toRef) {
1154
+ const s = this.requireSession(session.id);
1155
+ const from = this.resolveLocator(s, fromRef);
1156
+ const to = this.resolveLocator(s, toRef);
1157
+ await from.dragTo(to);
1158
+ this.invalidateRefs(s);
1159
+ }
1160
+ async fillForm(session, fields) {
1161
+ const s = this.requireSession(session.id);
1162
+ for (const field of fields) {
1163
+ const locator = this.resolveLocator(s, field.ref);
1164
+ const kind = field.kind;
1165
+ if (kind === "checkbox" || kind === "radio") {
1166
+ const checked = typeof field.value === "boolean" ? field.value : field.value === "true" || field.value === "on";
1167
+ await locator.setChecked(checked);
1168
+ } else if (kind === "select") {
1169
+ await locator.selectOption(String(field.value));
1170
+ } else {
1171
+ await locator.fill(String(field.value));
1172
+ }
1173
+ }
1174
+ this.invalidateRefs(s);
1175
+ }
1176
+ async uploadFile(session, ref, filePath) {
1177
+ const s = this.requireSession(session.id);
1178
+ if (!isAbsolute(filePath)) {
1179
+ throw new RolepodMcpError(
1180
+ "invalid_input",
1181
+ `upload_file requires an absolute path; got "${filePath}".`,
1182
+ { file_path: filePath }
1183
+ );
1184
+ }
1185
+ const locator = this.resolveLocator(s, ref);
1186
+ await locator.setInputFiles(filePath);
1187
+ this.invalidateRefs(s);
1188
+ }
1189
+ // -------------------------------------------------------------------------
1190
+ // v0.5 — web-only extensions (not on Engine interface; tools cast to
1191
+ // PlaywrightEngine before calling).
1192
+ // -------------------------------------------------------------------------
1193
+ /**
1194
+ * Pre-arm a one-shot dialog handler for the next dialog raised on the
1195
+ * active page. Returns when either the dialog fires (and is handled)
1196
+ * or the timeout elapses. The caller is expected to trigger the
1197
+ * dialog (via click etc.) AFTER arming.
1198
+ */
1199
+ async handleDialog(sessionId, opts) {
1200
+ const s = this.requireSession(sessionId);
1201
+ const timeoutMs = opts.timeoutMs ?? 3e4;
1202
+ const expiresAt = Date.now() + timeoutMs;
1203
+ if (s.dialogArming) {
1204
+ s.dialogArming.resolve(false);
1205
+ }
1206
+ return new Promise((resolve7) => {
1207
+ const arming = {
1208
+ action: opts.action,
1209
+ text: opts.text,
1210
+ expiresAt,
1211
+ resolve: (handled) => {
1212
+ s.dialogArming = null;
1213
+ resolve7({ handled });
1214
+ }
1215
+ };
1216
+ s.dialogArming = arming;
1217
+ const timer = setTimeout(() => {
1218
+ if (s.dialogArming === arming) {
1219
+ s.dialogArming = null;
1220
+ resolve7({ handled: false });
1221
+ }
1222
+ }, timeoutMs);
1223
+ timer.unref?.();
1224
+ });
1225
+ }
1226
+ getConsole(sessionId, opts) {
1227
+ const s = this.requireSession(sessionId);
1228
+ const levels = opts?.levels;
1229
+ const contains = opts?.contains;
1230
+ const limit = opts?.limit ?? 50;
1231
+ let entries = s.consoleBuffer;
1232
+ if (levels && levels.length > 0) {
1233
+ entries = entries.filter((e) => levels.includes(e.level));
1234
+ }
1235
+ if (contains) {
1236
+ entries = entries.filter((e) => e.text.includes(contains));
1237
+ }
1238
+ const result = entries.slice(-limit);
1239
+ if (opts?.clear) s.consoleBuffer = [];
1240
+ return result;
1241
+ }
1242
+ getNetwork(sessionId, opts) {
1243
+ const s = this.requireSession(sessionId);
1244
+ let entries = s.networkBuffer;
1245
+ if (opts?.urlPattern) {
1246
+ if (opts.patternKind === "regex") {
1247
+ const re = new RegExp(opts.urlPattern);
1248
+ entries = entries.filter((e) => re.test(e.url));
1249
+ } else {
1250
+ entries = entries.filter((e) => e.url.includes(opts.urlPattern));
1251
+ }
1252
+ }
1253
+ if (opts?.method) {
1254
+ const m = opts.method.toUpperCase();
1255
+ entries = entries.filter((e) => e.method.toUpperCase() === m);
1256
+ }
1257
+ if (opts?.statusRange) {
1258
+ const { min, max } = opts.statusRange;
1259
+ entries = entries.filter(
1260
+ (e) => e.status !== void 0 && e.status >= min && e.status <= max
1261
+ );
1262
+ }
1263
+ if (opts?.onlyFailed) {
1264
+ entries = entries.filter(
1265
+ (e) => !!e.failure || e.status !== void 0 && e.status >= 400
1266
+ );
1267
+ }
1268
+ const limit = opts?.limit ?? 50;
1269
+ const result = entries.slice(-limit);
1270
+ if (opts?.clear) s.networkBuffer = [];
1271
+ return result;
1272
+ }
1273
+ /**
1274
+ * Read the consoleBuffer/networkBuffer directly without filtering —
1275
+ * used by verify_ui_flow expect evaluators.
1276
+ */
1277
+ peekBuffers(sessionId) {
1278
+ const s = this.requireSession(sessionId);
1279
+ return { console: s.consoleBuffer, network: s.networkBuffer };
1280
+ }
1281
+ /**
1282
+ * Runtime mutation of context-level emulation. CPU + network throttle
1283
+ * use CDP and only work on chromium; everything else is cross-browser.
1284
+ */
1285
+ async setEnv(sessionId, opts) {
1286
+ const s = this.requireSession(sessionId);
1287
+ const page = this.activePage(s);
1288
+ const ctx = s.session.context;
1289
+ if (opts.viewport) {
1290
+ await page.setViewportSize(opts.viewport);
1291
+ }
1292
+ if (opts.offline !== void 0) {
1293
+ await ctx.setOffline(opts.offline);
1294
+ }
1295
+ if (opts.geolocation) {
1296
+ await ctx.setGeolocation(opts.geolocation);
1297
+ }
1298
+ if (opts.extraHeaders) {
1299
+ await ctx.setExtraHTTPHeaders(opts.extraHeaders);
1300
+ }
1301
+ if (opts.colorScheme || opts.reducedMotion) {
1302
+ await page.emulateMedia({
1303
+ ...opts.colorScheme ? { colorScheme: opts.colorScheme } : {},
1304
+ ...opts.reducedMotion ? { reducedMotion: opts.reducedMotion } : {}
1305
+ });
1306
+ }
1307
+ if (opts.networkThrottle || opts.cpuThrottle !== void 0) {
1308
+ const browserName = ctx.browser()?.browserType().name();
1309
+ if (browserName !== "chromium") {
1310
+ throw new RolepodMcpError(
1311
+ "unsupported_engine",
1312
+ `networkThrottle / cpuThrottle require chromium (CDP-backed); current browser is "${browserName}".`
1313
+ );
1314
+ }
1315
+ const cdp = await ctx.newCDPSession(page);
1316
+ try {
1317
+ if (opts.networkThrottle) {
1318
+ const preset = NETWORK_PRESETS[opts.networkThrottle];
1319
+ await cdp.send("Network.enable");
1320
+ await cdp.send("Network.emulateNetworkConditions", preset);
1321
+ }
1322
+ if (opts.cpuThrottle !== void 0) {
1323
+ await cdp.send("Emulation.setCPUThrottlingRate", {
1324
+ rate: opts.cpuThrottle
1325
+ });
1326
+ }
1327
+ } finally {
1328
+ await cdp.detach().catch(() => void 0);
1329
+ }
1330
+ }
1331
+ this.invalidateRefs(s);
1332
+ }
1333
+ /**
1334
+ * Execute a JavaScript function in the page context. ALWAYS gated by
1335
+ * the tool layer (`ROLEPOD_ALLOW_EVAL=1`); this method does not enforce
1336
+ * the env check.
1337
+ */
1338
+ async evaluate(sessionId, script, args) {
1339
+ const s = this.requireSession(sessionId);
1340
+ const page = this.activePage(s);
1341
+ return page.evaluate(
1342
+ ({ src, a }) => (
1343
+ // eslint-disable-next-line no-new-func
1344
+ new Function("args", `return (async () => { ${src} })();`)(a)
1345
+ ),
1346
+ { src: script, a: args ?? [] }
1347
+ );
1348
+ }
1349
+ listPages(sessionId) {
1350
+ const s = this.requireSession(sessionId);
1351
+ return s.pages.map((p, i) => ({
1352
+ index: i,
1353
+ url: p.url(),
1354
+ title_promise: p.title(),
1355
+ active: i === s.activePageIndex
1356
+ }));
1357
+ }
1358
+ async switchPage(sessionId, index) {
1359
+ const s = this.requireSession(sessionId);
1360
+ if (index < 0 || index >= s.pages.length) {
1361
+ throw new RolepodMcpError(
1362
+ "invalid_input",
1363
+ `Page index ${index} out of range (have ${s.pages.length} page(s)).`,
1364
+ { index, available: s.pages.length }
1365
+ );
1366
+ }
1367
+ s.activePageIndex = index;
1368
+ this.invalidateRefs(s);
1369
+ }
1370
+ // -------------------------------------------------------------------------
962
1371
  // Internal helpers
963
1372
  // -------------------------------------------------------------------------
1373
+ activePage(s) {
1374
+ return s.pages[s.activePageIndex] ?? s.session.mainPage;
1375
+ }
1376
+ attachPageListeners(s, page) {
1377
+ page.on("console", (msg) => {
1378
+ const level = mapConsoleLevel(msg.type());
1379
+ pushRing(
1380
+ s.consoleBuffer,
1381
+ {
1382
+ level,
1383
+ text: msg.text(),
1384
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1385
+ location: formatConsoleLocation(msg)
1386
+ },
1387
+ CONSOLE_BUFFER_CAP
1388
+ );
1389
+ });
1390
+ page.on("request", (req) => {
1391
+ const id = s.networkNextId++;
1392
+ s.networkInflight.set(req.url() + "::" + req.method() + "::" + id, {
1393
+ id,
1394
+ startedAt: Date.now(),
1395
+ resourceType: req.resourceType()
1396
+ });
1397
+ pushRing(
1398
+ s.networkBuffer,
1399
+ {
1400
+ id,
1401
+ url: req.url(),
1402
+ method: req.method(),
1403
+ resource_type: req.resourceType(),
1404
+ ts: (/* @__PURE__ */ new Date()).toISOString()
1405
+ },
1406
+ NETWORK_BUFFER_CAP
1407
+ );
1408
+ });
1409
+ page.on("response", (res) => {
1410
+ const req = res.request();
1411
+ const entry = findNetworkEntry(s.networkBuffer, req);
1412
+ if (entry) {
1413
+ entry.status = res.status();
1414
+ entry.duration_ms = Date.now() - new Date(entry.ts).getTime();
1415
+ }
1416
+ });
1417
+ page.on("requestfailed", (req) => {
1418
+ const entry = findNetworkEntry(s.networkBuffer, req);
1419
+ if (entry) {
1420
+ entry.failure = req.failure()?.errorText ?? "request failed";
1421
+ }
1422
+ });
1423
+ page.on("dialog", (dialog) => {
1424
+ void this.handlePageDialog(s, dialog);
1425
+ });
1426
+ }
1427
+ async handlePageDialog(s, dialog) {
1428
+ const arm = s.dialogArming;
1429
+ if (!arm || Date.now() > arm.expiresAt) {
1430
+ await dialog.dismiss().catch(() => void 0);
1431
+ if (arm) arm.resolve(false);
1432
+ return;
1433
+ }
1434
+ try {
1435
+ if (arm.action === "accept") {
1436
+ await dialog.accept();
1437
+ } else if (arm.action === "accept_with_text") {
1438
+ await dialog.accept(arm.text ?? "");
1439
+ } else {
1440
+ await dialog.dismiss();
1441
+ }
1442
+ arm.resolve(true);
1443
+ } catch (err) {
1444
+ log.warn("dialog handle failed", {
1445
+ session_id: s.session.id,
1446
+ err: String(err)
1447
+ });
1448
+ arm.resolve(false);
1449
+ }
1450
+ }
964
1451
  requireSession(sessionId) {
965
1452
  const s = this.sessions.get(sessionId);
966
1453
  if (!s) {
@@ -989,7 +1476,7 @@ var PlaywrightEngine = class {
989
1476
  if (meta.ref.startsWith("s")) {
990
1477
  throw new UnknownRefError(s.session.id, ref);
991
1478
  }
992
- return s.session.page.locator(`aria-ref=${meta.ref}`);
1479
+ return this.activePage(s).locator(`aria-ref=${meta.ref}`);
993
1480
  }
994
1481
  invalidateRefs(s) {
995
1482
  s.snapshotGeneration += 1;
@@ -1125,6 +1612,10 @@ var SessionRegistry = class {
1125
1612
  }
1126
1613
  };
1127
1614
 
1615
+ // src/tools/composite/verify_ui_flow.ts
1616
+ import { readdir } from "fs/promises";
1617
+ import { resolve as resolvePath2 } from "path";
1618
+
1128
1619
  // src/schema/tools.ts
1129
1620
  import { z } from "zod";
1130
1621
  var platformSchema = z.enum(["web", "ios", "android"]);
@@ -1228,6 +1719,143 @@ var browserNavigateShape = {
1228
1719
  url: z.string().url()
1229
1720
  };
1230
1721
  var browserNavigateSchema = z.object(browserNavigateShape);
1722
+ var browserHoverShape = {
1723
+ session_id: z.string().min(1),
1724
+ ref: z.string().min(1)
1725
+ };
1726
+ var browserHoverSchema = z.object(browserHoverShape);
1727
+ var browserDragShape = {
1728
+ session_id: z.string().min(1),
1729
+ from_ref: z.string().min(1),
1730
+ to_ref: z.string().min(1)
1731
+ };
1732
+ var browserDragSchema = z.object(browserDragShape);
1733
+ var fillFieldKindSchema = z.enum([
1734
+ "input",
1735
+ "select",
1736
+ "checkbox",
1737
+ "radio"
1738
+ ]);
1739
+ var fillFormFieldSchema = z.object({
1740
+ ref: z.string().min(1),
1741
+ value: z.union([z.string(), z.boolean()]),
1742
+ kind: fillFieldKindSchema.optional()
1743
+ });
1744
+ var browserFillFormShape = {
1745
+ session_id: z.string().min(1),
1746
+ fields: z.array(fillFormFieldSchema).min(1)
1747
+ };
1748
+ var browserFillFormSchema = z.object(browserFillFormShape);
1749
+ var browserUploadFileShape = {
1750
+ session_id: z.string().min(1),
1751
+ ref: z.string().min(1),
1752
+ file_path: z.string().min(1)
1753
+ };
1754
+ var browserUploadFileSchema = z.object(browserUploadFileShape);
1755
+ var dialogActionSchema = z.enum([
1756
+ "accept",
1757
+ "dismiss",
1758
+ "accept_with_text"
1759
+ ]);
1760
+ var browserHandleDialogShape = {
1761
+ session_id: z.string().min(1),
1762
+ action: dialogActionSchema,
1763
+ /** Only used when action='accept_with_text'. */
1764
+ text: z.string().optional(),
1765
+ /**
1766
+ * Arming behavior: registers a one-shot handler for the NEXT dialog
1767
+ * raised on the page. Call this BEFORE the action that triggers the
1768
+ * dialog (e.g. before clicking the button that calls `confirm()`).
1769
+ * Default 30s if no dialog appears, handler is auto-removed.
1770
+ */
1771
+ timeout_ms: z.number().int().positive().optional()
1772
+ };
1773
+ var browserHandleDialogSchema = z.object(browserHandleDialogShape);
1774
+ var consoleLevelSchema = z.enum([
1775
+ "error",
1776
+ "warning",
1777
+ "info",
1778
+ "log",
1779
+ "debug",
1780
+ "trace"
1781
+ ]);
1782
+ var browserConsoleShape = {
1783
+ session_id: z.string().min(1),
1784
+ /** Filter to only these levels. Default: errors+warnings. */
1785
+ levels: z.array(consoleLevelSchema).optional(),
1786
+ /** Substring match on message text. */
1787
+ contains: z.string().optional(),
1788
+ /** Drop all buffered messages after returning. */
1789
+ clear: z.boolean().default(false),
1790
+ /** Cap on returned messages (artifact still holds full ring buffer). */
1791
+ limit: z.number().int().positive().max(1e3).default(50)
1792
+ };
1793
+ var browserConsoleSchema = z.object(browserConsoleShape);
1794
+ var browserNetworkShape = {
1795
+ session_id: z.string().min(1),
1796
+ /** Substring or regex (per `pattern_kind`) match on URL. */
1797
+ url_pattern: z.string().optional(),
1798
+ pattern_kind: z.enum(["substring", "regex"]).default("substring"),
1799
+ method: z.string().optional(),
1800
+ /** Inclusive range — e.g. `{min: 400, max: 599}` for any error response. */
1801
+ status_range: z.object({
1802
+ min: z.number().int().min(100).max(599),
1803
+ max: z.number().int().min(100).max(599)
1804
+ }).optional(),
1805
+ only_failed: z.boolean().default(false),
1806
+ /** Write the full HAR file for this session to artifacts/{runId}/network.har. */
1807
+ export_har: z.boolean().default(false),
1808
+ /** Drop buffered entries after returning. */
1809
+ clear: z.boolean().default(false),
1810
+ limit: z.number().int().positive().max(1e3).default(50)
1811
+ };
1812
+ var browserNetworkSchema = z.object(browserNetworkShape);
1813
+ var networkPresetSchema = z.enum([
1814
+ "offline",
1815
+ "slow-3g",
1816
+ "fast-3g",
1817
+ "slow-4g",
1818
+ "fast-4g",
1819
+ "no-throttling"
1820
+ ]);
1821
+ var geolocationSchema = z.object({
1822
+ latitude: z.number().min(-90).max(90),
1823
+ longitude: z.number().min(-180).max(180),
1824
+ accuracy: z.number().nonnegative().optional()
1825
+ });
1826
+ var browserSetEnvShape = {
1827
+ session_id: z.string().min(1),
1828
+ viewport: viewportSchema.optional(),
1829
+ offline: z.boolean().optional(),
1830
+ geolocation: geolocationSchema.optional(),
1831
+ color_scheme: z.enum(["light", "dark", "no-preference"]).optional(),
1832
+ reduced_motion: z.enum(["reduce", "no-preference"]).optional(),
1833
+ extra_headers: z.record(z.string(), z.string()).optional(),
1834
+ network_throttle: networkPresetSchema.optional(),
1835
+ /** CPU slowdown multiplier (1 = no throttle, 4 = 4x slower). Chromium only. */
1836
+ cpu_throttle: z.number().min(1).max(20).optional()
1837
+ };
1838
+ var browserSetEnvSchema = z.object(browserSetEnvShape);
1839
+ var browserEvaluateShape = {
1840
+ session_id: z.string().min(1),
1841
+ script: z.string().min(1),
1842
+ args: z.array(z.unknown()).optional()
1843
+ };
1844
+ var browserEvaluateSchema = z.object(browserEvaluateShape);
1845
+ var browserPagesShape = {
1846
+ session_id: z.string().min(1)
1847
+ };
1848
+ var browserPagesSchema = z.object(browserPagesShape);
1849
+ var browserSwitchPageShape = {
1850
+ session_id: z.string().min(1),
1851
+ index: z.number().int().nonnegative()
1852
+ };
1853
+ var browserSwitchPageSchema = z.object(browserSwitchPageShape);
1854
+ var verifyFillFieldSchema = z.object({
1855
+ query: z.string(),
1856
+ value: z.union([z.string(), z.boolean()]),
1857
+ kind: fillFieldKindSchema.optional()
1858
+ });
1231
1859
  var verifyStepSchema = z.discriminatedUnion("kind", [
1232
1860
  z.object({ kind: z.literal("click"), query: z.string() }),
1233
1861
  z.object({
@@ -1238,7 +1866,44 @@ var verifyStepSchema = z.discriminatedUnion("kind", [
1238
1866
  }),
1239
1867
  z.object({ kind: z.literal("key"), key: z.string() }),
1240
1868
  z.object({ kind: z.literal("wait_for"), condition: waitConditionSchema }),
1241
- z.object({ kind: z.literal("navigate"), url: z.string().url() })
1869
+ z.object({ kind: z.literal("navigate"), url: z.string().url() }),
1870
+ // v0.5 additions
1871
+ z.object({ kind: z.literal("hover"), query: z.string() }),
1872
+ z.object({
1873
+ kind: z.literal("drag"),
1874
+ from_query: z.string(),
1875
+ to_query: z.string()
1876
+ }),
1877
+ z.object({
1878
+ kind: z.literal("fill_form"),
1879
+ fields: z.array(verifyFillFieldSchema).min(1)
1880
+ }),
1881
+ z.object({
1882
+ kind: z.literal("upload"),
1883
+ query: z.string(),
1884
+ file_path: z.string().min(1)
1885
+ }),
1886
+ z.object({
1887
+ kind: z.literal("dialog"),
1888
+ action: dialogActionSchema,
1889
+ text: z.string().optional()
1890
+ }),
1891
+ z.object({
1892
+ kind: z.literal("set_env"),
1893
+ viewport: viewportSchema.optional(),
1894
+ offline: z.boolean().optional(),
1895
+ geolocation: geolocationSchema.optional(),
1896
+ color_scheme: z.enum(["light", "dark", "no-preference"]).optional(),
1897
+ reduced_motion: z.enum(["reduce", "no-preference"]).optional(),
1898
+ extra_headers: z.record(z.string(), z.string()).optional(),
1899
+ network_throttle: networkPresetSchema.optional(),
1900
+ cpu_throttle: z.number().min(1).max(20).optional()
1901
+ }),
1902
+ z.object({
1903
+ kind: z.literal("switch_page"),
1904
+ index: z.number().int().nonnegative()
1905
+ }),
1906
+ z.object({ kind: z.literal("evaluate"), script: z.string().min(1) })
1242
1907
  ]);
1243
1908
  var verifyExpectSchema = z.discriminatedUnion("kind", [
1244
1909
  z.object({ kind: z.literal("text_visible"), text: z.string() }),
@@ -1248,6 +1913,28 @@ var verifyExpectSchema = z.discriminatedUnion("kind", [
1248
1913
  kind: z.literal("ref_in_state"),
1249
1914
  query: z.string(),
1250
1915
  state: z.enum(["visible", "enabled", "focused"])
1916
+ }),
1917
+ // v0.5 additions
1918
+ z.object({
1919
+ kind: z.literal("no_console_errors"),
1920
+ exclude_patterns: z.array(z.string()).optional()
1921
+ }),
1922
+ z.object({
1923
+ kind: z.literal("no_failed_requests"),
1924
+ exclude_patterns: z.array(z.string()).optional(),
1925
+ /** When true, only 5xx counts as a failure. Default false (4xx + 5xx). */
1926
+ allow_4xx: z.boolean().optional()
1927
+ }),
1928
+ z.object({
1929
+ kind: z.literal("request_made"),
1930
+ url_pattern: z.string(),
1931
+ method: z.string().optional(),
1932
+ min_count: z.number().int().positive().optional()
1933
+ }),
1934
+ z.object({
1935
+ kind: z.literal("response_status"),
1936
+ url_pattern: z.string(),
1937
+ status: z.number().int().min(100).max(599)
1251
1938
  })
1252
1939
  ]);
1253
1940
  var captureKindSchema = z.enum([
@@ -1255,7 +1942,8 @@ var captureKindSchema = z.enum([
1255
1942
  "har",
1256
1943
  "console",
1257
1944
  "a11y_tree",
1258
- "video"
1945
+ "video",
1946
+ "trace"
1259
1947
  ]);
1260
1948
  var verifyUiFlowShape = {
1261
1949
  mode: z.enum(["assert", "reproduce"]).default("assert"),
@@ -1333,6 +2021,19 @@ var ToolNames = {
1333
2021
  browserWaitFor: "rolepod_browser_wait_for",
1334
2022
  browserScreenshot: "rolepod_browser_screenshot",
1335
2023
  browserNavigate: "rolepod_browser_navigate",
2024
+ // v0.5 atomics
2025
+ browserHover: "rolepod_browser_hover",
2026
+ browserDrag: "rolepod_browser_drag",
2027
+ browserFillForm: "rolepod_browser_fill_form",
2028
+ browserUploadFile: "rolepod_browser_upload_file",
2029
+ browserHandleDialog: "rolepod_browser_handle_dialog",
2030
+ browserConsole: "rolepod_browser_console",
2031
+ browserNetwork: "rolepod_browser_network",
2032
+ browserSetEnv: "rolepod_browser_set_env",
2033
+ browserEvaluate: "rolepod_browser_evaluate",
2034
+ browserPages: "rolepod_browser_pages",
2035
+ browserSwitchPage: "rolepod_browser_switch_page",
2036
+ // composite
1336
2037
  verifyUiFlow: "rolepod_verify_ui_flow",
1337
2038
  auditA11y: "rolepod_audit_a11y",
1338
2039
  visualDiff: "rolepod_visual_diff",
@@ -1367,6 +2068,37 @@ async function ddmin(input, reproduces) {
1367
2068
  return current;
1368
2069
  }
1369
2070
 
2071
+ // src/util/manifest.ts
2072
+ import { writeFile as writeFile2 } from "fs/promises";
2073
+ import { resolve as resolve3 } from "path";
2074
+ var ROLEPOD_PROTOCOL_VERSION = "rolepod/v1";
2075
+ async function writeManifest(input) {
2076
+ const manifest = {
2077
+ protocol: ROLEPOD_PROTOCOL_VERSION,
2078
+ plugin: "rolepod-uiproof",
2079
+ skill: input.skill,
2080
+ phase: input.phase,
2081
+ status: input.status,
2082
+ summary: input.summary,
2083
+ started_at: input.startedAt,
2084
+ finished_at: input.finishedAt,
2085
+ artifacts: input.artifacts,
2086
+ metadata: input.metadata ?? {}
2087
+ };
2088
+ const path = resolve3(input.runDir, "manifest.json");
2089
+ try {
2090
+ await writeFile2(path, JSON.stringify(manifest, null, 2), "utf8");
2091
+ return path;
2092
+ } catch (err) {
2093
+ log.warn("manifest write failed", {
2094
+ run_dir: input.runDir,
2095
+ skill: input.skill,
2096
+ err: String(err)
2097
+ });
2098
+ return void 0;
2099
+ }
2100
+ }
2101
+
1370
2102
  // src/tools/result.ts
1371
2103
  function ok(value) {
1372
2104
  return {
@@ -1408,7 +2140,11 @@ var verifyUiFlowTool = {
1408
2140
  inputShape: verifyUiFlowShape,
1409
2141
  build(ctx) {
1410
2142
  return safeHandler(async (args) => {
1411
- const { runId, runDir } = await ctx.store.startRun("verify");
2143
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2144
+ const { runId, runDir, skill } = await ctx.store.startRun(
2145
+ "verify",
2146
+ { skill: "verify-ui" }
2147
+ );
1412
2148
  const initial = await runFlow(ctx, args, args.steps, runDir, {
1413
2149
  captureEvidence: true,
1414
2150
  bundleName: "replay.json"
@@ -1433,17 +2169,75 @@ var verifyUiFlowTool = {
1433
2169
  attempts: min.attempts
1434
2170
  };
1435
2171
  }
2172
+ const manifestPath = await writeManifest({
2173
+ runDir,
2174
+ skill,
2175
+ phase: "verify",
2176
+ status: initial.passed ? "pass" : "fail",
2177
+ summary: buildVerifySummary(args, initial),
2178
+ startedAt,
2179
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
2180
+ artifacts: flattenVerifyEvidence(initial.evidence),
2181
+ metadata: {
2182
+ mode: args.mode,
2183
+ step_count: args.steps.length,
2184
+ expect_count: args.expect.length,
2185
+ ...initial.finalUrl !== void 0 ? { final_url: initial.finalUrl } : {}
2186
+ }
2187
+ });
2188
+ if (manifestPath) result.manifest = manifestPath;
1436
2189
  return ok(result);
1437
2190
  });
1438
2191
  }
1439
2192
  };
2193
+ function buildVerifySummary(args, outcome) {
2194
+ const stepCount = args.steps.length;
2195
+ const expectCount = args.expect.length;
2196
+ if (outcome.passed) {
2197
+ return `${stepCount} step(s), ${expectCount} expect(s) passed`;
2198
+ }
2199
+ if (outcome.failedAtStep !== void 0) {
2200
+ return `failed at step ${outcome.failedAtStep}: ${outcome.failureReason ?? "unknown"}`;
2201
+ }
2202
+ return `failed: ${outcome.failureReason ?? "unknown"}`;
2203
+ }
2204
+ function flattenVerifyEvidence(ev) {
2205
+ const out = [];
2206
+ for (const s of ev.screenshots) out.push({ type: "screenshot", path: s });
2207
+ if (ev.replay_bundle) out.push({ type: "replay_bundle", path: ev.replay_bundle });
2208
+ if (ev.console) out.push({ type: "console", path: ev.console });
2209
+ if (ev.a11y_tree) out.push({ type: "a11y_tree", path: ev.a11y_tree });
2210
+ if (ev.har) out.push({ type: "har", path: ev.har });
2211
+ if (ev.trace) out.push({ type: "trace", path: ev.trace });
2212
+ if (ev.video) for (const v of ev.video) out.push({ type: "video", path: v });
2213
+ return out;
2214
+ }
2215
+ function buildCaptureOptions(captures, runDir) {
2216
+ const cap = {};
2217
+ if (captures.has("har")) {
2218
+ cap.har = { path: resolvePath2(runDir, "network.har") };
2219
+ }
2220
+ if (captures.has("video")) {
2221
+ cap.video = { dir: resolvePath2(runDir, "videos") };
2222
+ }
2223
+ if (captures.has("trace")) {
2224
+ cap.trace = { artifactDir: runDir };
2225
+ }
2226
+ return Object.keys(cap).length > 0 ? cap : void 0;
2227
+ }
1440
2228
  async function runFlow(ctx, args, steps, runDir, opts) {
1441
2229
  const evidence = { screenshots: [] };
2230
+ const captures = new Set(args.capture ?? ["screenshot"]);
1442
2231
  let passed = false;
1443
2232
  let failedAtStep;
1444
2233
  let failureReason;
1445
2234
  let finalSnapshot;
1446
- const session = await ctx.registry.open(args.open);
2235
+ const openOpts = { ...args.open };
2236
+ const captureCfg = buildCaptureOptions(captures, runDir);
2237
+ if (captureCfg) {
2238
+ openOpts.capture = captureCfg;
2239
+ }
2240
+ const session = await ctx.registry.open(openOpts);
1447
2241
  const engine = ctx.registry.engineFor(session.id);
1448
2242
  const sessionHandle = { id: session.id, platform: session.platform };
1449
2243
  try {
@@ -1462,7 +2256,7 @@ async function runFlow(ctx, args, steps, runDir, opts) {
1462
2256
  const failures = [];
1463
2257
  for (let i = 0; i < args.expect.length; i++) {
1464
2258
  const expectation = args.expect[i];
1465
- if (!evaluateExpect(expectation, finalSnapshot)) {
2259
+ if (!evaluateExpect(expectation, finalSnapshot, engine, session.id)) {
1466
2260
  failures.push(`expect[${i}] ${describeExpect(expectation)}`);
1467
2261
  }
1468
2262
  }
@@ -1476,8 +2270,7 @@ async function runFlow(ctx, args, steps, runDir, opts) {
1476
2270
  passed = false;
1477
2271
  } finally {
1478
2272
  if (opts.captureEvidence) {
1479
- const wantScreenshot = !args.capture || args.capture.includes("screenshot");
1480
- if (wantScreenshot) {
2273
+ if (captures.has("screenshot")) {
1481
2274
  try {
1482
2275
  const buf = await engine.screenshot(sessionHandle, true);
1483
2276
  const p = await ctx.store.writeScreenshot(runDir, buf, "final");
@@ -1486,11 +2279,42 @@ async function runFlow(ctx, args, steps, runDir, opts) {
1486
2279
  failureReason ??= `screenshot capture failed: ${describeError(err)}`;
1487
2280
  }
1488
2281
  }
1489
- try {
1490
- evidence.replay_bundle = await ctx.store.writeReplayBundle(
1491
- runDir,
1492
- {
1493
- version: 1,
2282
+ if (captures.has("console") && engine instanceof PlaywrightEngine) {
2283
+ try {
2284
+ const messages = engine.peekBuffers(session.id).console;
2285
+ evidence.console = await ctx.store.writeReport(
2286
+ runDir,
2287
+ "console.json",
2288
+ JSON.stringify(
2289
+ {
2290
+ count: messages.length,
2291
+ by_level: countByLevel(messages),
2292
+ messages
2293
+ },
2294
+ null,
2295
+ 2
2296
+ )
2297
+ );
2298
+ } catch (err) {
2299
+ failureReason ??= `console capture failed: ${describeError(err)}`;
2300
+ }
2301
+ }
2302
+ if (captures.has("a11y_tree") && finalSnapshot) {
2303
+ try {
2304
+ evidence.a11y_tree = await ctx.store.writeReport(
2305
+ runDir,
2306
+ "a11y_tree.json",
2307
+ JSON.stringify(finalSnapshot, null, 2)
2308
+ );
2309
+ } catch (err) {
2310
+ failureReason ??= `a11y_tree capture failed: ${describeError(err)}`;
2311
+ }
2312
+ }
2313
+ try {
2314
+ evidence.replay_bundle = await ctx.store.writeReplayBundle(
2315
+ runDir,
2316
+ {
2317
+ version: 1,
1494
2318
  run_id: runDir.split("/").pop() ?? "run",
1495
2319
  recorded_at: (/* @__PURE__ */ new Date()).toISOString(),
1496
2320
  open: args.open,
@@ -1506,12 +2330,32 @@ async function runFlow(ctx, args, steps, runDir, opts) {
1506
2330
  await ctx.registry.close(sessionHandle).catch(() => void 0);
1507
2331
  }
1508
2332
  }
2333
+ if (opts.captureEvidence) {
2334
+ if (captureCfg?.har) evidence.har = captureCfg.har.path;
2335
+ if (captureCfg?.trace) {
2336
+ evidence.trace = resolvePath2(captureCfg.trace.artifactDir, "trace.zip");
2337
+ }
2338
+ if (captureCfg?.video) {
2339
+ try {
2340
+ const files = await readdir(captureCfg.video.dir).catch(() => []);
2341
+ evidence.video = files.filter((f) => f.endsWith(".webm")).map((f) => resolvePath2(captureCfg.video.dir, f));
2342
+ } catch {
2343
+ }
2344
+ }
2345
+ }
1509
2346
  const out = { passed, evidence };
1510
2347
  if (failedAtStep !== void 0) out.failedAtStep = failedAtStep;
1511
2348
  if (failureReason !== void 0) out.failureReason = failureReason;
1512
2349
  if (finalSnapshot) out.finalUrl = finalSnapshot.url_or_screen;
1513
2350
  return out;
1514
2351
  }
2352
+ function countByLevel(messages) {
2353
+ const counts = {};
2354
+ for (const m of messages) {
2355
+ counts[m.level] = (counts[m.level] ?? 0) + 1;
2356
+ }
2357
+ return counts;
2358
+ }
1515
2359
  async function minimize(ctx, args, initialSteps, runDir) {
1516
2360
  const tagged = initialSteps.map((step, origIndex) => ({ step, origIndex }));
1517
2361
  let attempts = 0;
@@ -1575,9 +2419,84 @@ async function runStep(engine, session, step, snap) {
1575
2419
  case "navigate":
1576
2420
  await engine.navigate(session, step.url);
1577
2421
  return;
2422
+ case "hover": {
2423
+ const ref = findRefByQuery(snap.tree, step.query);
2424
+ if (!ref) throw missingQuery(step.query);
2425
+ await engine.hover(session, ref);
2426
+ return;
2427
+ }
2428
+ case "drag": {
2429
+ const fromRef = findRefByQuery(snap.tree, step.from_query);
2430
+ if (!fromRef) throw missingQuery(step.from_query);
2431
+ const toRef = findRefByQuery(snap.tree, step.to_query);
2432
+ if (!toRef) throw missingQuery(step.to_query);
2433
+ await engine.drag(session, fromRef, toRef);
2434
+ return;
2435
+ }
2436
+ case "fill_form": {
2437
+ const resolved = step.fields.map((f) => {
2438
+ const ref = findRefByQuery(snap.tree, f.query);
2439
+ if (!ref) throw missingQuery(f.query);
2440
+ return f.kind !== void 0 ? { ref, value: f.value, kind: f.kind } : { ref, value: f.value };
2441
+ });
2442
+ await engine.fillForm(session, resolved);
2443
+ return;
2444
+ }
2445
+ case "upload": {
2446
+ const ref = findRefByQuery(snap.tree, step.query);
2447
+ if (!ref) throw missingQuery(step.query);
2448
+ await engine.uploadFile(session, ref, step.file_path);
2449
+ return;
2450
+ }
2451
+ case "dialog": {
2452
+ requirePlaywright(engine, "dialog");
2453
+ void engine.handleDialog(session.id, {
2454
+ action: step.action,
2455
+ ...step.text !== void 0 ? { text: step.text } : {}
2456
+ }).catch(() => void 0);
2457
+ return;
2458
+ }
2459
+ case "set_env": {
2460
+ requirePlaywright(engine, "set_env");
2461
+ await engine.setEnv(session.id, {
2462
+ ...step.viewport !== void 0 ? { viewport: step.viewport } : {},
2463
+ ...step.offline !== void 0 ? { offline: step.offline } : {},
2464
+ ...step.geolocation !== void 0 ? { geolocation: step.geolocation } : {},
2465
+ ...step.color_scheme !== void 0 ? { colorScheme: step.color_scheme } : {},
2466
+ ...step.reduced_motion !== void 0 ? { reducedMotion: step.reduced_motion } : {},
2467
+ ...step.extra_headers !== void 0 ? { extraHeaders: step.extra_headers } : {},
2468
+ ...step.network_throttle !== void 0 ? { networkThrottle: step.network_throttle } : {},
2469
+ ...step.cpu_throttle !== void 0 ? { cpuThrottle: step.cpu_throttle } : {}
2470
+ });
2471
+ return;
2472
+ }
2473
+ case "switch_page": {
2474
+ requirePlaywright(engine, "switch_page");
2475
+ await engine.switchPage(session.id, step.index);
2476
+ return;
2477
+ }
2478
+ case "evaluate": {
2479
+ requirePlaywright(engine, "evaluate");
2480
+ if (process.env.ROLEPOD_ALLOW_EVAL !== "1") {
2481
+ throw new RolepodMcpError(
2482
+ "engine_error",
2483
+ "verify_ui_flow step kind 'evaluate' is disabled. Restart the rolepod-uiproof MCP server with ROLEPOD_ALLOW_EVAL=1 to enable."
2484
+ );
2485
+ }
2486
+ await engine.evaluate(session.id, step.script);
2487
+ return;
2488
+ }
2489
+ }
2490
+ }
2491
+ function requirePlaywright(engine, stepKind) {
2492
+ if (!(engine instanceof PlaywrightEngine)) {
2493
+ throw new RolepodMcpError(
2494
+ "unsupported_engine",
2495
+ `verify_ui_flow step kind "${stepKind}" is web-only and requires PlaywrightEngine.`
2496
+ );
1578
2497
  }
1579
2498
  }
1580
- function evaluateExpect(exp, snap) {
2499
+ function evaluateExpect(exp, snap, engine, sessionId) {
1581
2500
  switch (exp.kind) {
1582
2501
  case "text_visible":
1583
2502
  return treeHasText(snap.tree, exp.text);
@@ -1597,6 +2516,49 @@ function evaluateExpect(exp, snap) {
1597
2516
  return node.state?.focused === true;
1598
2517
  }
1599
2518
  }
2519
+ case "no_console_errors": {
2520
+ if (!(engine instanceof PlaywrightEngine)) return true;
2521
+ const msgs = engine.peekBuffers(sessionId).console.filter(
2522
+ (m) => m.level === "error"
2523
+ );
2524
+ const excludes = exp.exclude_patterns ?? [];
2525
+ const remaining = msgs.filter(
2526
+ (m) => !excludes.some((p) => m.text.includes(p))
2527
+ );
2528
+ return remaining.length === 0;
2529
+ }
2530
+ case "no_failed_requests": {
2531
+ if (!(engine instanceof PlaywrightEngine)) return true;
2532
+ const reqs = engine.peekBuffers(sessionId).network.filter((r) => {
2533
+ if (r.failure) return true;
2534
+ if (r.status === void 0) return false;
2535
+ if (exp.allow_4xx) return r.status >= 500;
2536
+ return r.status >= 400;
2537
+ });
2538
+ const excludes = exp.exclude_patterns ?? [];
2539
+ const remaining = reqs.filter(
2540
+ (r) => !excludes.some((p) => r.url.includes(p))
2541
+ );
2542
+ return remaining.length === 0;
2543
+ }
2544
+ case "request_made": {
2545
+ if (!(engine instanceof PlaywrightEngine)) return false;
2546
+ const re = new RegExp(exp.url_pattern);
2547
+ const wantMethod = exp.method?.toUpperCase();
2548
+ const matches = engine.peekBuffers(sessionId).network.filter((r) => {
2549
+ if (!re.test(r.url)) return false;
2550
+ if (wantMethod && r.method.toUpperCase() !== wantMethod) return false;
2551
+ return true;
2552
+ });
2553
+ const min = exp.min_count ?? 1;
2554
+ return matches.length >= min;
2555
+ }
2556
+ case "response_status": {
2557
+ if (!(engine instanceof PlaywrightEngine)) return false;
2558
+ const re = new RegExp(exp.url_pattern);
2559
+ const match = engine.peekBuffers(sessionId).network.find((r) => re.test(r.url) && r.status === exp.status);
2560
+ return match !== void 0;
2561
+ }
1600
2562
  }
1601
2563
  }
1602
2564
  function describeExpect(exp) {
@@ -1609,6 +2571,14 @@ function describeExpect(exp) {
1609
2571
  return `url_matches /${exp.pattern}/`;
1610
2572
  case "ref_in_state":
1611
2573
  return `ref_in_state "${exp.query}" \u2192 ${exp.state}`;
2574
+ case "no_console_errors":
2575
+ return "no_console_errors";
2576
+ case "no_failed_requests":
2577
+ return "no_failed_requests";
2578
+ case "request_made":
2579
+ return `request_made ${exp.method ?? ""} ${exp.url_pattern}`.trim();
2580
+ case "response_status":
2581
+ return `response_status ${exp.url_pattern} = ${exp.status}`;
1612
2582
  }
1613
2583
  }
1614
2584
  function missingQuery(query) {
@@ -1655,7 +2625,7 @@ function treeHasText(tree, text) {
1655
2625
 
1656
2626
  // src/cli/replay.ts
1657
2627
  async function runReplay(bundlePath) {
1658
- const abs = resolve3(bundlePath);
2628
+ const abs = resolve4(bundlePath);
1659
2629
  const raw = await readFile(abs, "utf8");
1660
2630
  const bundle = JSON.parse(raw);
1661
2631
  if (bundle.version !== 1) {
@@ -1725,6 +2695,151 @@ var browserCloseTool = {
1725
2695
  }
1726
2696
  };
1727
2697
 
2698
+ // src/tools/atomic/browser_console.ts
2699
+ var browserConsoleTool = {
2700
+ name: ToolNames.browserConsole,
2701
+ 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.",
2702
+ inputShape: browserConsoleShape,
2703
+ build(ctx) {
2704
+ return safeHandler(async (args) => {
2705
+ const engine = ctx.registry.engineFor(args.session_id);
2706
+ if (!(engine instanceof PlaywrightEngine)) {
2707
+ throw new RolepodMcpError(
2708
+ "unsupported_engine",
2709
+ "console is web-only and requires PlaywrightEngine."
2710
+ );
2711
+ }
2712
+ const levels = args.levels && args.levels.length > 0 ? args.levels : ["error", "warning"];
2713
+ const messages = engine.getConsole(args.session_id, {
2714
+ levels,
2715
+ ...args.contains !== void 0 ? { contains: args.contains } : {},
2716
+ clear: args.clear,
2717
+ limit: args.limit
2718
+ });
2719
+ return ok({
2720
+ count: messages.length,
2721
+ messages
2722
+ });
2723
+ });
2724
+ }
2725
+ };
2726
+
2727
+ // src/tools/atomic/browser_drag.ts
2728
+ var browserDragTool = {
2729
+ name: ToolNames.browserDrag,
2730
+ 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.",
2731
+ inputShape: browserDragShape,
2732
+ build(ctx) {
2733
+ return safeHandler(async (args) => {
2734
+ const engine = ctx.registry.engineFor(args.session_id);
2735
+ await engine.drag(
2736
+ {
2737
+ id: args.session_id,
2738
+ platform: ctx.registry.platformOf(args.session_id)
2739
+ },
2740
+ args.from_ref,
2741
+ args.to_ref
2742
+ );
2743
+ return ok({ dragged: true });
2744
+ });
2745
+ }
2746
+ };
2747
+
2748
+ // src/tools/atomic/browser_evaluate.ts
2749
+ var browserEvaluateTool = {
2750
+ name: ToolNames.browserEvaluate,
2751
+ 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).",
2752
+ inputShape: browserEvaluateShape,
2753
+ build(ctx) {
2754
+ const allowed = process.env.ROLEPOD_ALLOW_EVAL === "1";
2755
+ return safeHandler(async (args) => {
2756
+ if (!allowed) {
2757
+ throw new RolepodMcpError(
2758
+ "engine_error",
2759
+ "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."
2760
+ );
2761
+ }
2762
+ const engine = ctx.registry.engineFor(args.session_id);
2763
+ if (!(engine instanceof PlaywrightEngine)) {
2764
+ throw new RolepodMcpError(
2765
+ "unsupported_engine",
2766
+ "evaluate is web-only and requires PlaywrightEngine."
2767
+ );
2768
+ }
2769
+ const result = await engine.evaluate(
2770
+ args.session_id,
2771
+ args.script,
2772
+ args.args
2773
+ );
2774
+ return ok({ result });
2775
+ });
2776
+ }
2777
+ };
2778
+
2779
+ // src/tools/atomic/browser_fill_form.ts
2780
+ var browserFillFormTool = {
2781
+ name: ToolNames.browserFillForm,
2782
+ 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.",
2783
+ inputShape: browserFillFormShape,
2784
+ build(ctx) {
2785
+ return safeHandler(async (args) => {
2786
+ const engine = ctx.registry.engineFor(args.session_id);
2787
+ await engine.fillForm(
2788
+ {
2789
+ id: args.session_id,
2790
+ platform: ctx.registry.platformOf(args.session_id)
2791
+ },
2792
+ args.fields
2793
+ );
2794
+ return ok({ filled: args.fields.length });
2795
+ });
2796
+ }
2797
+ };
2798
+
2799
+ // src/tools/atomic/browser_handle_dialog.ts
2800
+ var browserHandleDialogTool = {
2801
+ name: ToolNames.browserHandleDialog,
2802
+ 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.",
2803
+ inputShape: browserHandleDialogShape,
2804
+ build(ctx) {
2805
+ return safeHandler(async (args) => {
2806
+ const engine = ctx.registry.engineFor(args.session_id);
2807
+ if (!(engine instanceof PlaywrightEngine)) {
2808
+ throw new RolepodMcpError(
2809
+ "unsupported_engine",
2810
+ "handle_dialog is web-only and requires PlaywrightEngine."
2811
+ );
2812
+ }
2813
+ const { handled } = await engine.handleDialog(args.session_id, {
2814
+ action: args.action,
2815
+ ...args.text !== void 0 ? { text: args.text } : {},
2816
+ ...args.timeout_ms !== void 0 ? { timeoutMs: args.timeout_ms } : {}
2817
+ });
2818
+ return ok({ handled, action: args.action });
2819
+ });
2820
+ }
2821
+ };
2822
+
2823
+ // src/tools/atomic/browser_hover.ts
2824
+ var browserHoverTool = {
2825
+ name: ToolNames.browserHover,
2826
+ description: "Hover the pointer over the element identified by `ref`. Refs remain valid afterwards (read-mostly).",
2827
+ inputShape: browserHoverShape,
2828
+ build(ctx) {
2829
+ return safeHandler(async (args) => {
2830
+ const engine = ctx.registry.engineFor(args.session_id);
2831
+ await engine.hover(
2832
+ {
2833
+ id: args.session_id,
2834
+ platform: ctx.registry.platformOf(args.session_id)
2835
+ },
2836
+ args.ref
2837
+ );
2838
+ return ok({ hovered: true });
2839
+ });
2840
+ }
2841
+ };
2842
+
1728
2843
  // src/tools/atomic/browser_key.ts
1729
2844
  var browserKeyTool = {
1730
2845
  name: ToolNames.browserKey,
@@ -1753,6 +2868,46 @@ var browserNavigateTool = {
1753
2868
  }
1754
2869
  };
1755
2870
 
2871
+ // src/tools/atomic/browser_network.ts
2872
+ var browserNetworkTool = {
2873
+ name: ToolNames.browserNetwork,
2874
+ 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`.',
2875
+ inputShape: browserNetworkShape,
2876
+ build(ctx) {
2877
+ return safeHandler(async (args) => {
2878
+ const engine = ctx.registry.engineFor(args.session_id);
2879
+ if (!(engine instanceof PlaywrightEngine)) {
2880
+ throw new RolepodMcpError(
2881
+ "unsupported_engine",
2882
+ "network is web-only and requires PlaywrightEngine."
2883
+ );
2884
+ }
2885
+ const requests = engine.getNetwork(args.session_id, {
2886
+ ...args.url_pattern !== void 0 ? { urlPattern: args.url_pattern } : {},
2887
+ patternKind: args.pattern_kind,
2888
+ ...args.method !== void 0 ? { method: args.method } : {},
2889
+ ...args.status_range !== void 0 ? { statusRange: args.status_range } : {},
2890
+ onlyFailed: args.only_failed,
2891
+ clear: args.clear,
2892
+ limit: args.limit
2893
+ });
2894
+ const failed = requests.filter(
2895
+ (r) => !!r.failure || r.status !== void 0 && r.status >= 400
2896
+ ).length;
2897
+ return ok({
2898
+ count: requests.length,
2899
+ failed_count: failed,
2900
+ requests,
2901
+ // HAR file lives wherever the session was opened with
2902
+ // `capture.har.path`. We don't echo it here to avoid leaking
2903
+ // filesystem paths into untrusted logs; the verify_ui_flow run
2904
+ // result surfaces it in `evidence_paths.har`.
2905
+ har_recording: args.export_har ? "HAR is written at session close to the path passed via capture.har at open time." : void 0
2906
+ });
2907
+ });
2908
+ }
2909
+ };
2910
+
1756
2911
  // src/tools/atomic/browser_open.ts
1757
2912
  var browserOpenTool = {
1758
2913
  name: ToolNames.browserOpen,
@@ -1766,6 +2921,34 @@ var browserOpenTool = {
1766
2921
  }
1767
2922
  };
1768
2923
 
2924
+ // src/tools/atomic/browser_pages.ts
2925
+ var browserPagesTool = {
2926
+ name: ToolNames.browserPages,
2927
+ 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.",
2928
+ inputShape: browserPagesShape,
2929
+ build(ctx) {
2930
+ return safeHandler(async (args) => {
2931
+ const engine = ctx.registry.engineFor(args.session_id);
2932
+ if (!(engine instanceof PlaywrightEngine)) {
2933
+ throw new RolepodMcpError(
2934
+ "unsupported_engine",
2935
+ "pages is web-only and requires PlaywrightEngine."
2936
+ );
2937
+ }
2938
+ const raw = engine.listPages(args.session_id);
2939
+ const pages = await Promise.all(
2940
+ raw.map(async (p) => ({
2941
+ index: p.index,
2942
+ url: p.url,
2943
+ title: await p.title_promise.catch(() => ""),
2944
+ active: p.active
2945
+ }))
2946
+ );
2947
+ return ok({ count: pages.length, pages });
2948
+ });
2949
+ }
2950
+ };
2951
+
1769
2952
  // src/tools/atomic/browser_screenshot.ts
1770
2953
  var browserScreenshotTool = {
1771
2954
  name: ToolNames.browserScreenshot,
@@ -1810,6 +2993,35 @@ var browserScrollTool = {
1810
2993
  }
1811
2994
  };
1812
2995
 
2996
+ // src/tools/atomic/browser_set_env.ts
2997
+ var browserSetEnvTool = {
2998
+ name: ToolNames.browserSetEnv,
2999
+ 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.",
3000
+ inputShape: browserSetEnvShape,
3001
+ build(ctx) {
3002
+ return safeHandler(async (args) => {
3003
+ const engine = ctx.registry.engineFor(args.session_id);
3004
+ if (!(engine instanceof PlaywrightEngine)) {
3005
+ throw new RolepodMcpError(
3006
+ "unsupported_engine",
3007
+ "set_env is web-only and requires PlaywrightEngine."
3008
+ );
3009
+ }
3010
+ await engine.setEnv(args.session_id, {
3011
+ ...args.viewport !== void 0 ? { viewport: args.viewport } : {},
3012
+ ...args.offline !== void 0 ? { offline: args.offline } : {},
3013
+ ...args.geolocation !== void 0 ? { geolocation: args.geolocation } : {},
3014
+ ...args.color_scheme !== void 0 ? { colorScheme: args.color_scheme } : {},
3015
+ ...args.reduced_motion !== void 0 ? { reducedMotion: args.reduced_motion } : {},
3016
+ ...args.extra_headers !== void 0 ? { extraHeaders: args.extra_headers } : {},
3017
+ ...args.network_throttle !== void 0 ? { networkThrottle: args.network_throttle } : {},
3018
+ ...args.cpu_throttle !== void 0 ? { cpuThrottle: args.cpu_throttle } : {}
3019
+ });
3020
+ return ok({ applied: true });
3021
+ });
3022
+ }
3023
+ };
3024
+
1813
3025
  // src/tools/atomic/browser_snapshot.ts
1814
3026
  var browserSnapshotTool = {
1815
3027
  name: ToolNames.browserSnapshot,
@@ -1832,6 +3044,26 @@ var browserSnapshotTool = {
1832
3044
  }
1833
3045
  };
1834
3046
 
3047
+ // src/tools/atomic/browser_switch_page.ts
3048
+ var browserSwitchPageTool = {
3049
+ name: ToolNames.browserSwitchPage,
3050
+ 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.",
3051
+ inputShape: browserSwitchPageShape,
3052
+ build(ctx) {
3053
+ return safeHandler(async (args) => {
3054
+ const engine = ctx.registry.engineFor(args.session_id);
3055
+ if (!(engine instanceof PlaywrightEngine)) {
3056
+ throw new RolepodMcpError(
3057
+ "unsupported_engine",
3058
+ "switch_page is web-only and requires PlaywrightEngine."
3059
+ );
3060
+ }
3061
+ await engine.switchPage(args.session_id, args.index);
3062
+ return ok({ active_index: args.index });
3063
+ });
3064
+ }
3065
+ };
3066
+
1835
3067
  // src/tools/atomic/browser_type.ts
1836
3068
  var browserTypeTool = {
1837
3069
  name: ToolNames.browserType,
@@ -1851,6 +3083,27 @@ var browserTypeTool = {
1851
3083
  }
1852
3084
  };
1853
3085
 
3086
+ // src/tools/atomic/browser_upload_file.ts
3087
+ var browserUploadFileTool = {
3088
+ name: ToolNames.browserUploadFile,
3089
+ 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.",
3090
+ inputShape: browserUploadFileShape,
3091
+ build(ctx) {
3092
+ return safeHandler(async (args) => {
3093
+ const engine = ctx.registry.engineFor(args.session_id);
3094
+ await engine.uploadFile(
3095
+ {
3096
+ id: args.session_id,
3097
+ platform: ctx.registry.platformOf(args.session_id)
3098
+ },
3099
+ args.ref,
3100
+ args.file_path
3101
+ );
3102
+ return ok({ uploaded: true, file_path: args.file_path });
3103
+ });
3104
+ }
3105
+ };
3106
+
1854
3107
  // src/tools/atomic/browser_wait_for.ts
1855
3108
  var browserWaitForTool = {
1856
3109
  name: ToolNames.browserWaitFor,
@@ -1896,7 +3149,11 @@ var auditA11yTool = {
1896
3149
  inputShape: auditA11yShape,
1897
3150
  build(ctx) {
1898
3151
  return safeHandler(async (args) => {
1899
- const { runId, runDir } = await ctx.store.startRun("audit");
3152
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3153
+ const { runId, runDir, skill } = await ctx.store.startRun(
3154
+ "audit",
3155
+ { skill: "audit-a11y" }
3156
+ );
1900
3157
  const session = await ctx.registry.open(args.open);
1901
3158
  const engine = ctx.registry.engineFor(session.id);
1902
3159
  if (!(engine instanceof PlaywrightEngine)) {
@@ -1964,15 +3221,45 @@ var auditA11yTool = {
1964
3221
  await ctx.registry.close(session).catch(() => void 0);
1965
3222
  }
1966
3223
  }
3224
+ const counts = countBySeverity(issues);
3225
+ const status = a11yStatus(counts);
3226
+ const artifacts = reportPath ? [{ type: "report", path: reportPath }] : [];
3227
+ const manifestPath = await writeManifest({
3228
+ runDir,
3229
+ skill,
3230
+ phase: "verify",
3231
+ status,
3232
+ summary: buildAuditSummary(args.level, counts, status),
3233
+ startedAt,
3234
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3235
+ artifacts,
3236
+ metadata: {
3237
+ level: args.level,
3238
+ scope: args.scope,
3239
+ counts,
3240
+ report_format: args.report_format
3241
+ }
3242
+ });
1967
3243
  return ok({
1968
3244
  run_id: runId,
1969
- counts: countBySeverity(issues),
3245
+ counts,
1970
3246
  issues,
1971
- report_path: reportPath
3247
+ report_path: reportPath,
3248
+ ...manifestPath ? { manifest: manifestPath } : {}
1972
3249
  });
1973
3250
  });
1974
3251
  }
1975
3252
  };
3253
+ function a11yStatus(counts) {
3254
+ if ((counts.critical ?? 0) + (counts.serious ?? 0) > 0) return "fail";
3255
+ if ((counts.moderate ?? 0) + (counts.minor ?? 0) > 0) return "warn";
3256
+ return "pass";
3257
+ }
3258
+ function buildAuditSummary(level, counts, status) {
3259
+ const total = (counts.critical ?? 0) + (counts.serious ?? 0) + (counts.moderate ?? 0) + (counts.minor ?? 0);
3260
+ if (status === "pass") return `${level}: 0 issues`;
3261
+ return `${level}: ${total} issue(s) \u2014 critical=${counts.critical ?? 0}, serious=${counts.serious ?? 0}, moderate=${counts.moderate ?? 0}, minor=${counts.minor ?? 0}`;
3262
+ }
1976
3263
  function pickWcagRef(tags) {
1977
3264
  return tags.find((t) => /^wcag\d/.test(t));
1978
3265
  }
@@ -2089,14 +3376,18 @@ function scoreTree(root, tokens) {
2089
3376
 
2090
3377
  // src/tools/composite/scaffold_e2e.ts
2091
3378
  import { readFile as readFile2 } from "fs/promises";
2092
- import { resolve as resolve4 } from "path";
3379
+ import { resolve as resolve5 } from "path";
2093
3380
  var scaffoldE2eTool = {
2094
3381
  name: ToolNames.scaffoldE2e,
2095
3382
  description: "Generate a runnable e2e test file (playwright-test, vitest+playwright, or pytest+selenium) from a scenario description and optional replay bundle from a prior verify_ui_flow run.",
2096
3383
  inputShape: scaffoldE2eShape,
2097
3384
  build(ctx) {
2098
3385
  return safeHandler(async (args) => {
2099
- const { runId, runDir } = await ctx.store.startRun("scaffold");
3386
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3387
+ const { runId, runDir, skill } = await ctx.store.startRun(
3388
+ "scaffold",
3389
+ { skill: "scaffold-e2e" }
3390
+ );
2100
3391
  const slug = slugify(args.scenario_nl);
2101
3392
  const bundle = args.recorded_bundle ? await loadReplay(args.recorded_bundle) : null;
2102
3393
  const ctxObj = { args, slug, bundle };
@@ -2134,19 +3425,35 @@ var scaffoldE2eTool = {
2134
3425
  );
2135
3426
  }
2136
3427
  const path = await ctx.store.writeReport(runDir, filename, body);
3428
+ const manifestPath = await writeManifest({
3429
+ runDir,
3430
+ skill,
3431
+ phase: "build",
3432
+ status: "pass",
3433
+ summary: `generated ${args.framework} test "${filename}" from ${bundle ? "replay bundle" : "scenario"}`,
3434
+ startedAt,
3435
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3436
+ artifacts: [{ type: "test_file", path }],
3437
+ metadata: {
3438
+ framework: args.framework,
3439
+ language,
3440
+ from_replay_bundle: Boolean(bundle)
3441
+ }
3442
+ });
2137
3443
  return ok({
2138
3444
  run_id: runId,
2139
3445
  test_file_path: path,
2140
3446
  language,
2141
3447
  dependencies,
2142
3448
  setup_notes: setupNotes,
2143
- from_replay_bundle: Boolean(bundle)
3449
+ from_replay_bundle: Boolean(bundle),
3450
+ ...manifestPath ? { manifest: manifestPath } : {}
2144
3451
  });
2145
3452
  });
2146
3453
  }
2147
3454
  };
2148
3455
  async function loadReplay(bundlePath) {
2149
- const raw = await readFile2(resolve4(bundlePath), "utf8");
3456
+ const raw = await readFile2(resolve5(bundlePath), "utf8");
2150
3457
  return JSON.parse(raw);
2151
3458
  }
2152
3459
  function slugify(s) {
@@ -2223,6 +3530,67 @@ function playwrightStepLine(step) {
2223
3530
  return ` await page.goto(${JSON.stringify(step.url)});`;
2224
3531
  case "wait_for":
2225
3532
  return ` // wait_for: ${JSON.stringify(step.condition)} \u2014 translate to page.waitForXxx()`;
3533
+ case "hover":
3534
+ return ` await page.getByText(${JSON.stringify(step.query)}, { exact: false }).first().hover();`;
3535
+ case "drag":
3536
+ return [
3537
+ ` await page`,
3538
+ ` .getByText(${JSON.stringify(step.from_query)}, { exact: false })`,
3539
+ ` .first()`,
3540
+ ` .dragTo(page.getByText(${JSON.stringify(step.to_query)}, { exact: false }).first());`
3541
+ ].join("\n");
3542
+ case "fill_form": {
3543
+ const fields = Array.isArray(step.fields) ? step.fields : [];
3544
+ return fields.map((f) => {
3545
+ const q = JSON.stringify(f.query);
3546
+ if (f.kind === "select") {
3547
+ return ` await page.getByLabel(${q}).selectOption(${JSON.stringify(String(f.value))});`;
3548
+ }
3549
+ if (f.kind === "checkbox" || f.kind === "radio") {
3550
+ const checked = typeof f.value === "boolean" ? f.value : String(f.value) === "true" || String(f.value) === "on";
3551
+ return ` await page.getByLabel(${q}).setChecked(${checked});`;
3552
+ }
3553
+ return ` await page.getByLabel(${q}).fill(${JSON.stringify(String(f.value))});`;
3554
+ }).join("\n");
3555
+ }
3556
+ case "upload":
3557
+ return ` await page.getByLabel(${JSON.stringify(step.query)}).setInputFiles(${JSON.stringify(step.file_path)});`;
3558
+ case "dialog":
3559
+ return [
3560
+ ` page.once("dialog", async (dialog) => {`,
3561
+ step.action === "accept" ? ` await dialog.accept();` : step.action === "accept_with_text" ? ` await dialog.accept(${JSON.stringify(step.text ?? "")});` : ` await dialog.dismiss();`,
3562
+ ` });`
3563
+ ].join("\n");
3564
+ case "set_env": {
3565
+ const lines = [];
3566
+ if (step.viewport && typeof step.viewport === "object") {
3567
+ const v = step.viewport;
3568
+ lines.push(` await page.setViewportSize({ width: ${v.width}, height: ${v.height} });`);
3569
+ }
3570
+ if (step.offline !== void 0) {
3571
+ lines.push(` await page.context().setOffline(${Boolean(step.offline)});`);
3572
+ }
3573
+ if (step.geolocation) {
3574
+ lines.push(` await page.context().setGeolocation(${JSON.stringify(step.geolocation)});`);
3575
+ }
3576
+ if (step.color_scheme || step.reduced_motion) {
3577
+ const opts = {};
3578
+ if (step.color_scheme) opts.colorScheme = step.color_scheme;
3579
+ if (step.reduced_motion) opts.reducedMotion = step.reduced_motion;
3580
+ lines.push(` await page.emulateMedia(${JSON.stringify(opts)});`);
3581
+ }
3582
+ if (step.extra_headers) {
3583
+ lines.push(` await page.context().setExtraHTTPHeaders(${JSON.stringify(step.extra_headers)});`);
3584
+ }
3585
+ if (step.network_throttle || step.cpu_throttle !== void 0) {
3586
+ lines.push(` // network/cpu throttle requires CDP \u2014 see Playwright docs (chromium only)`);
3587
+ }
3588
+ return lines.length > 0 ? lines.join("\n") : ` // set_env: nothing to apply`;
3589
+ }
3590
+ case "switch_page":
3591
+ return ` const allPages = page.context().pages(); /* switch to index ${step.index} */ if (allPages[${step.index}]) await allPages[${step.index}].bringToFront();`;
3592
+ case "evaluate":
3593
+ return ` await page.evaluate(${JSON.stringify(step.script)});`;
2226
3594
  default:
2227
3595
  return ` // unsupported step kind: ${step.kind}`;
2228
3596
  }
@@ -2237,6 +3605,20 @@ function playwrightExpectLine(exp) {
2237
3605
  return ` await expect(page).toHaveURL(new RegExp(${JSON.stringify(exp.pattern)}));`;
2238
3606
  case "ref_in_state":
2239
3607
  return ` // ref_in_state ${JSON.stringify(exp.query)} \u2192 ${String(exp.state)} \u2014 translate as needed`;
3608
+ case "no_console_errors":
3609
+ return [
3610
+ ` // no_console_errors \u2014 collect via page.on('console') before the steps, then:`,
3611
+ ` // expect(consoleErrors).toEqual([]);`
3612
+ ].join("\n");
3613
+ case "no_failed_requests":
3614
+ return [
3615
+ ` // no_failed_requests \u2014 collect via page.on('requestfailed'/'response') before the steps, then:`,
3616
+ ` // expect(failedRequests).toEqual([]);`
3617
+ ].join("\n");
3618
+ case "request_made":
3619
+ return ` await page.waitForRequest(new RegExp(${JSON.stringify(exp.url_pattern)}));`;
3620
+ case "response_status":
3621
+ return ` await page.waitForResponse((r) => new RegExp(${JSON.stringify(exp.url_pattern)}).test(r.url()) && r.status() === ${Number(exp.status)});`;
2240
3622
  default:
2241
3623
  return ` // unsupported expect kind: ${exp.kind}`;
2242
3624
  }
@@ -2253,6 +3635,56 @@ function seleniumStepLine(step) {
2253
3635
  return ` driver.get(${JSON.stringify(step.url)})`;
2254
3636
  case "wait_for":
2255
3637
  return ` # wait_for: ${JSON.stringify(step.condition)} \u2014 translate to WebDriverWait`;
3638
+ case "hover":
3639
+ return [
3640
+ ` from selenium.webdriver.common.action_chains import ActionChains`,
3641
+ ` target = driver.find_element(By.XPATH, f"//*[contains(text(), \\"${escapePy(String(step.query))}\\")]")`,
3642
+ ` ActionChains(driver).move_to_element(target).perform()`
3643
+ ].join("\n");
3644
+ case "drag":
3645
+ return [
3646
+ ` from selenium.webdriver.common.action_chains import ActionChains`,
3647
+ ` src = driver.find_element(By.XPATH, f"//*[contains(text(), \\"${escapePy(String(step.from_query))}\\")]")`,
3648
+ ` dst = driver.find_element(By.XPATH, f"//*[contains(text(), \\"${escapePy(String(step.to_query))}\\")]")`,
3649
+ ` ActionChains(driver).drag_and_drop(src, dst).perform()`
3650
+ ].join("\n");
3651
+ case "fill_form": {
3652
+ const fields = Array.isArray(step.fields) ? step.fields : [];
3653
+ return fields.map((f) => {
3654
+ const q = escapePy(f.query);
3655
+ if (f.kind === "select") {
3656
+ return [
3657
+ ` from selenium.webdriver.support.ui import Select`,
3658
+ ` Select(driver.find_element(By.XPATH, f"//*[@aria-label=\\"${q}\\"]")).select_by_visible_text(${JSON.stringify(String(f.value))})`
3659
+ ].join("\n");
3660
+ }
3661
+ if (f.kind === "checkbox" || f.kind === "radio") {
3662
+ const checked = typeof f.value === "boolean" ? f.value : String(f.value) === "true";
3663
+ return ` el = driver.find_element(By.XPATH, f"//*[@aria-label=\\"${q}\\"]"); el.click() if el.is_selected() != ${checked ? "True" : "False"} else None`;
3664
+ }
3665
+ return ` driver.find_element(By.XPATH, f"//*[@aria-label=\\"${q}\\"]").send_keys(${JSON.stringify(String(f.value))})`;
3666
+ }).join("\n");
3667
+ }
3668
+ case "upload":
3669
+ return ` driver.find_element(By.XPATH, f"//*[@aria-label=\\"${escapePy(String(step.query))}\\"]").send_keys(${JSON.stringify(step.file_path)})`;
3670
+ case "dialog":
3671
+ return [
3672
+ ` alert = driver.switch_to.alert`,
3673
+ step.action === "accept" ? ` alert.accept()` : step.action === "accept_with_text" ? ` alert.send_keys(${JSON.stringify(step.text ?? "")}); alert.accept()` : ` alert.dismiss()`
3674
+ ].join("\n");
3675
+ case "set_env": {
3676
+ const lines = [];
3677
+ if (step.viewport && typeof step.viewport === "object") {
3678
+ const v = step.viewport;
3679
+ lines.push(` driver.set_window_size(${v.width}, ${v.height})`);
3680
+ }
3681
+ lines.push(` # set_env partially supported in Selenium \u2014 see selenium docs for offline/geolocation/colorScheme via CDP`);
3682
+ return lines.join("\n");
3683
+ }
3684
+ case "switch_page":
3685
+ return ` driver.switch_to.window(driver.window_handles[${step.index}])`;
3686
+ case "evaluate":
3687
+ return ` driver.execute_script(${JSON.stringify(step.script)})`;
2256
3688
  default:
2257
3689
  return ` # unsupported step kind: ${step.kind}`;
2258
3690
  }
@@ -2267,6 +3699,18 @@ function seleniumExpectLine(exp) {
2267
3699
  return ` import re; assert re.search(${JSON.stringify(exp.pattern)}, driver.current_url)`;
2268
3700
  case "ref_in_state":
2269
3701
  return ` # ref_in_state ${JSON.stringify(exp.query)} \u2192 ${String(exp.state)}`;
3702
+ case "no_console_errors":
3703
+ return [
3704
+ ` # no_console_errors \u2014 read browser logs via driver.get_log("browser")`,
3705
+ ` errors = [l for l in driver.get_log("browser") if l.get("level") == "SEVERE"]`,
3706
+ ` assert errors == [], f"console errors: {errors}"`
3707
+ ].join("\n");
3708
+ case "no_failed_requests":
3709
+ return ` # no_failed_requests \u2014 selenium has no built-in network capture. Enable selenium-wire or BiDi for this.`;
3710
+ case "request_made":
3711
+ return ` # request_made ${JSON.stringify(exp.url_pattern)} \u2014 use selenium-wire (driver.requests) or BiDi`;
3712
+ case "response_status":
3713
+ return ` # response_status ${JSON.stringify(exp.url_pattern)} == ${Number(exp.status)} \u2014 use selenium-wire (driver.requests) or BiDi`;
2270
3714
  default:
2271
3715
  return ` # unsupported expect kind: ${exp.kind}`;
2272
3716
  }
@@ -2298,7 +3742,7 @@ function indent(block, n) {
2298
3742
  // src/tools/composite/visual_diff.ts
2299
3743
  import { existsSync as existsSync2 } from "fs";
2300
3744
  import { readFile as readFile3 } from "fs/promises";
2301
- import { resolve as resolve5 } from "path";
3745
+ import { resolve as resolve6 } from "path";
2302
3746
  import pixelmatch from "pixelmatch";
2303
3747
  import { PNG } from "pngjs";
2304
3748
  var visualDiffTool = {
@@ -2307,7 +3751,11 @@ var visualDiffTool = {
2307
3751
  inputShape: visualDiffShape,
2308
3752
  build(ctx) {
2309
3753
  return safeHandler(async (args) => {
2310
- const { runId, runDir } = await ctx.store.startRun("vdiff");
3754
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3755
+ const { runId, runDir, skill } = await ctx.store.startRun(
3756
+ "vdiff",
3757
+ { skill: "visual-diff" }
3758
+ );
2311
3759
  const session = await ctx.registry.open({
2312
3760
  ...args.open,
2313
3761
  ...args.viewport ? { viewport: args.viewport } : {}
@@ -2326,7 +3774,7 @@ var visualDiffTool = {
2326
3774
  );
2327
3775
  const currentPath = await ctx.store.writeScreenshot(runDir, buf, "current");
2328
3776
  await ctx.store.ensureDir(ctx.store.baselineDir);
2329
- const baselinePath = resolve5(
3777
+ const baselinePath = resolve6(
2330
3778
  ctx.store.baselineDir,
2331
3779
  `${args.baseline_id}.png`
2332
3780
  );
@@ -2336,6 +3784,20 @@ var visualDiffTool = {
2336
3784
  `${args.baseline_id}.png`,
2337
3785
  buf
2338
3786
  );
3787
+ const manifestPath2 = await writeManifest({
3788
+ runDir,
3789
+ skill,
3790
+ phase: "verify",
3791
+ status: "pass",
3792
+ summary: `baseline "${args.baseline_id}" seeded from current capture`,
3793
+ startedAt,
3794
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3795
+ artifacts: [
3796
+ { type: "baseline", path: baselinePath },
3797
+ { type: "screenshot", path: currentPath }
3798
+ ],
3799
+ metadata: { baseline_id: args.baseline_id, seeded: true }
3800
+ });
2339
3801
  return ok({
2340
3802
  run_id: runId,
2341
3803
  baseline_id: args.baseline_id,
@@ -2343,6 +3805,7 @@ var visualDiffTool = {
2343
3805
  passed: true,
2344
3806
  baseline_path: baselinePath,
2345
3807
  current_path: currentPath,
3808
+ ...manifestPath2 ? { manifest: manifestPath2 } : {},
2346
3809
  note: "Baseline did not exist \u2014 current capture saved as the new baseline."
2347
3810
  });
2348
3811
  }
@@ -2373,21 +3836,45 @@ var visualDiffTool = {
2373
3836
  );
2374
3837
  const total = baseline.width * baseline.height;
2375
3838
  const diffPct = diffPixels / total;
3839
+ const passed = diffPct <= args.threshold_pct;
2376
3840
  const diffImagePath = await ctx.store.writeBytes(
2377
3841
  runDir,
2378
3842
  "diff.png",
2379
3843
  PNG.sync.write(diff)
2380
3844
  );
3845
+ const artifacts = [
3846
+ { type: "baseline", path: baselinePath },
3847
+ { type: "screenshot", path: currentPath },
3848
+ { type: "diff", path: diffImagePath }
3849
+ ];
3850
+ const manifestPath = await writeManifest({
3851
+ runDir,
3852
+ skill,
3853
+ phase: "verify",
3854
+ status: passed ? "pass" : "fail",
3855
+ summary: `diff ${(diffPct * 100).toFixed(3)}% vs baseline "${args.baseline_id}" (threshold ${(args.threshold_pct * 100).toFixed(3)}%)`,
3856
+ startedAt,
3857
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3858
+ artifacts,
3859
+ metadata: {
3860
+ baseline_id: args.baseline_id,
3861
+ diff_pct: Number(diffPct.toFixed(6)),
3862
+ diff_pixels: diffPixels,
3863
+ total_pixels: total,
3864
+ threshold_pct: args.threshold_pct
3865
+ }
3866
+ });
2381
3867
  return ok({
2382
3868
  run_id: runId,
2383
3869
  baseline_id: args.baseline_id,
2384
3870
  diff_pct: Number(diffPct.toFixed(6)),
2385
3871
  diff_pixels: diffPixels,
2386
3872
  total_pixels: total,
2387
- passed: diffPct <= args.threshold_pct,
3873
+ passed,
2388
3874
  baseline_path: baselinePath,
2389
3875
  current_path: currentPath,
2390
- diff_image_path: diffImagePath
3876
+ diff_image_path: diffImagePath,
3877
+ ...manifestPath ? { manifest: manifestPath } : {}
2391
3878
  });
2392
3879
  } finally {
2393
3880
  if (args.close_on_finish) {
@@ -2544,13 +4031,131 @@ var toolMetadata = {
2544
4031
  readOnlyHint: true,
2545
4032
  openWorldHint: true
2546
4033
  }
4034
+ },
4035
+ // ---------- v0.5 atomic additions ----------
4036
+ [ToolNames.browserHover]: {
4037
+ title: "Hover Element",
4038
+ annotations: {
4039
+ title: "Hover Element",
4040
+ readOnlyHint: false,
4041
+ destructiveHint: false,
4042
+ idempotentHint: true,
4043
+ openWorldHint: true
4044
+ }
4045
+ },
4046
+ [ToolNames.browserDrag]: {
4047
+ title: "Drag Element",
4048
+ annotations: {
4049
+ title: "Drag Element",
4050
+ readOnlyHint: false,
4051
+ destructiveHint: true,
4052
+ idempotentHint: false,
4053
+ openWorldHint: true
4054
+ }
4055
+ },
4056
+ [ToolNames.browserFillForm]: {
4057
+ title: "Fill Form (Batch)",
4058
+ annotations: {
4059
+ title: "Fill Form (Batch)",
4060
+ readOnlyHint: false,
4061
+ destructiveHint: true,
4062
+ idempotentHint: false,
4063
+ openWorldHint: true
4064
+ }
4065
+ },
4066
+ [ToolNames.browserUploadFile]: {
4067
+ title: "Upload File",
4068
+ annotations: {
4069
+ title: "Upload File",
4070
+ readOnlyHint: false,
4071
+ destructiveHint: true,
4072
+ idempotentHint: false,
4073
+ openWorldHint: true
4074
+ }
4075
+ },
4076
+ [ToolNames.browserHandleDialog]: {
4077
+ title: "Pre-arm Dialog Handler",
4078
+ annotations: {
4079
+ title: "Pre-arm Dialog Handler",
4080
+ readOnlyHint: false,
4081
+ destructiveHint: true,
4082
+ idempotentHint: false,
4083
+ openWorldHint: false
4084
+ }
4085
+ },
4086
+ [ToolNames.browserConsole]: {
4087
+ title: "Inspect Console Logs",
4088
+ annotations: {
4089
+ title: "Inspect Console Logs",
4090
+ readOnlyHint: true,
4091
+ openWorldHint: false
4092
+ }
4093
+ },
4094
+ [ToolNames.browserNetwork]: {
4095
+ title: "Inspect Network Requests",
4096
+ annotations: {
4097
+ title: "Inspect Network Requests",
4098
+ readOnlyHint: true,
4099
+ openWorldHint: false
4100
+ }
4101
+ },
4102
+ [ToolNames.browserSetEnv]: {
4103
+ title: "Set Browser Environment",
4104
+ annotations: {
4105
+ title: "Set Browser Environment",
4106
+ readOnlyHint: false,
4107
+ destructiveHint: true,
4108
+ idempotentHint: true,
4109
+ openWorldHint: false
4110
+ }
4111
+ },
4112
+ [ToolNames.browserEvaluate]: {
4113
+ title: "Evaluate JavaScript (gated; arbitrary code execution)",
4114
+ annotations: {
4115
+ title: "Evaluate JavaScript",
4116
+ // Arbitrary code execution in the page context. Gated by
4117
+ // ROLEPOD_ALLOW_EVAL=1 server-side. Always treat as destructive.
4118
+ readOnlyHint: false,
4119
+ destructiveHint: true,
4120
+ idempotentHint: false,
4121
+ openWorldHint: true
4122
+ }
4123
+ },
4124
+ [ToolNames.browserPages]: {
4125
+ title: "List Open Pages",
4126
+ annotations: {
4127
+ title: "List Open Pages",
4128
+ readOnlyHint: true,
4129
+ openWorldHint: false
4130
+ }
4131
+ },
4132
+ [ToolNames.browserSwitchPage]: {
4133
+ title: "Switch Active Page",
4134
+ annotations: {
4135
+ title: "Switch Active Page",
4136
+ readOnlyHint: false,
4137
+ destructiveHint: false,
4138
+ idempotentHint: true,
4139
+ openWorldHint: false
4140
+ }
2547
4141
  }
2548
4142
  };
2549
4143
 
2550
4144
  // src/server.ts
2551
4145
  var SERVER_NAME = "rolepod-uiproof";
2552
- var SERVER_VERSION = "0.4.1";
4146
+ var SERVER_VERSION = "0.6.0";
4147
+ var SUPPORTED_PROTOCOL = "v1";
4148
+ function checkProtocolCompat() {
4149
+ const requested = process.env.ROLEPOD_PROTOCOL;
4150
+ if (!requested) return;
4151
+ if (requested !== SUPPORTED_PROTOCOL) {
4152
+ console.warn(
4153
+ `rolepod protocol mismatch: expected ${SUPPORTED_PROTOCOL}, got ${requested}. Manifest will still be written in ${SUPPORTED_PROTOCOL} shape \u2014 parent may not parse it correctly.`
4154
+ );
4155
+ }
4156
+ }
2553
4157
  function buildServer(opts = {}) {
4158
+ checkProtocolCompat();
2554
4159
  const webEngine = createWebEngine();
2555
4160
  const registry = new SessionRegistry({ idleTimeoutMs: opts.idleTimeoutMs });
2556
4161
  registry.register("web", webEngine);
@@ -2566,7 +4171,7 @@ function buildServer(opts = {}) {
2566
4171
  version: SERVER_VERSION
2567
4172
  });
2568
4173
  const tools = [
2569
- // atomic
4174
+ // atomic (v0.1-v0.4)
2570
4175
  browserOpenTool,
2571
4176
  browserCloseTool,
2572
4177
  browserSnapshotTool,
@@ -2577,6 +4182,18 @@ function buildServer(opts = {}) {
2577
4182
  browserWaitForTool,
2578
4183
  browserScreenshotTool,
2579
4184
  browserNavigateTool,
4185
+ // atomic (v0.5)
4186
+ browserHoverTool,
4187
+ browserDragTool,
4188
+ browserFillFormTool,
4189
+ browserUploadFileTool,
4190
+ browserHandleDialogTool,
4191
+ browserConsoleTool,
4192
+ browserNetworkTool,
4193
+ browserSetEnvTool,
4194
+ browserEvaluateTool,
4195
+ browserPagesTool,
4196
+ browserSwitchPageTool,
2580
4197
  // composite
2581
4198
  verifyUiFlowTool,
2582
4199
  auditA11yTool,
@@ -2599,6 +4216,8 @@ function buildServer(opts = {}) {
2599
4216
  }
2600
4217
  log.info("rolepod-uiproof server built", {
2601
4218
  version: SERVER_VERSION,
4219
+ protocol: SUPPORTED_PROTOCOL,
4220
+ mode: store.mode,
2602
4221
  tools: tools.map((t) => t.name)
2603
4222
  });
2604
4223
  return {