prowl-tools 0.1.3 → 0.1.4

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.cjs CHANGED
@@ -30,6 +30,15 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
30
30
  mod
31
31
  ));
32
32
 
33
+ // src/types/index.ts
34
+ var SUPPORTED_BROWSER_ENGINES;
35
+ var init_types = __esm({
36
+ "src/types/index.ts"() {
37
+ "use strict";
38
+ SUPPORTED_BROWSER_ENGINES = ["chromium", "firefox", "webkit"];
39
+ }
40
+ });
41
+
33
42
  // src/config/hunt-name.ts
34
43
  function isValidHuntName(name) {
35
44
  return HUNT_NAME_PATTERN.test(name);
@@ -50,21 +59,29 @@ var init_hunt_name = __esm({
50
59
  });
51
60
 
52
61
  // src/config/schema.ts
53
- var import_zod, configSchema, navigateStepSchema, clickStepSchema, singleKeyValueSchema, fillStepSchema, typeStepSchema, pressStepSchema, waitForSelectorStepSchema, waitStepSchema, waitForUrlStepSchema, waitForNetworkIdleStepSchema, selectOptionStepSchema, selectStepSchema, onDialogStepSchema, setInputFilesStepSchema, inlineAssertStepSchema, runHuntStepSchema, hoverStepSchema, scrollStepSchema, scrollToStepSchema, screenshotStepSchema, ifStepSchema, repeatStepSchema, mockRouteStepSchema, unmockRouteStepSchema, evalScriptStepSchema, runScriptStepSchema, assertScreenshotStepSchema, copyTextStepSchema, waitForDownloadStepSchema, stepSchema, assertionSchema, huntSchema;
62
+ var import_zod, webTargetSchema, macosTargetSchema, targetSchema, configSchema, navigateStepSchema, clickStepSchema, singleKeyValueSchema, fillStepSchema, typeStepSchema, pressStepSchema, waitForSelectorStepSchema, waitStepSchema, waitForUrlStepSchema, waitForNetworkIdleStepSchema, selectOptionStepSchema, selectStepSchema, onDialogStepSchema, setInputFilesStepSchema, inlineAssertStepSchema, runHuntStepSchema, hoverStepSchema, scrollStepSchema, scrollToStepSchema, screenshotStepSchema, ifStepSchema, repeatStepSchema, mockRouteStepSchema, unmockRouteStepSchema, evalScriptStepSchema, runScriptStepSchema, assertScreenshotStepSchema, copyTextStepSchema, waitForDownloadStepSchema, stepSchema, assertionSchema, huntSchema;
54
63
  var init_schema = __esm({
55
64
  "src/config/schema.ts"() {
56
65
  "use strict";
57
66
  import_zod = require("zod");
67
+ init_types();
58
68
  init_hunt_name();
69
+ webTargetSchema = import_zod.z.object({
70
+ type: import_zod.z.literal("web").optional(),
71
+ url: import_zod.z.string().min(1)
72
+ }).strict();
73
+ macosTargetSchema = import_zod.z.object({
74
+ type: import_zod.z.literal("macos"),
75
+ app: import_zod.z.string().min(1)
76
+ }).strict();
77
+ targetSchema = import_zod.z.union([macosTargetSchema, webTargetSchema]);
59
78
  configSchema = import_zod.z.object({
60
- target: import_zod.z.object({
61
- url: import_zod.z.string().min(1)
62
- }),
79
+ target: targetSchema,
63
80
  browser: import_zod.z.object({
64
81
  headless: import_zod.z.boolean().optional(),
65
82
  slowMo: import_zod.z.number().optional(),
66
83
  timeout: import_zod.z.number().optional(),
67
- engine: import_zod.z.enum(["chromium", "firefox", "webkit"]).optional(),
84
+ engine: import_zod.z.enum(SUPPORTED_BROWSER_ENGINES).optional(),
68
85
  channel: import_zod.z.enum([
69
86
  "chromium",
70
87
  "chrome",
@@ -96,6 +113,7 @@ var init_schema = __esm({
96
113
  guardrails: import_zod.z.object({
97
114
  maxSteps: import_zod.z.number().optional(),
98
115
  allowedDomains: import_zod.z.array(import_zod.z.string()).optional(),
116
+ allowedApps: import_zod.z.array(import_zod.z.string()).optional(),
99
117
  forbiddenSelectors: import_zod.z.array(import_zod.z.string()).optional(),
100
118
  selfHealing: import_zod.z.boolean().optional()
101
119
  }).optional(),
@@ -398,11 +416,18 @@ function resolveViewport(value) {
398
416
  }
399
417
  return value;
400
418
  }
419
+ function resolveTarget(target) {
420
+ if (target && target.type === "macos") {
421
+ return { type: "macos", app: target.app };
422
+ }
423
+ return {
424
+ type: "web",
425
+ url: target?.url ?? DEFAULT_WEB_URL
426
+ };
427
+ }
401
428
  function mergeConfig(partial) {
402
429
  return {
403
- target: {
404
- url: partial.target?.url ?? DEFAULT_CONFIG.target.url
405
- },
430
+ target: resolveTarget(partial.target),
406
431
  browser: {
407
432
  headless: partial.browser?.headless ?? DEFAULT_CONFIG.browser.headless,
408
433
  slowMo: partial.browser?.slowMo ?? DEFAULT_CONFIG.browser.slowMo,
@@ -426,6 +451,7 @@ function mergeConfig(partial) {
426
451
  guardrails: {
427
452
  maxSteps: partial.guardrails?.maxSteps ?? DEFAULT_CONFIG.guardrails.maxSteps,
428
453
  allowedDomains: partial.guardrails?.allowedDomains ?? DEFAULT_CONFIG.guardrails.allowedDomains,
454
+ allowedApps: partial.guardrails?.allowedApps ?? DEFAULT_CONFIG.guardrails.allowedApps,
429
455
  forbiddenSelectors: partial.guardrails?.forbiddenSelectors ?? DEFAULT_CONFIG.guardrails.forbiddenSelectors,
430
456
  selfHealing: partial.guardrails?.selfHealing ?? DEFAULT_CONFIG.guardrails.selfHealing
431
457
  },
@@ -468,10 +494,12 @@ function loadConfig(configPath) {
468
494
  const parsed = import_yaml.default.parse(raw) ?? {};
469
495
  const validated = configSchema.parse(parsed);
470
496
  const config = mergeConfig(validated);
471
- config.guardrails.allowedDomains = ensureAllowedDomain(
472
- config.guardrails.allowedDomains,
473
- config.target.url
474
- );
497
+ if (config.target.type === "web") {
498
+ config.guardrails.allowedDomains = ensureAllowedDomain(
499
+ config.guardrails.allowedDomains,
500
+ config.target.url
501
+ );
502
+ }
475
503
  return { config, configPath: resolvedPath, configDir };
476
504
  }
477
505
  function loadHunt(huntName, configDir) {
@@ -533,7 +561,7 @@ function listHunts(configDir) {
533
561
  scanDir(huntsDir);
534
562
  return results.sort((a, b) => a.localeCompare(b));
535
563
  }
536
- var import_node_fs, import_node_path, import_yaml, import_dotenv, DEFAULT_CONFIG, CONFIG_DIR, LEGACY_CONFIG_DIR, legacyDirWarned, VIEWPORT_PRESETS;
564
+ var import_node_fs, import_node_path, import_yaml, import_dotenv, DEFAULT_WEB_URL, DEFAULT_CONFIG, CONFIG_DIR, LEGACY_CONFIG_DIR, legacyDirWarned, VIEWPORT_PRESETS;
537
565
  var init_loader = __esm({
538
566
  "src/config/loader.ts"() {
539
567
  "use strict";
@@ -543,9 +571,11 @@ var init_loader = __esm({
543
571
  import_dotenv = __toESM(require("dotenv"), 1);
544
572
  init_schema();
545
573
  init_hunt_name();
574
+ DEFAULT_WEB_URL = "http://localhost:3000";
546
575
  DEFAULT_CONFIG = {
547
576
  target: {
548
- url: "http://localhost:3000"
577
+ type: "web",
578
+ url: DEFAULT_WEB_URL
549
579
  },
550
580
  browser: {
551
581
  headless: true,
@@ -569,6 +599,7 @@ var init_loader = __esm({
569
599
  guardrails: {
570
600
  maxSteps: 50,
571
601
  allowedDomains: ["localhost", "127.0.0.1", "0.0.0.0"],
602
+ allowedApps: [],
572
603
  forbiddenSelectors: ["[data-danger]", ".delete-btn"],
573
604
  selfHealing: false
574
605
  },
@@ -597,12 +628,12 @@ __export(visual_exports, {
597
628
  ensureBaselineDir: () => ensureBaselineDir
598
629
  });
599
630
  async function compareScreenshots(baselinePath, currentPath, diffPath, threshold) {
600
- const baselineData = import_pngjs.PNG.sync.read(import_node_fs3.default.readFileSync(baselinePath));
601
- const currentData = import_pngjs.PNG.sync.read(import_node_fs3.default.readFileSync(currentPath));
631
+ const baselineData = import_pngjs.PNG.sync.read(import_node_fs5.default.readFileSync(baselinePath));
632
+ const currentData = import_pngjs.PNG.sync.read(import_node_fs5.default.readFileSync(currentPath));
602
633
  const { width, height } = baselineData;
603
634
  if (currentData.width !== width || currentData.height !== height) {
604
635
  const diff2 = new import_pngjs.PNG({ width, height });
605
- import_node_fs3.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
636
+ import_node_fs5.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
606
637
  return {
607
638
  match: false,
608
639
  diffPercentage: 1,
@@ -620,7 +651,7 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
620
651
  );
621
652
  const totalPixels = width * height;
622
653
  const diffPercentage = diffPixels / totalPixels;
623
- import_node_fs3.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
654
+ import_node_fs5.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
624
655
  return {
625
656
  match: diffPercentage <= threshold,
626
657
  diffPercentage,
@@ -628,16 +659,16 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
628
659
  };
629
660
  }
630
661
  function ensureBaselineDir(configDir) {
631
- const baselineDir = import_node_path3.default.join(configDir, "baselines");
632
- import_node_fs3.default.mkdirSync(baselineDir, { recursive: true });
662
+ const baselineDir = import_node_path5.default.join(configDir, "baselines");
663
+ import_node_fs5.default.mkdirSync(baselineDir, { recursive: true });
633
664
  return baselineDir;
634
665
  }
635
- var import_node_fs3, import_node_path3, import_pngjs, import_pixelmatch;
666
+ var import_node_fs5, import_node_path5, import_pngjs, import_pixelmatch;
636
667
  var init_visual = __esm({
637
668
  "src/runner/visual.ts"() {
638
669
  "use strict";
639
- import_node_fs3 = __toESM(require("fs"), 1);
640
- import_node_path3 = __toESM(require("path"), 1);
670
+ import_node_fs5 = __toESM(require("fs"), 1);
671
+ import_node_path5 = __toESM(require("path"), 1);
641
672
  import_pngjs = require("pngjs");
642
673
  import_pixelmatch = __toESM(require("pixelmatch"), 1);
643
674
  }
@@ -649,11 +680,11 @@ var import_commander13 = require("commander");
649
680
  // package.json
650
681
  var package_default = {
651
682
  name: "prowl-tools",
652
- version: "0.1.3",
683
+ version: "0.1.4",
653
684
  description: "CLI-first QA testing tool for deterministic Playwright flows.",
654
685
  type: "module",
655
686
  license: "Apache-2.0",
656
- author: "Michael Tookes",
687
+ author: "Prowl Tools",
657
688
  repository: {
658
689
  type: "git",
659
690
  url: "https://github.com/prowl-tools/prowl.git"
@@ -736,8 +767,8 @@ var import_commander = require("commander");
736
767
  var import_chalk3 = __toESM(require("chalk"), 1);
737
768
 
738
769
  // src/runner/index.ts
739
- var import_node_fs9 = __toESM(require("fs"), 1);
740
- var import_node_path9 = __toESM(require("path"), 1);
770
+ var import_node_fs11 = __toESM(require("fs"), 1);
771
+ var import_node_path11 = __toESM(require("path"), 1);
741
772
  init_loader();
742
773
 
743
774
  // src/config/interpolate.ts
@@ -785,7 +816,7 @@ function generateRandomVars(randomSource) {
785
816
  const hex = randomBytes(4).toString("hex");
786
817
  const firstIndex = Math.floor(random() * RANDOM_FIRST_NAMES.length);
787
818
  const lastIndex = Math.floor(random() * RANDOM_LAST_NAMES.length);
788
- const num = Math.floor(random() * 9e3) + 1e3;
819
+ const num2 = Math.floor(random() * 9e3) + 1e3;
789
820
  const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
790
821
  let text = "";
791
822
  for (let i = 0; i < 8; i++) {
@@ -794,7 +825,7 @@ function generateRandomVars(randomSource) {
794
825
  return {
795
826
  RANDOM_EMAIL: `prowl_${hex}@test.com`,
796
827
  RANDOM_NAME: `${RANDOM_FIRST_NAMES[firstIndex]} ${RANDOM_LAST_NAMES[lastIndex]}`,
797
- RANDOM_NUMBER: String(num),
828
+ RANDOM_NUMBER: String(num2),
798
829
  RANDOM_UUID: randomUUID(),
799
830
  RANDOM_TEXT: text
800
831
  };
@@ -1106,255 +1137,943 @@ function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
1106
1137
  };
1107
1138
  }
1108
1139
 
1109
- // src/browser/controller.ts
1140
+ // src/config/target.ts
1141
+ var import_node_child_process = require("child_process");
1110
1142
  var import_node_fs2 = __toESM(require("fs"), 1);
1111
1143
  var import_node_path2 = __toESM(require("path"), 1);
1112
- var import_playwright = require("playwright");
1113
- var ENGINES = { chromium: import_playwright.chromium, firefox: import_playwright.firefox, webkit: import_playwright.webkit };
1114
- async function launchBrowser(options) {
1115
- const engine = ENGINES[options.engine ?? "chromium"];
1116
- const browser = await engine.launch({
1117
- headless: options.headless,
1118
- slowMo: options.slowMo,
1119
- channel: options.channel
1120
- });
1121
- const contextOptions = {};
1122
- if (options.viewport) {
1123
- contextOptions.viewport = options.viewport;
1144
+ var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
1145
+ "navigate",
1146
+ "waitForUrl",
1147
+ "waitForNetworkIdle",
1148
+ "mockRoute",
1149
+ "unmockRoute",
1150
+ "evalScript",
1151
+ "runScript",
1152
+ "onDialog",
1153
+ "select",
1154
+ "selectOption",
1155
+ "setInputFiles",
1156
+ "waitForDownload",
1157
+ "scroll"
1158
+ // directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
1159
+ ]);
1160
+ function webOnlyReason(step) {
1161
+ for (const type of WEB_ONLY_STEP_TYPES) {
1162
+ if (type in step) {
1163
+ return type;
1164
+ }
1124
1165
  }
1125
- if (options.storageStatePath) {
1126
- if (import_node_fs2.default.existsSync(options.storageStatePath)) {
1127
- contextOptions.storageState = options.storageStatePath;
1128
- } else {
1129
- console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
1166
+ if ("assert" in step) {
1167
+ const assertion = step.assert;
1168
+ if (assertion.urlIncludes !== void 0 || assertion.urlEquals !== void 0) {
1169
+ return "assert (url)";
1130
1170
  }
1131
1171
  }
1132
- if (options.recordHar) {
1133
- contextOptions.recordHar = { path: import_node_path2.default.join(options.runDir, "network.har") };
1172
+ return null;
1173
+ }
1174
+ function assertStepsSupportedByTarget(steps, target) {
1175
+ if (target !== "macos") {
1176
+ return;
1134
1177
  }
1135
- const context = await browser.newContext(contextOptions);
1136
- const page = await context.newPage();
1137
- page.setDefaultTimeout(options.timeout);
1138
- page.setDefaultNavigationTimeout(options.timeout);
1139
- let tracePath;
1140
- if (options.trace) {
1141
- tracePath = import_node_path2.default.join(options.runDir, "trace.zip");
1142
- await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
1178
+ for (const step of steps) {
1179
+ const reason = webOnlyReason(step);
1180
+ if (reason) {
1181
+ throw new Error(
1182
+ `Step "${reason}" is not supported by the macOS target. It is web-only; use a portable step (click, fill, type, press, wait, assert visible, screenshot, etc.).`
1183
+ );
1184
+ }
1185
+ if ("if" in step) {
1186
+ assertStepsSupportedByTarget(step.if.then, target);
1187
+ if (step.if.else) {
1188
+ assertStepsSupportedByTarget(step.if.else, target);
1189
+ }
1190
+ }
1191
+ if ("repeat" in step) {
1192
+ assertStepsSupportedByTarget(step.repeat.steps, target);
1193
+ }
1143
1194
  }
1144
- return { browser, context, page, tracePath };
1145
1195
  }
1146
- async function closeBrowser(session) {
1147
- if (session.tracePath) {
1148
- await session.context.tracing.stop({ path: session.tracePath });
1196
+ function assertHuntAssertionsSupportedByTarget(assertions, target) {
1197
+ if (target !== "macos" || !assertions || assertions.length === 0) {
1198
+ return;
1149
1199
  }
1150
- await session.context.close();
1151
- await session.browser.close();
1200
+ throw new Error(
1201
+ "Hunt-level assertions are not supported by the macOS target. Use inline assert visible/notVisible steps instead."
1202
+ );
1152
1203
  }
1153
-
1154
- // src/runner/steps.ts
1155
- var import_node_fs4 = __toESM(require("fs"), 1);
1156
- var import_node_path4 = __toESM(require("path"), 1);
1157
-
1158
- // src/browser/actions.ts
1159
- async function clickElement(page, selector) {
1160
- await page.locator(selector).click();
1204
+ function trimTrailingPathSeparators(value) {
1205
+ return value.replace(/[\\/]+$/g, "");
1161
1206
  }
1162
- async function fillElement(page, selector, value) {
1163
- await page.locator(selector).fill(value);
1207
+ function looksLikeMacosAppPath(app) {
1208
+ const trimmed = trimTrailingPathSeparators(app);
1209
+ return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
1164
1210
  }
1165
- async function pressKey(page, selector, key) {
1166
- await page.locator(selector).press(key);
1211
+ function normalizeAppPath(app) {
1212
+ return import_node_path2.default.resolve(trimTrailingPathSeparators(app));
1167
1213
  }
1168
- async function selectOption(page, selector, value) {
1169
- await page.locator(selector).selectOption(value);
1214
+ function parseBundleIdentifier(plist) {
1215
+ const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
1216
+ return match?.[1]?.trim() || null;
1170
1217
  }
1171
- function setupDialogHandler(page, action) {
1172
- page.once("dialog", async (dialog) => {
1173
- if (action === "accept") {
1174
- await dialog.accept();
1175
- } else {
1176
- await dialog.dismiss();
1218
+ function readBundleIdentifier(appPath) {
1219
+ const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), "Contents", "Info.plist");
1220
+ if (!import_node_fs2.default.existsSync(infoPlistPath)) {
1221
+ return null;
1222
+ }
1223
+ try {
1224
+ const parsed = parseBundleIdentifier(import_node_fs2.default.readFileSync(infoPlistPath, "utf-8"));
1225
+ if (parsed) {
1226
+ return parsed;
1177
1227
  }
1178
- });
1179
- }
1180
- async function setInputFiles(page, selector, files) {
1181
- await page.locator(selector).setInputFiles(files);
1182
- }
1183
-
1184
- // src/runner/steps.ts
1185
- init_loader();
1186
-
1187
- // src/runner/healing.ts
1188
- var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
1189
- function extractSelectorIntent(selector) {
1190
- const raw = [];
1191
- for (const match of selector.matchAll(/[#.]([A-Za-z_][\w-]*)/g)) {
1192
- raw.push(match[1]);
1228
+ } catch {
1193
1229
  }
1194
- for (const match of selector.matchAll(/\[[A-Za-z_:-]+\s*[~|^$*]?=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\]/g)) {
1195
- const value = match[1] ?? match[2] ?? match[3];
1196
- if (value) raw.push(value);
1230
+ if (process.platform !== "darwin" || !import_node_fs2.default.existsSync("/usr/bin/plutil")) {
1231
+ return null;
1197
1232
  }
1198
- const words = [];
1199
- for (const token of raw) {
1200
- for (const part of splitToken(token)) {
1201
- const lower = part.toLowerCase();
1202
- if (lower.length > 0 && !words.includes(lower)) {
1203
- words.push(lower);
1204
- }
1233
+ try {
1234
+ const output = (0, import_node_child_process.execFileSync)(
1235
+ "/usr/bin/plutil",
1236
+ ["-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlistPath],
1237
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1e3 }
1238
+ );
1239
+ return output.trim() || null;
1240
+ } catch {
1241
+ return null;
1242
+ }
1243
+ }
1244
+ function macosAppAllowedIdentities(app) {
1245
+ const identities = /* @__PURE__ */ new Set([app]);
1246
+ if (looksLikeMacosAppPath(app)) {
1247
+ const normalizedPath = normalizeAppPath(app);
1248
+ identities.add(trimTrailingPathSeparators(app));
1249
+ identities.add(normalizedPath);
1250
+ const bundleName = import_node_path2.default.basename(normalizedPath).replace(/\.app$/i, "");
1251
+ if (bundleName) {
1252
+ identities.add(bundleName);
1253
+ }
1254
+ const bundleId = readBundleIdentifier(app);
1255
+ if (bundleId) {
1256
+ identities.add(bundleId);
1205
1257
  }
1206
1258
  }
1207
- return { words, label: words.join(" ") };
1208
- }
1209
- function splitToken(token) {
1210
- return token.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[\s\-_.:]+/).filter((part) => part.length > 0);
1259
+ return [...identities];
1211
1260
  }
1212
- function buildHealCandidates(selector) {
1213
- const { words, label } = extractSelectorIntent(selector);
1214
- if (words.length === 0) return [];
1215
- const escaped = label.replace(/"/g, '\\"');
1216
- const candidates = [];
1217
- candidates.push({ selector: `text=${label}`, strategy: "text" });
1218
- candidates.push({ selector: `[aria-label*="${escaped}" i]`, strategy: "aria" });
1219
- for (const tag of INTERACTIVE_TAGS) {
1220
- candidates.push({ selector: `${tag}:has-text("${escaped}")`, strategy: "structural" });
1261
+ function assertTargetAppAllowed(allowedApps, app) {
1262
+ if (allowedApps.length === 0) {
1263
+ return;
1221
1264
  }
1222
- return candidates;
1265
+ const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => macosAppAllowedIdentities(allowedApp)));
1266
+ if (macosAppAllowedIdentities(app).some((identity) => allowedIdentities.has(identity))) {
1267
+ return;
1268
+ }
1269
+ throw new Error(
1270
+ `Target app "${app}" is not in guardrails.allowedApps (${allowedApps.join(", ")}).`
1271
+ );
1223
1272
  }
1224
- async function healSelector(page, selector, options) {
1225
- if (!options.enabled) return null;
1226
- for (const candidate of buildHealCandidates(selector)) {
1227
- let count;
1273
+
1274
+ // src/browser/playwright-driver.ts
1275
+ var import_node_fs3 = __toESM(require("fs"), 1);
1276
+ var import_node_path3 = __toESM(require("path"), 1);
1277
+ var import_playwright = require("playwright");
1278
+ var ENGINES = { chromium: import_playwright.chromium, firefox: import_playwright.firefox, webkit: import_playwright.webkit };
1279
+ async function launchBrowser(options) {
1280
+ const engineName = options.engine ?? "chromium";
1281
+ const engine = Object.prototype.hasOwnProperty.call(ENGINES, engineName) ? ENGINES[engineName] : void 0;
1282
+ if (!engine) {
1283
+ throw new Error(
1284
+ `Unsupported browser engine "${String(engineName)}". Available engines: ${Object.keys(ENGINES).join(", ")}.`
1285
+ );
1286
+ }
1287
+ const browser = await engine.launch({
1288
+ headless: options.headless,
1289
+ slowMo: options.slowMo,
1290
+ channel: options.channel
1291
+ });
1292
+ try {
1293
+ const contextOptions = {};
1294
+ if (options.viewport) {
1295
+ contextOptions.viewport = options.viewport;
1296
+ }
1297
+ if (options.storageStatePath) {
1298
+ if (import_node_fs3.default.existsSync(options.storageStatePath)) {
1299
+ contextOptions.storageState = options.storageStatePath;
1300
+ } else {
1301
+ console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
1302
+ }
1303
+ }
1304
+ if (options.recordHar) {
1305
+ contextOptions.recordHar = { path: import_node_path3.default.join(options.runDir, "network.har") };
1306
+ }
1307
+ const context = await browser.newContext(contextOptions);
1308
+ const page = await context.newPage();
1309
+ page.setDefaultTimeout(options.timeout);
1310
+ page.setDefaultNavigationTimeout(options.timeout);
1311
+ let tracePath;
1312
+ if (options.trace) {
1313
+ tracePath = import_node_path3.default.join(options.runDir, "trace.zip");
1314
+ await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
1315
+ }
1316
+ return { browser, context, page, tracePath };
1317
+ } catch (error) {
1228
1318
  try {
1229
- const locator = page.locator(candidate.selector);
1230
- count = await locator.count();
1231
- } catch {
1232
- continue;
1319
+ await browser.close();
1320
+ } catch (closeError) {
1321
+ console.warn(`Failed to close browser after setup error: ${formatError(closeError)}`);
1233
1322
  }
1234
- if (count === 1) {
1235
- return { selector: candidate.selector, healedFrom: selector, strategy: candidate.strategy };
1323
+ throw error;
1324
+ }
1325
+ }
1326
+ async function closeBrowser(session) {
1327
+ try {
1328
+ if (session.tracePath) {
1329
+ await session.context.tracing.stop({ path: session.tracePath });
1236
1330
  }
1331
+ await session.context.close();
1332
+ } finally {
1333
+ await session.browser.close();
1237
1334
  }
1238
- return null;
1239
1335
  }
1240
-
1241
- // src/runner/steps.ts
1242
- var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
1336
+ async function saveStorageState(session, storageStatePath) {
1337
+ await session.context.storageState({ path: storageStatePath });
1338
+ }
1339
+ var ALL_CAPABILITIES = /* @__PURE__ */ new Set([
1340
+ "navigate",
1341
+ "query",
1342
+ "interact",
1343
+ "wait",
1344
+ "screenshot",
1345
+ "evaluate",
1346
+ "response",
1347
+ "route",
1348
+ "dialog",
1349
+ "files",
1350
+ "download"
1351
+ ]);
1352
+ function formatError(error) {
1353
+ return error instanceof Error ? error.message : String(error);
1354
+ }
1243
1355
  function unwrapTextSelector(value) {
1244
1356
  const trimmed = value.trim();
1245
- if (trimmed.startsWith('text="') && trimmed.endsWith('"')) {
1246
- return trimmed.slice(6, -1);
1247
- }
1248
- if (trimmed.startsWith("text='") && trimmed.endsWith("'")) {
1249
- return trimmed.slice(6, -1);
1357
+ if (!trimmed.startsWith("text=")) {
1358
+ return null;
1250
1359
  }
1251
- if (trimmed.startsWith("text=")) {
1252
- return trimmed.slice(5);
1360
+ const raw = trimmed.slice(5);
1361
+ const first = raw[0];
1362
+ if (first === '"' || first === "'") {
1363
+ const unquoted = raw.slice(1);
1364
+ return unquoted.endsWith(first) ? unquoted.slice(0, -1) : unquoted;
1253
1365
  }
1254
- return null;
1255
- }
1256
- function matchesForbiddenPattern(selector, forbidden) {
1257
- const selectorText = unwrapTextSelector(selector);
1258
- if (selectorText === null) {
1259
- return false;
1260
- }
1261
- const forbiddenText = unwrapTextSelector(forbidden);
1262
- if (forbiddenText !== null) {
1263
- return selectorText.includes(forbiddenText);
1264
- }
1265
- return selectorText.includes(forbidden);
1366
+ return raw;
1266
1367
  }
1267
- function isForbiddenSelector(selector, forbiddenSelectors) {
1268
- return forbiddenSelectors.some(
1269
- (forbidden) => selector.includes(forbidden) || matchesForbiddenPattern(selector, forbidden)
1270
- );
1368
+ function createPlaywrightDriver(page) {
1369
+ return {
1370
+ capabilities: ALL_CAPABILITIES,
1371
+ async goto(url, options) {
1372
+ if (options?.waitUntil !== void 0) {
1373
+ await page.goto(url, { waitUntil: options.waitUntil });
1374
+ } else {
1375
+ await page.goto(url);
1376
+ }
1377
+ },
1378
+ currentUrl() {
1379
+ return page.url();
1380
+ },
1381
+ count(selector) {
1382
+ return page.locator(selector).count();
1383
+ },
1384
+ textContent(selector) {
1385
+ return page.locator(selector).textContent();
1386
+ },
1387
+ async click(selector) {
1388
+ await page.locator(selector).click();
1389
+ },
1390
+ async clickFirst(selector) {
1391
+ await page.locator(selector).first().click();
1392
+ },
1393
+ async fill(selector, value) {
1394
+ await page.locator(selector).fill(value);
1395
+ },
1396
+ async fillFirst(selector, value) {
1397
+ await page.locator(selector).first().fill(value);
1398
+ },
1399
+ async press(selector, key) {
1400
+ await page.locator(selector).press(key);
1401
+ },
1402
+ async selectOption(selector, value) {
1403
+ await page.locator(selector).selectOption(value);
1404
+ },
1405
+ async selectOptionFirst(selector, value) {
1406
+ await page.locator(selector).first().selectOption(value);
1407
+ },
1408
+ async hover(selector) {
1409
+ await page.locator(selector).hover();
1410
+ },
1411
+ async scrollIntoView(selector) {
1412
+ await page.locator(selector).scrollIntoViewIfNeeded();
1413
+ },
1414
+ async setInputFiles(selector, files) {
1415
+ await page.locator(selector).setInputFiles(files);
1416
+ },
1417
+ countByRole(role, name) {
1418
+ return page.getByRole(role, { name }).count();
1419
+ },
1420
+ async clickFirstByRole(role, name) {
1421
+ await page.getByRole(role, { name }).first().click();
1422
+ },
1423
+ countByLabel(label) {
1424
+ return page.getByLabel(label, { exact: true }).count();
1425
+ },
1426
+ async fillFirstByLabel(label, value) {
1427
+ await page.getByLabel(label, { exact: true }).first().fill(value);
1428
+ },
1429
+ async selectOptionFirstByLabel(label, value) {
1430
+ await page.getByLabel(label, { exact: true }).first().selectOption(value);
1431
+ },
1432
+ async waitForSelector(selector, options) {
1433
+ await page.waitForSelector(selector, { timeout: options?.timeout });
1434
+ },
1435
+ async waitForUrl(predicate, options) {
1436
+ await page.waitForURL((url) => predicate(url.toString()), { timeout: options?.timeout });
1437
+ },
1438
+ async waitForNetworkIdle(options) {
1439
+ await page.waitForLoadState("networkidle", { timeout: options?.timeout });
1440
+ },
1441
+ evaluate(pageFunction, arg) {
1442
+ const raw = page.evaluate;
1443
+ const result = arg === void 0 ? raw.call(page, pageFunction) : raw.call(page, pageFunction, arg);
1444
+ return result;
1445
+ },
1446
+ async screenshot(options) {
1447
+ await page.screenshot({ path: options.path, fullPage: options.fullPage });
1448
+ },
1449
+ onResponse(handler) {
1450
+ page.on("response", handler);
1451
+ },
1452
+ async route(url, handler) {
1453
+ await page.route(url, async (pwRoute) => {
1454
+ try {
1455
+ await handler({
1456
+ fulfill: (response) => pwRoute.fulfill(response)
1457
+ });
1458
+ } catch (error) {
1459
+ try {
1460
+ await pwRoute.abort("failed");
1461
+ } catch (abortError) {
1462
+ throw new Error(
1463
+ `Route handler failed for ${url}: ${formatError(error)}. Route abort also failed: ${formatError(abortError)}`
1464
+ );
1465
+ }
1466
+ throw new Error(`Route handler failed for ${url}: ${formatError(error)}`);
1467
+ }
1468
+ });
1469
+ },
1470
+ async unroute(url) {
1471
+ await page.unroute(url);
1472
+ },
1473
+ onDialog(action) {
1474
+ page.once("dialog", (dialog) => {
1475
+ const response = action === "accept" ? dialog.accept() : dialog.dismiss();
1476
+ response.catch((error) => {
1477
+ console.warn(`Failed to ${action} dialog: ${formatError(error)}`);
1478
+ });
1479
+ });
1480
+ },
1481
+ waitForDownloadEvent(options) {
1482
+ return page.waitForEvent("download", { timeout: options?.timeout });
1483
+ },
1484
+ parseTextSelector(selector) {
1485
+ return unwrapTextSelector(selector);
1486
+ }
1487
+ };
1271
1488
  }
1272
- function assertAllowedSelector(selector, forbiddenSelectors) {
1273
- if (isForbiddenSelector(selector, forbiddenSelectors)) {
1274
- throw new Error(`Forbidden selector: ${selector}`);
1489
+
1490
+ // src/browser/mac-helper.ts
1491
+ var import_node_child_process2 = require("child_process");
1492
+ var import_node_fs4 = __toESM(require("fs"), 1);
1493
+ var import_node_path4 = __toESM(require("path"), 1);
1494
+ var import_node_url = require("url");
1495
+
1496
+ // src/browser/mac-driver.ts
1497
+ var MAC_CAPABILITIES = /* @__PURE__ */ new Set([
1498
+ "query",
1499
+ "interact",
1500
+ "wait",
1501
+ "screenshot"
1502
+ ]);
1503
+ var STATUS_ITEM_SELECTOR = "statusitem";
1504
+ var MENU_PREFIX = "menu=";
1505
+ function unquote(value) {
1506
+ const trimmed = value.trim();
1507
+ const first = trimmed[0];
1508
+ if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
1509
+ return trimmed.slice(1, -1);
1275
1510
  }
1511
+ return trimmed;
1276
1512
  }
1277
- async function resolveActionSelector(context, selector) {
1278
- assertAllowedSelector(selector, context.forbiddenSelectors);
1279
- if (!context.selfHealing) {
1280
- return { selector };
1513
+ function parseMacSelector(selector) {
1514
+ const trimmed = selector.trim();
1515
+ const idMatch = /^id=(.+)$/s.exec(trimmed);
1516
+ if (idMatch) {
1517
+ return { by: "id", value: unquote(idMatch[1]) };
1281
1518
  }
1282
- let matched = false;
1283
- try {
1284
- matched = await context.page.locator(selector).count() > 0;
1285
- } catch {
1286
- return { selector };
1519
+ const roleMatch = /^role=([A-Za-z][\w-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
1520
+ if (roleMatch) {
1521
+ const name = roleMatch[2] !== void 0 ? unquote(roleMatch[2]) : void 0;
1522
+ return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
1287
1523
  }
1288
- if (matched) {
1289
- return { selector };
1524
+ const labelMatch = /^label=(.+)$/s.exec(trimmed);
1525
+ if (labelMatch) {
1526
+ return { by: "label", value: unquote(labelMatch[1]) };
1290
1527
  }
1291
- const healed = await healSelector(context.page, selector, { enabled: true });
1292
- if (!healed) {
1293
- return { selector };
1528
+ const textMatch = /^text=(.+)$/s.exec(trimmed);
1529
+ if (textMatch) {
1530
+ return { by: "text", value: unquote(textMatch[1]) };
1294
1531
  }
1295
- assertAllowedSelector(healed.selector, context.forbiddenSelectors);
1296
- console.warn(
1297
- `Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
1298
- );
1299
- return { selector: healed.selector, healedFrom: healed.healedFrom };
1532
+ return { by: "text", value: trimmed };
1300
1533
  }
1301
- function assertWithinMaxSteps(stepCount, maxSteps, huntName) {
1302
- if (stepCount > maxSteps) {
1303
- if (huntName) {
1304
- throw new Error(`Hunt "${huntName}" has ${stepCount} steps. Max allowed is ${maxSteps}.`);
1305
- }
1306
- throw new Error(`Hunt has ${stepCount} steps. Max allowed is ${maxSteps}.`);
1534
+ function unwrapMacTextSelector(selector) {
1535
+ const trimmed = selector.trim();
1536
+ if (!trimmed.startsWith("text=")) {
1537
+ return null;
1307
1538
  }
1539
+ return unquote(trimmed.slice(5));
1308
1540
  }
1309
- function getStepType(step) {
1310
- if ("navigate" in step) return "navigate";
1311
- if ("click" in step) return "click";
1312
- if ("fill" in step) return "fill";
1313
- if ("type" in step) return "type";
1314
- if ("selectOption" in step) return "selectOption";
1315
- if ("select" in step) return "select";
1316
- if ("onDialog" in step) return "onDialog";
1317
- if ("setInputFiles" in step) return "setInputFiles";
1318
- if ("runHunt" in step) return "runHunt";
1319
- if ("assert" in step) return "assert";
1320
- if ("press" in step) return "press";
1321
- if ("wait" in step) return "wait";
1322
- if ("waitForSelector" in step) return "waitForSelector";
1323
- if ("waitForUrl" in step) return "waitForUrl";
1324
- if ("waitForNetworkIdle" in step) return "waitForNetworkIdle";
1325
- if ("hover" in step) return "hover";
1326
- if ("scroll" in step) return "scroll";
1327
- if ("scrollTo" in step) return "scrollTo";
1328
- if ("screenshot" in step) return "screenshot";
1329
- if ("if" in step) return "if";
1330
- if ("repeat" in step) return "repeat";
1331
- if ("mockRoute" in step) return "mockRoute";
1332
- if ("unmockRoute" in step) return "unmockRoute";
1333
- if ("evalScript" in step) return "evalScript";
1334
- if ("runScript" in step) return "runScript";
1335
- if ("assertScreenshot" in step) return "assertScreenshot";
1336
- if ("copyText" in step) return "copyText";
1337
- if ("waitForDownload" in step) return "waitForDownload";
1338
- return "step";
1339
- }
1340
- var RUNTIME_VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
1341
- function substituteRuntimeVars(input, vars) {
1342
- return input.replace(RUNTIME_VAR_PATTERN, (match, name) => {
1343
- const value = vars.get(name);
1344
- return value !== void 0 ? value : match;
1345
- });
1541
+ function num(value) {
1542
+ return typeof value === "number" ? value : Number(value ?? 0);
1346
1543
  }
1347
- function applyRuntimeVars(step, vars) {
1348
- const sub = (s) => substituteRuntimeVars(s, vars);
1349
- if ("navigate" in step) return { navigate: sub(step.navigate) };
1350
- if ("click" in step) {
1351
- if (typeof step.click === "string") return { click: sub(step.click) };
1352
- return { click: { selector: sub(step.click.selector) } };
1544
+ function createMacDriver(client, options = {}) {
1545
+ const unsupported = (verb) => new Error(`${verb} is not supported by the macOS target`);
1546
+ const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
1547
+ async function query(cmd, selector, extra) {
1548
+ return client.request(cmd, { query: parseMacSelector(selector), ...extra });
1353
1549
  }
1354
- if ("fill" in step) {
1355
- if ("selector" in step.fill && "value" in step.fill) {
1356
- const f = step.fill;
1357
- return { fill: { selector: sub(f.selector), value: sub(f.value) } };
1550
+ async function clickSelector(selector) {
1551
+ const trimmed = selector.trim();
1552
+ if (trimmed.toLowerCase() === STATUS_ITEM_SELECTOR) {
1553
+ await client.request("openMenu");
1554
+ return;
1555
+ }
1556
+ if (trimmed.toLowerCase().startsWith(MENU_PREFIX)) {
1557
+ await client.request("clickMenu", { title: trimmed.slice(MENU_PREFIX.length).trim() });
1558
+ return;
1559
+ }
1560
+ await query("click", selector);
1561
+ }
1562
+ async function fillSelector(selector, value) {
1563
+ if (selector.trim() === ":focus") {
1564
+ await client.request("fill", { query: { by: "focused" }, value });
1565
+ return;
1566
+ }
1567
+ await query("fill", selector, { value });
1568
+ }
1569
+ return {
1570
+ capabilities: MAC_CAPABILITIES,
1571
+ // navigation -----------------------------------------------------------
1572
+ goto(_url, _options) {
1573
+ return rejectUnsupported("navigate");
1574
+ },
1575
+ currentUrl() {
1576
+ return `macos:${options.appLabel ?? ""}`;
1577
+ },
1578
+ // queries --------------------------------------------------------------
1579
+ async count(selector) {
1580
+ const result = await query("count", selector);
1581
+ return num(result.count);
1582
+ },
1583
+ async textContent(selector) {
1584
+ const result = await query("text", selector);
1585
+ return result.text === void 0 || result.text === null ? null : String(result.text);
1586
+ },
1587
+ // interactions ---------------------------------------------------------
1588
+ click: clickSelector,
1589
+ clickFirst: clickSelector,
1590
+ fill: fillSelector,
1591
+ fillFirst: fillSelector,
1592
+ async press(selector, key) {
1593
+ await query("press", selector, { key });
1594
+ },
1595
+ selectOption() {
1596
+ return rejectUnsupported("select");
1597
+ },
1598
+ selectOptionFirst() {
1599
+ return rejectUnsupported("select");
1600
+ },
1601
+ async hover(selector) {
1602
+ await query("hover", selector);
1603
+ },
1604
+ async scrollIntoView(selector) {
1605
+ await query("scrollTo", selector);
1606
+ },
1607
+ setInputFiles() {
1608
+ return rejectUnsupported("setInputFiles");
1609
+ },
1610
+ // semantic locators ----------------------------------------------------
1611
+ async countByRole(role, name) {
1612
+ const result = await client.request("count", { query: { by: "role", role, name } });
1613
+ return num(result.count);
1614
+ },
1615
+ async clickFirstByRole(role, name) {
1616
+ await client.request("click", { query: { by: "role", role, name } });
1617
+ },
1618
+ async countByLabel(label) {
1619
+ const result = await client.request("count", { query: { by: "label", value: label } });
1620
+ return num(result.count);
1621
+ },
1622
+ async fillFirstByLabel(label, value) {
1623
+ await client.request("fill", { query: { by: "label", value: label }, value });
1624
+ },
1625
+ selectOptionFirstByLabel() {
1626
+ return rejectUnsupported("select");
1627
+ },
1628
+ // waiting --------------------------------------------------------------
1629
+ async waitForSelector(selector, waitOptions) {
1630
+ const extra = waitOptions?.timeout !== void 0 ? { timeout: waitOptions.timeout / 1e3 } : void 0;
1631
+ await query("waitFor", selector, extra);
1632
+ },
1633
+ waitForUrl() {
1634
+ return rejectUnsupported("waitForUrl");
1635
+ },
1636
+ waitForNetworkIdle() {
1637
+ return rejectUnsupported("waitForNetworkIdle");
1638
+ },
1639
+ // scripting & artifacts ------------------------------------------------
1640
+ evaluate() {
1641
+ return rejectUnsupported("evalScript");
1642
+ },
1643
+ async screenshot(screenshotOptions) {
1644
+ await client.request("screenshot", { path: screenshotOptions.path });
1645
+ },
1646
+ // network / dialogs / downloads (all web-only) -------------------------
1647
+ onResponse(_handler) {
1648
+ throw unsupported("onResponse");
1649
+ },
1650
+ route(_url, _handler) {
1651
+ return rejectUnsupported("mockRoute");
1652
+ },
1653
+ unroute() {
1654
+ return rejectUnsupported("unmockRoute");
1655
+ },
1656
+ onDialog(_action) {
1657
+ throw unsupported("onDialog");
1658
+ },
1659
+ waitForDownloadEvent() {
1660
+ return rejectUnsupported("waitForDownload");
1661
+ },
1662
+ parseTextSelector(selector) {
1663
+ return unwrapMacTextSelector(selector);
1664
+ }
1665
+ };
1666
+ }
1667
+
1668
+ // src/browser/mac-helper.ts
1669
+ var import_meta = {};
1670
+ var HELPER_BINARY = "prowl-macdriver";
1671
+ function macdriverBuildInstructions() {
1672
+ return "The macOS target requires the experimental `prowl-macdriver` helper, which is not shipped in the npm package. Build it locally:\n cd macdriver && swift build -c release\nor point Prowl at a prebuilt binary via the PROWL_MACDRIVER_BIN environment variable.";
1673
+ }
1674
+ function getPackageRoot() {
1675
+ let dir = import_node_path4.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
1676
+ const root = import_node_path4.default.parse(dir).root;
1677
+ while (dir !== root) {
1678
+ if (import_node_fs4.default.existsSync(import_node_path4.default.join(dir, "package.json"))) {
1679
+ return dir;
1680
+ }
1681
+ dir = import_node_path4.default.dirname(dir);
1682
+ }
1683
+ return root;
1684
+ }
1685
+ function resolveHelperBinary(env = process.env) {
1686
+ const override = env.PROWL_MACDRIVER_BIN;
1687
+ if (override) {
1688
+ if (!import_node_fs4.default.existsSync(override)) {
1689
+ throw new Error(
1690
+ `PROWL_MACDRIVER_BIN points at a missing file: ${override}
1691
+ ${macdriverBuildInstructions()}`
1692
+ );
1693
+ }
1694
+ return override;
1695
+ }
1696
+ const root = getPackageRoot();
1697
+ const candidates = [
1698
+ import_node_path4.default.join(root, "macdriver", ".build", "release", HELPER_BINARY),
1699
+ import_node_path4.default.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
1700
+ ];
1701
+ for (const candidate of candidates) {
1702
+ if (import_node_fs4.default.existsSync(candidate)) {
1703
+ return candidate;
1704
+ }
1705
+ }
1706
+ throw new Error(`Could not find the ${HELPER_BINARY} helper binary.
1707
+ ${macdriverBuildInstructions()}`);
1708
+ }
1709
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
1710
+ var SpawnMacHelperClient = class {
1711
+ child;
1712
+ pending = /* @__PURE__ */ new Map();
1713
+ requestTimeoutMs;
1714
+ stdoutBuffer = "";
1715
+ stderrBuffer = "";
1716
+ nextId = 1;
1717
+ closed = false;
1718
+ terminalError;
1719
+ constructor(binaryPath, options = {}) {
1720
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1721
+ this.child = (0, import_node_child_process2.spawn)(binaryPath, ["serve"], { stdio: ["pipe", "pipe", "pipe"] });
1722
+ this.child.stdout?.setEncoding("utf-8");
1723
+ this.child.stderr?.setEncoding("utf-8");
1724
+ this.child.stdout?.on("data", (chunk) => this.onStdout(chunk));
1725
+ this.child.stderr?.on("data", (chunk) => {
1726
+ this.stderrBuffer = (this.stderrBuffer + chunk).slice(-4e3);
1727
+ });
1728
+ this.child.on("error", (error) => this.recordTerminalFailure(error));
1729
+ this.child.on("exit", (code) => {
1730
+ if (!this.closed) {
1731
+ const detail = this.stderrBuffer.trim();
1732
+ this.recordTerminalFailure(
1733
+ new Error(`prowl-macdriver exited unexpectedly (code ${code ?? "null"})${detail ? `: ${detail}` : ""}`)
1734
+ );
1735
+ }
1736
+ });
1737
+ }
1738
+ onStdout(chunk) {
1739
+ this.stdoutBuffer += chunk;
1740
+ let newlineIndex = this.stdoutBuffer.indexOf("\n");
1741
+ while (newlineIndex !== -1) {
1742
+ const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
1743
+ this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
1744
+ if (line.length > 0) {
1745
+ this.dispatch(line);
1746
+ }
1747
+ newlineIndex = this.stdoutBuffer.indexOf("\n");
1748
+ }
1749
+ }
1750
+ dispatch(line) {
1751
+ let message;
1752
+ try {
1753
+ message = JSON.parse(line);
1754
+ } catch {
1755
+ return;
1756
+ }
1757
+ const id = typeof message.id === "number" ? message.id : void 0;
1758
+ if (id === void 0) {
1759
+ return;
1760
+ }
1761
+ const pending = this.pending.get(id);
1762
+ if (!pending) {
1763
+ return;
1764
+ }
1765
+ this.pending.delete(id);
1766
+ clearTimeout(pending.timer);
1767
+ if (message.ok === true) {
1768
+ pending.resolve(message.result ?? {});
1769
+ } else {
1770
+ pending.reject(new Error(typeof message.error === "string" ? message.error : "prowl-macdriver error"));
1771
+ }
1772
+ }
1773
+ failAll(error) {
1774
+ for (const pending of this.pending.values()) {
1775
+ clearTimeout(pending.timer);
1776
+ pending.reject(error);
1777
+ }
1778
+ this.pending.clear();
1779
+ }
1780
+ recordTerminalFailure(error) {
1781
+ this.terminalError ??= error;
1782
+ this.closed = true;
1783
+ this.failAll(this.terminalError);
1784
+ }
1785
+ /** Number of in-flight requests awaiting a response (for teardown/tests). */
1786
+ get pendingCount() {
1787
+ return this.pending.size;
1788
+ }
1789
+ request(cmd, params = {}) {
1790
+ if (this.terminalError) {
1791
+ return Promise.reject(this.terminalError);
1792
+ }
1793
+ if (this.closed) {
1794
+ return Promise.reject(new Error("prowl-macdriver client is closed"));
1795
+ }
1796
+ const id = this.nextId++;
1797
+ const payload = JSON.stringify({ id, cmd, ...params });
1798
+ return new Promise((resolve, reject) => {
1799
+ const timer = setTimeout(() => {
1800
+ if (this.pending.delete(id)) {
1801
+ const shown = this.requestTimeoutMs >= 1e3 ? `${Math.round(this.requestTimeoutMs / 1e3)}s` : `${this.requestTimeoutMs}ms`;
1802
+ reject(new Error(`prowl-macdriver request "${cmd}" timed out after ${shown}`));
1803
+ }
1804
+ }, this.requestTimeoutMs);
1805
+ timer.unref?.();
1806
+ this.pending.set(id, { cmd, resolve, reject, timer });
1807
+ this.child.stdin?.write(payload + "\n", (error) => {
1808
+ if (error && this.pending.delete(id)) {
1809
+ clearTimeout(timer);
1810
+ reject(error);
1811
+ }
1812
+ });
1813
+ });
1814
+ }
1815
+ async close() {
1816
+ if (this.closed) {
1817
+ return;
1818
+ }
1819
+ this.closed = true;
1820
+ try {
1821
+ this.child.stdin?.write(JSON.stringify({ cmd: "shutdown" }) + "\n");
1822
+ this.child.stdin?.end();
1823
+ } catch {
1824
+ }
1825
+ await new Promise((resolve) => {
1826
+ if (this.child.exitCode !== null || this.child.signalCode !== null) {
1827
+ resolve();
1828
+ return;
1829
+ }
1830
+ const timer = setTimeout(() => {
1831
+ this.child.kill("SIGKILL");
1832
+ resolve();
1833
+ }, 2e3);
1834
+ this.child.once("exit", () => {
1835
+ clearTimeout(timer);
1836
+ resolve();
1837
+ });
1838
+ });
1839
+ this.failAll(new Error("prowl-macdriver client is closed"));
1840
+ }
1841
+ };
1842
+ async function launchMacSession(options) {
1843
+ const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_REQUEST_TIMEOUT_MS) + 5e3;
1844
+ const client = options.clientFactory ? options.clientFactory() : new SpawnMacHelperClient(resolveHelperBinary(), { requestTimeoutMs });
1845
+ const timeoutSeconds = (options.timeoutMs ?? 1e4) / 1e3;
1846
+ try {
1847
+ const trust = await client.request("check");
1848
+ if (trust.trusted !== true) {
1849
+ throw new Error(
1850
+ "Prowl's macOS target is not trusted for Accessibility. Grant the hosting terminal/app permission in System Settings \u2192 Privacy & Security \u2192 Accessibility, then retry."
1851
+ );
1852
+ }
1853
+ const launched = await client.request("launch", { app: options.app, timeout: timeoutSeconds });
1854
+ const bundleId = String(launched.bundleId ?? options.app);
1855
+ const driver = createMacDriver(client, { appLabel: bundleId });
1856
+ return { client, driver, bundleId };
1857
+ } catch (error) {
1858
+ await client.close().catch(() => void 0);
1859
+ throw error;
1860
+ }
1861
+ }
1862
+ async function closeMacSession(session) {
1863
+ try {
1864
+ await session.client.request("quit");
1865
+ } catch {
1866
+ } finally {
1867
+ await session.client.close();
1868
+ }
1869
+ }
1870
+
1871
+ // src/runner/steps.ts
1872
+ var import_node_fs6 = __toESM(require("fs"), 1);
1873
+ var import_node_path6 = __toESM(require("path"), 1);
1874
+ init_loader();
1875
+
1876
+ // src/runner/healing.ts
1877
+ var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
1878
+ function extractSelectorIntent(selector) {
1879
+ const raw = [];
1880
+ for (const match of selector.matchAll(/[#.]([A-Za-z_][\w-]*)/g)) {
1881
+ raw.push(match[1]);
1882
+ }
1883
+ for (const match of selector.matchAll(/\[[A-Za-z_:-]+\s*[~|^$*]?=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\]/g)) {
1884
+ const value = match[1] ?? match[2] ?? match[3];
1885
+ if (value) raw.push(value);
1886
+ }
1887
+ const words = [];
1888
+ for (const token of raw) {
1889
+ for (const part of splitToken(token)) {
1890
+ const lower = part.toLowerCase();
1891
+ if (lower.length > 0 && !words.includes(lower)) {
1892
+ words.push(lower);
1893
+ }
1894
+ }
1895
+ }
1896
+ return { words, label: words.join(" ") };
1897
+ }
1898
+ function splitToken(token) {
1899
+ return token.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[\s\-_.:]+/).filter((part) => part.length > 0);
1900
+ }
1901
+ function buildHealCandidates(selector) {
1902
+ const { words, label } = extractSelectorIntent(selector);
1903
+ if (words.length === 0) return [];
1904
+ const escaped = label.replace(/"/g, '\\"');
1905
+ const candidates = [];
1906
+ candidates.push({ selector: `text=${label}`, strategy: "text" });
1907
+ candidates.push({ selector: `[aria-label*="${escaped}" i]`, strategy: "aria" });
1908
+ for (const tag of INTERACTIVE_TAGS) {
1909
+ candidates.push({ selector: `${tag}:has-text("${escaped}")`, strategy: "structural" });
1910
+ }
1911
+ return candidates;
1912
+ }
1913
+ async function healSelector(probe, selector, options) {
1914
+ if (!options.enabled) return null;
1915
+ for (const candidate of buildHealCandidates(selector)) {
1916
+ let count;
1917
+ try {
1918
+ const locator = probe.locator(candidate.selector);
1919
+ count = await locator.count();
1920
+ } catch {
1921
+ continue;
1922
+ }
1923
+ if (count === 1) {
1924
+ return { selector: candidate.selector, healedFrom: selector, strategy: candidate.strategy };
1925
+ }
1926
+ }
1927
+ return null;
1928
+ }
1929
+
1930
+ // src/runner/policy.ts
1931
+ var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
1932
+ function createRunPolicy(driver, options) {
1933
+ const { forbiddenSelectors, allowedDomains, maxSteps, selfHealing } = options;
1934
+ const allowedApps = options.allowedApps ?? [];
1935
+ function matchesForbiddenPattern(selector, forbidden) {
1936
+ const selectorText = driver.parseTextSelector(selector);
1937
+ if (selectorText === null) {
1938
+ return false;
1939
+ }
1940
+ const forbiddenText = driver.parseTextSelector(forbidden);
1941
+ if (forbiddenText !== null) {
1942
+ return selectorText.includes(forbiddenText);
1943
+ }
1944
+ return selectorText.includes(forbidden);
1945
+ }
1946
+ function isForbiddenSelector(selector) {
1947
+ return forbiddenSelectors.some(
1948
+ (forbidden) => selector.includes(forbidden) || matchesForbiddenPattern(selector, forbidden)
1949
+ );
1950
+ }
1951
+ function assertAllowedSelector(selector) {
1952
+ if (isForbiddenSelector(selector)) {
1953
+ throw new Error(`Forbidden selector: ${selector}`);
1954
+ }
1955
+ }
1956
+ function assertWithinMaxSteps(stepCount, huntName) {
1957
+ if (stepCount > maxSteps) {
1958
+ if (huntName) {
1959
+ throw new Error(`Hunt "${huntName}" has ${stepCount} steps. Max allowed is ${maxSteps}.`);
1960
+ }
1961
+ throw new Error(`Hunt has ${stepCount} steps. Max allowed is ${maxSteps}.`);
1962
+ }
1963
+ }
1964
+ function ensureUrlAllowed(urlValue) {
1965
+ for (const protocol of ALWAYS_ALLOWED_PROTOCOLS) {
1966
+ if (urlValue.startsWith(protocol)) {
1967
+ return;
1968
+ }
1969
+ }
1970
+ let url;
1971
+ try {
1972
+ url = new URL(urlValue);
1973
+ } catch {
1974
+ throw new Error(`Navigation target is not a valid absolute URL: ${urlValue}`);
1975
+ }
1976
+ if (!allowedDomains.includes(url.hostname)) {
1977
+ throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
1978
+ }
1979
+ }
1980
+ function ensureAppAllowed(app) {
1981
+ if (!allowedApps.includes(app)) {
1982
+ throw new Error(`Interaction with disallowed app: ${app}`);
1983
+ }
1984
+ }
1985
+ function ensureLocationAllowed(activeDriver) {
1986
+ if (activeDriver.capabilities.has("navigate")) {
1987
+ ensureUrlAllowed(activeDriver.currentUrl());
1988
+ }
1989
+ }
1990
+ const healProbe = {
1991
+ locator: (selector) => ({ count: () => driver.count(selector) })
1992
+ };
1993
+ async function resolveActionSelector(selector) {
1994
+ assertAllowedSelector(selector);
1995
+ if (!selfHealing) {
1996
+ return { selector };
1997
+ }
1998
+ let matched = false;
1999
+ try {
2000
+ matched = await driver.count(selector) > 0;
2001
+ } catch {
2002
+ return { selector };
2003
+ }
2004
+ if (matched) {
2005
+ return { selector };
2006
+ }
2007
+ const healed = await healSelector(healProbe, selector, { enabled: true });
2008
+ if (!healed) {
2009
+ return { selector };
2010
+ }
2011
+ assertAllowedSelector(healed.selector);
2012
+ console.warn(
2013
+ `Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
2014
+ );
2015
+ return { selector: healed.selector, healedFrom: healed.healedFrom };
2016
+ }
2017
+ return {
2018
+ assertWithinMaxSteps,
2019
+ ensureUrlAllowed,
2020
+ ensureAppAllowed,
2021
+ ensureLocationAllowed,
2022
+ assertAllowedSelector,
2023
+ resolveActionSelector
2024
+ };
2025
+ }
2026
+
2027
+ // src/runner/steps.ts
2028
+ function getStepType(step) {
2029
+ if ("navigate" in step) return "navigate";
2030
+ if ("click" in step) return "click";
2031
+ if ("fill" in step) return "fill";
2032
+ if ("type" in step) return "type";
2033
+ if ("selectOption" in step) return "selectOption";
2034
+ if ("select" in step) return "select";
2035
+ if ("onDialog" in step) return "onDialog";
2036
+ if ("setInputFiles" in step) return "setInputFiles";
2037
+ if ("runHunt" in step) return "runHunt";
2038
+ if ("assert" in step) return "assert";
2039
+ if ("press" in step) return "press";
2040
+ if ("wait" in step) return "wait";
2041
+ if ("waitForSelector" in step) return "waitForSelector";
2042
+ if ("waitForUrl" in step) return "waitForUrl";
2043
+ if ("waitForNetworkIdle" in step) return "waitForNetworkIdle";
2044
+ if ("hover" in step) return "hover";
2045
+ if ("scroll" in step) return "scroll";
2046
+ if ("scrollTo" in step) return "scrollTo";
2047
+ if ("screenshot" in step) return "screenshot";
2048
+ if ("if" in step) return "if";
2049
+ if ("repeat" in step) return "repeat";
2050
+ if ("mockRoute" in step) return "mockRoute";
2051
+ if ("unmockRoute" in step) return "unmockRoute";
2052
+ if ("evalScript" in step) return "evalScript";
2053
+ if ("runScript" in step) return "runScript";
2054
+ if ("assertScreenshot" in step) return "assertScreenshot";
2055
+ if ("copyText" in step) return "copyText";
2056
+ if ("waitForDownload" in step) return "waitForDownload";
2057
+ return "step";
2058
+ }
2059
+ var RUNTIME_VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
2060
+ function substituteRuntimeVars(input, vars) {
2061
+ return input.replace(RUNTIME_VAR_PATTERN, (match, name) => {
2062
+ const value = vars.get(name);
2063
+ return value !== void 0 ? value : match;
2064
+ });
2065
+ }
2066
+ function applyRuntimeVars(step, vars) {
2067
+ const sub = (s) => substituteRuntimeVars(s, vars);
2068
+ if ("navigate" in step) return { navigate: sub(step.navigate) };
2069
+ if ("click" in step) {
2070
+ if (typeof step.click === "string") return { click: sub(step.click) };
2071
+ return { click: { selector: sub(step.click.selector) } };
2072
+ }
2073
+ if ("fill" in step) {
2074
+ if ("selector" in step.fill && "value" in step.fill) {
2075
+ const f = step.fill;
2076
+ return { fill: { selector: sub(f.selector), value: sub(f.value) } };
1358
2077
  }
1359
2078
  const [key, value] = Object.entries(step.fill)[0];
1360
2079
  return { fill: { [sub(key)]: sub(value) } };
@@ -1409,17 +2128,6 @@ function applyRuntimeVars(step, vars) {
1409
2128
  function isExplicitFillStep(value) {
1410
2129
  return typeof value.selector === "string" && typeof value.value === "string";
1411
2130
  }
1412
- function ensureAllowedUrl(urlValue, allowedDomains) {
1413
- for (const protocol of ALWAYS_ALLOWED_PROTOCOLS) {
1414
- if (urlValue.startsWith(protocol)) {
1415
- return;
1416
- }
1417
- }
1418
- const url = new URL(urlValue);
1419
- if (!allowedDomains.includes(url.hostname)) {
1420
- throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
1421
- }
1422
- }
1423
2131
  function resolveNavigationTarget(targetUrl, value) {
1424
2132
  try {
1425
2133
  return new URL(value, targetUrl).toString();
@@ -1446,56 +2154,50 @@ function getSinglePair(value, stepType) {
1446
2154
  }
1447
2155
  return entries[0];
1448
2156
  }
1449
- async function clickByTextWithFallback(page, text, forbiddenSelectors) {
2157
+ async function clickByTextWithFallback(driver, policy, text) {
1450
2158
  const roleSelector = `role=button[name="${escapeForAttribute(text)}"]`;
1451
- assertAllowedSelector(roleSelector, forbiddenSelectors);
1452
- const button = page.getByRole("button", { name: text });
1453
- if (await button.count()) {
1454
- await button.first().click();
2159
+ policy.assertAllowedSelector(roleSelector);
2160
+ if (await driver.countByRole("button", text)) {
2161
+ await driver.clickFirstByRole("button", text);
1455
2162
  return roleSelector;
1456
2163
  }
1457
2164
  const selector = exactTextSelector(text);
1458
- assertAllowedSelector(selector, forbiddenSelectors);
1459
- await page.locator(selector).first().click();
2165
+ policy.assertAllowedSelector(selector);
2166
+ await driver.clickFirst(selector);
1460
2167
  return selector;
1461
2168
  }
1462
- async function fillByLabelOrPlaceholder(page, label, value, forbiddenSelectors) {
2169
+ async function fillByLabelOrPlaceholder(driver, policy, label, value) {
1463
2170
  const labelSelector = `label="${escapeForAttribute(label)}"`;
1464
- assertAllowedSelector(labelSelector, forbiddenSelectors);
1465
- const byLabel = page.getByLabel(label, { exact: true });
1466
- if (await byLabel.count()) {
1467
- await byLabel.first().fill(value);
2171
+ policy.assertAllowedSelector(labelSelector);
2172
+ if (await driver.countByLabel(label)) {
2173
+ await driver.fillFirstByLabel(label, value);
1468
2174
  return labelSelector;
1469
2175
  }
1470
2176
  const placeholder = `input[placeholder="${escapeForAttribute(label)}"], textarea[placeholder="${escapeForAttribute(label)}"]`;
1471
- assertAllowedSelector(placeholder, forbiddenSelectors);
1472
- const byPlaceholder = page.locator(placeholder);
1473
- if (await byPlaceholder.count()) {
1474
- await byPlaceholder.first().fill(value);
2177
+ policy.assertAllowedSelector(placeholder);
2178
+ if (await driver.count(placeholder)) {
2179
+ await driver.fillFirst(placeholder, value);
1475
2180
  return placeholder;
1476
2181
  }
1477
2182
  throw new Error(`Could not resolve fill shorthand for "${label}"`);
1478
2183
  }
1479
- async function selectByLabelOrFallback(page, label, value, forbiddenSelectors) {
2184
+ async function selectByLabelOrFallback(driver, policy, label, value) {
1480
2185
  const labelSelector = `label="${escapeForAttribute(label)}"`;
1481
- assertAllowedSelector(labelSelector, forbiddenSelectors);
1482
- const byLabel = page.getByLabel(label, { exact: true });
1483
- if (await byLabel.count()) {
1484
- await byLabel.first().selectOption(value);
2186
+ policy.assertAllowedSelector(labelSelector);
2187
+ if (await driver.countByLabel(label)) {
2188
+ await driver.selectOptionFirstByLabel(label, value);
1485
2189
  return labelSelector;
1486
2190
  }
1487
2191
  const ariaSelector = `select[aria-label="${escapeForAttribute(label)}"]`;
1488
- assertAllowedSelector(ariaSelector, forbiddenSelectors);
1489
- const byAria = page.locator(ariaSelector);
1490
- if (await byAria.count()) {
1491
- await byAria.first().selectOption(value);
2192
+ policy.assertAllowedSelector(ariaSelector);
2193
+ if (await driver.count(ariaSelector)) {
2194
+ await driver.selectOptionFirst(ariaSelector, value);
1492
2195
  return ariaSelector;
1493
2196
  }
1494
2197
  const placeholderSelector = `select[placeholder="${escapeForAttribute(label)}"]`;
1495
- assertAllowedSelector(placeholderSelector, forbiddenSelectors);
1496
- const byPlaceholder = page.locator(placeholderSelector);
1497
- if (await byPlaceholder.count()) {
1498
- await byPlaceholder.first().selectOption(value);
2198
+ policy.assertAllowedSelector(placeholderSelector);
2199
+ if (await driver.count(placeholderSelector)) {
2200
+ await driver.selectOptionFirst(placeholderSelector, value);
1499
2201
  return placeholderSelector;
1500
2202
  }
1501
2203
  throw new Error(`Could not resolve select shorthand for "${label}"`);
@@ -1623,11 +2325,11 @@ function toVisibilitySelector(value) {
1623
2325
  if (looksLikeSelector(value)) return value;
1624
2326
  return textContainsSelector(value);
1625
2327
  }
1626
- async function runInlineAssert(page, assertion, forbiddenSelectors) {
2328
+ async function runInlineAssert(driver, policy, assertion) {
1627
2329
  if (assertion.visible !== void 0) {
1628
2330
  const selector = toVisibilitySelector(assertion.visible);
1629
- assertAllowedSelector(selector, forbiddenSelectors);
1630
- const count = await page.locator(selector).count();
2331
+ policy.assertAllowedSelector(selector);
2332
+ const count = await driver.count(selector);
1631
2333
  if (count === 0) {
1632
2334
  throw new Error(`Expected visible: ${assertion.visible}`);
1633
2335
  }
@@ -1635,22 +2337,22 @@ async function runInlineAssert(page, assertion, forbiddenSelectors) {
1635
2337
  }
1636
2338
  if (assertion.notVisible !== void 0) {
1637
2339
  const selector = toVisibilitySelector(assertion.notVisible);
1638
- assertAllowedSelector(selector, forbiddenSelectors);
1639
- const count = await page.locator(selector).count();
2340
+ policy.assertAllowedSelector(selector);
2341
+ const count = await driver.count(selector);
1640
2342
  if (count > 0) {
1641
2343
  throw new Error(`Expected not visible: ${assertion.notVisible}`);
1642
2344
  }
1643
2345
  return `notVisible:${assertion.notVisible}`;
1644
2346
  }
1645
2347
  if (assertion.urlIncludes !== void 0) {
1646
- const current = page.url();
2348
+ const current = driver.currentUrl();
1647
2349
  if (!current.includes(assertion.urlIncludes)) {
1648
2350
  throw new Error(`URL did not include ${assertion.urlIncludes}`);
1649
2351
  }
1650
2352
  return `urlIncludes:${assertion.urlIncludes}`;
1651
2353
  }
1652
2354
  if (assertion.urlEquals !== void 0) {
1653
- const current = page.url();
2355
+ const current = driver.currentUrl();
1654
2356
  if (current !== assertion.urlEquals) {
1655
2357
  throw new Error(`URL did not equal ${assertion.urlEquals}`);
1656
2358
  }
@@ -1659,7 +2361,7 @@ async function runInlineAssert(page, assertion, forbiddenSelectors) {
1659
2361
  throw new Error("assert step is missing an assertion type");
1660
2362
  }
1661
2363
  function screenshotPath(screenshotsDir, fileName) {
1662
- return import_node_path4.default.join(screenshotsDir, fileName);
2364
+ return import_node_path6.default.join(screenshotsDir, fileName);
1663
2365
  }
1664
2366
  function stepPath(prefix, index) {
1665
2367
  return prefix ? `${prefix}.${index}` : `${index}`;
@@ -1667,8 +2369,8 @@ function stepPath(prefix, index) {
1667
2369
  function isWaitForDownloadStep(step) {
1668
2370
  return step !== void 0 && "waitForDownload" in step;
1669
2371
  }
1670
- function armDownloadListener(page, timeout) {
1671
- const downloadPromise = page.waitForEvent("download", { timeout });
2372
+ function armDownloadListener(driver, timeout) {
2373
+ const downloadPromise = driver.waitForDownloadEvent({ timeout });
1672
2374
  void downloadPromise.catch(() => void 0);
1673
2375
  return downloadPromise;
1674
2376
  }
@@ -1676,14 +2378,14 @@ function validateDownloadFilename(suggestedFilename) {
1676
2378
  const safeFilename = suggestedFilename.trim();
1677
2379
  const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
1678
2380
  const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
1679
- if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path4.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
2381
+ if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path6.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
1680
2382
  throw new Error(`Invalid download filename: "${suggestedFilename}"`);
1681
2383
  }
1682
2384
  return safeFilename;
1683
2385
  }
1684
- async function captureScreenshot(page, filePath) {
2386
+ async function captureScreenshot(taker, filePath) {
1685
2387
  try {
1686
- await page.screenshot({ path: filePath, fullPage: true });
2388
+ await taker.screenshot({ path: filePath, fullPage: true });
1687
2389
  } catch (error) {
1688
2390
  const message = error instanceof Error ? error.message : "Screenshot failed";
1689
2391
  throw new Error(`Failed to capture screenshot at ${filePath}: ${message}`);
@@ -1702,608 +2404,784 @@ async function executeNestedSteps(context, overrides) {
1702
2404
  }
1703
2405
  return result;
1704
2406
  }
1705
- async function executeSteps(context) {
1706
- const screenshotsDir = import_node_path4.default.join(context.runDir, "screenshots");
1707
- import_node_fs4.default.mkdirSync(screenshotsDir, { recursive: true });
1708
- const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
1709
- assertWithinMaxSteps(context.steps.length, context.maxSteps, currentHuntName);
1710
- const results = [];
1711
- const screenshots = [];
1712
- const runStartedAtMs = context.runStartedAtMs ?? Date.now();
1713
- context.runStartedAtMs = runStartedAtMs;
1714
- const addScreenshot = async (fileName) => {
1715
- const fullPath = screenshotPath(screenshotsDir, fileName);
1716
- await captureScreenshot(context.page, fullPath);
1717
- const relative = import_node_path4.default.join("screenshots", fileName);
1718
- screenshots.push(relative);
1719
- return relative;
1720
- };
1721
- for (let index = 0; index < context.steps.length; index += 1) {
1722
- const currentStepPath = stepPath(context.stepPathPrefix, index);
1723
- if (Date.now() - runStartedAtMs > context.maxTotalTimeMs) {
1724
- results.push({
1725
- type: "timeout",
1726
- status: "fail",
1727
- durationMs: 0,
1728
- error: `Max total time exceeded (${context.maxTotalTimeMs}ms)`
1729
- });
1730
- return { results, screenshots, failed: true, error: "Max total time exceeded" };
1731
- }
1732
- const runtimeVars = context.runtimeVars ?? /* @__PURE__ */ new Map();
1733
- context.runtimeVars = runtimeVars;
1734
- let step = context.steps[index];
1735
- if (runtimeVars.size > 0) {
1736
- step = applyRuntimeVars(step, runtimeVars);
2407
+ function unknownStep() {
2408
+ throw new Error("Unknown step type");
2409
+ }
2410
+ var STEP_HANDLERS = {
2411
+ navigate: {
2412
+ capabilities: ["navigate"],
2413
+ run: async (h) => {
2414
+ if (!("navigate" in h.step)) unknownStep();
2415
+ const destination = resolveNavigationTarget(h.context.targetUrl, h.step.navigate);
2416
+ h.policy.ensureUrlAllowed(destination);
2417
+ await h.driver.goto(destination);
2418
+ h.policy.ensureLocationAllowed(h.driver);
2419
+ return { kind: "result", result: { type: "navigate", status: "pass", durationMs: Date.now() - h.stepStart } };
1737
2420
  }
1738
- const nextStep = context.steps[index + 1];
1739
- if (!isWaitForDownloadStep(step) && context.pendingDownload === void 0 && isWaitForDownloadStep(nextStep)) {
1740
- context.pendingDownload = armDownloadListener(
1741
- context.page,
1742
- nextStep.waitForDownload?.timeout ?? 3e4
1743
- );
1744
- }
1745
- const stepStart = Date.now();
1746
- const stepType = getStepType(step);
1747
- let stepResult = null;
1748
- try {
1749
- if ("navigate" in step) {
1750
- const destination = resolveNavigationTarget(context.targetUrl, step.navigate);
1751
- ensureAllowedUrl(destination, context.allowedDomains);
1752
- await context.page.goto(destination);
1753
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1754
- stepResult = { type: "navigate", status: "pass", durationMs: Date.now() - stepStart };
1755
- } else if ("click" in step) {
1756
- let selector;
1757
- let healedFrom;
1758
- if (typeof step.click === "string") {
1759
- selector = await clickByTextWithFallback(
1760
- context.page,
1761
- step.click,
1762
- context.forbiddenSelectors
1763
- );
1764
- } else {
1765
- const resolved = await resolveActionSelector(context, step.click.selector);
1766
- await clickElement(context.page, resolved.selector);
1767
- selector = resolved.selector;
1768
- healedFrom = resolved.healedFrom;
1769
- }
1770
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1771
- stepResult = {
2421
+ },
2422
+ click: {
2423
+ capabilities: ["interact", "query"],
2424
+ run: async (h) => {
2425
+ if (!("click" in h.step)) unknownStep();
2426
+ let selector;
2427
+ let healedFrom;
2428
+ if (typeof h.step.click === "string") {
2429
+ selector = await clickByTextWithFallback(h.driver, h.policy, h.step.click);
2430
+ } else {
2431
+ const resolved = await h.policy.resolveActionSelector(h.step.click.selector);
2432
+ await h.driver.click(resolved.selector);
2433
+ selector = resolved.selector;
2434
+ healedFrom = resolved.healedFrom;
2435
+ }
2436
+ h.policy.ensureLocationAllowed(h.driver);
2437
+ return {
2438
+ kind: "result",
2439
+ result: {
1772
2440
  type: "click",
1773
2441
  status: "pass",
1774
- durationMs: Date.now() - stepStart,
2442
+ durationMs: Date.now() - h.stepStart,
1775
2443
  selector,
1776
2444
  ...healedFrom ? { healedFrom } : {}
1777
- };
1778
- } else if ("fill" in step) {
1779
- let selector;
1780
- let value;
1781
- let healedFrom;
1782
- if (isExplicitFillStep(step.fill)) {
1783
- const resolved = await resolveActionSelector(context, step.fill.selector);
1784
- await fillElement(context.page, resolved.selector, step.fill.value);
1785
- selector = resolved.selector;
1786
- healedFrom = resolved.healedFrom;
1787
- value = step.fill.value;
1788
- } else {
1789
- const [label, shorthandValue] = getSinglePair(step.fill, "fill");
1790
- selector = await fillByLabelOrPlaceholder(
1791
- context.page,
1792
- label,
1793
- shorthandValue,
1794
- context.forbiddenSelectors
1795
- );
1796
- value = shorthandValue;
1797
2445
  }
1798
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1799
- stepResult = {
2446
+ };
2447
+ }
2448
+ },
2449
+ fill: {
2450
+ capabilities: ["interact", "query"],
2451
+ run: async (h) => {
2452
+ if (!("fill" in h.step)) unknownStep();
2453
+ let selector;
2454
+ let value;
2455
+ let healedFrom;
2456
+ if (isExplicitFillStep(h.step.fill)) {
2457
+ const resolved = await h.policy.resolveActionSelector(h.step.fill.selector);
2458
+ await h.driver.fill(resolved.selector, h.step.fill.value);
2459
+ selector = resolved.selector;
2460
+ healedFrom = resolved.healedFrom;
2461
+ value = h.step.fill.value;
2462
+ } else {
2463
+ const [label, shorthandValue] = getSinglePair(h.step.fill, "fill");
2464
+ selector = await fillByLabelOrPlaceholder(h.driver, h.policy, label, shorthandValue);
2465
+ value = shorthandValue;
2466
+ }
2467
+ h.policy.ensureLocationAllowed(h.driver);
2468
+ return {
2469
+ kind: "result",
2470
+ result: {
1800
2471
  type: "fill",
1801
2472
  status: "pass",
1802
- durationMs: Date.now() - stepStart,
2473
+ durationMs: Date.now() - h.stepStart,
1803
2474
  selector,
1804
- value: context.redactedFillSteps.has(currentStepPath) ? "[REDACTED]" : value,
2475
+ value: h.context.redactedFillSteps.has(h.stepPath) ? "[REDACTED]" : value,
1805
2476
  ...healedFrom ? { healedFrom } : {}
1806
- };
1807
- } else if ("type" in step) {
1808
- assertAllowedSelector(":focus", context.forbiddenSelectors);
1809
- await fillElement(context.page, ":focus", step.type);
1810
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1811
- stepResult = {
2477
+ }
2478
+ };
2479
+ }
2480
+ },
2481
+ type: {
2482
+ capabilities: ["interact"],
2483
+ run: async (h) => {
2484
+ if (!("type" in h.step)) unknownStep();
2485
+ h.policy.assertAllowedSelector(":focus");
2486
+ await h.driver.fill(":focus", h.step.type);
2487
+ h.policy.ensureLocationAllowed(h.driver);
2488
+ return {
2489
+ kind: "result",
2490
+ result: {
1812
2491
  type: "type",
1813
2492
  status: "pass",
1814
- durationMs: Date.now() - stepStart,
2493
+ durationMs: Date.now() - h.stepStart,
1815
2494
  selector: ":focus",
1816
- value: context.redactedFillSteps.has(currentStepPath) ? "[REDACTED]" : step.type
1817
- };
1818
- } else if ("selectOption" in step) {
1819
- const resolved = await resolveActionSelector(context, step.selectOption.selector);
1820
- await selectOption(context.page, resolved.selector, step.selectOption.value);
1821
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1822
- stepResult = {
2495
+ value: h.context.redactedFillSteps.has(h.stepPath) ? "[REDACTED]" : h.step.type
2496
+ }
2497
+ };
2498
+ }
2499
+ },
2500
+ selectOption: {
2501
+ capabilities: ["navigate", "interact", "query"],
2502
+ run: async (h) => {
2503
+ if (!("selectOption" in h.step)) unknownStep();
2504
+ const resolved = await h.policy.resolveActionSelector(h.step.selectOption.selector);
2505
+ await h.driver.selectOption(resolved.selector, h.step.selectOption.value);
2506
+ h.policy.ensureLocationAllowed(h.driver);
2507
+ return {
2508
+ kind: "result",
2509
+ result: {
1823
2510
  type: "selectOption",
1824
2511
  status: "pass",
1825
- durationMs: Date.now() - stepStart,
2512
+ durationMs: Date.now() - h.stepStart,
1826
2513
  selector: resolved.selector,
1827
- value: step.selectOption.value,
2514
+ value: h.step.selectOption.value,
1828
2515
  ...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
1829
- };
1830
- } else if ("select" in step) {
1831
- const [label, value] = getSinglePair(step.select, "select");
1832
- const selector = await selectByLabelOrFallback(
1833
- context.page,
1834
- label,
1835
- value,
1836
- context.forbiddenSelectors
1837
- );
1838
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1839
- stepResult = {
2516
+ }
2517
+ };
2518
+ }
2519
+ },
2520
+ select: {
2521
+ capabilities: ["navigate", "interact", "query"],
2522
+ run: async (h) => {
2523
+ if (!("select" in h.step)) unknownStep();
2524
+ const [label, value] = getSinglePair(h.step.select, "select");
2525
+ const selector = await selectByLabelOrFallback(h.driver, h.policy, label, value);
2526
+ h.policy.ensureLocationAllowed(h.driver);
2527
+ return {
2528
+ kind: "result",
2529
+ result: {
1840
2530
  type: "select",
1841
2531
  status: "pass",
1842
- durationMs: Date.now() - stepStart,
2532
+ durationMs: Date.now() - h.stepStart,
1843
2533
  selector,
1844
2534
  value
1845
- };
1846
- } else if ("onDialog" in step) {
1847
- setupDialogHandler(context.page, step.onDialog.action);
1848
- stepResult = {
2535
+ }
2536
+ };
2537
+ }
2538
+ },
2539
+ onDialog: {
2540
+ capabilities: ["dialog"],
2541
+ run: async (h) => {
2542
+ if (!("onDialog" in h.step)) unknownStep();
2543
+ h.driver.onDialog(h.step.onDialog.action);
2544
+ return {
2545
+ kind: "result",
2546
+ result: {
1849
2547
  type: "onDialog",
1850
2548
  status: "pass",
1851
- durationMs: Date.now() - stepStart,
1852
- value: step.onDialog.action
1853
- };
1854
- } else if ("setInputFiles" in step) {
1855
- const resolvedInput = await resolveActionSelector(context, step.setInputFiles.selector);
1856
- const rawFiles = step.setInputFiles.files;
1857
- const resolveFile = (f) => import_node_path4.default.isAbsolute(f) ? f : import_node_path4.default.join(context.configDir, f);
1858
- const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
1859
- await setInputFiles(context.page, resolvedInput.selector, resolvedFiles);
1860
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1861
- const filesLabel = Array.isArray(rawFiles) ? rawFiles.join(", ") : rawFiles;
1862
- stepResult = {
2549
+ durationMs: Date.now() - h.stepStart,
2550
+ value: h.step.onDialog.action
2551
+ }
2552
+ };
2553
+ }
2554
+ },
2555
+ setInputFiles: {
2556
+ capabilities: ["navigate", "interact", "query", "files"],
2557
+ run: async (h) => {
2558
+ if (!("setInputFiles" in h.step)) unknownStep();
2559
+ const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
2560
+ const rawFiles = h.step.setInputFiles.files;
2561
+ const resolveFile = (f) => import_node_path6.default.isAbsolute(f) ? f : import_node_path6.default.join(h.context.configDir, f);
2562
+ const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
2563
+ await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
2564
+ h.policy.ensureLocationAllowed(h.driver);
2565
+ const filesLabel = Array.isArray(rawFiles) ? rawFiles.join(", ") : rawFiles;
2566
+ return {
2567
+ kind: "result",
2568
+ result: {
1863
2569
  type: "setInputFiles",
1864
2570
  status: "pass",
1865
- durationMs: Date.now() - stepStart,
2571
+ durationMs: Date.now() - h.stepStart,
1866
2572
  selector: resolvedInput.selector,
1867
2573
  ...resolvedInput.healedFrom ? { healedFrom: resolvedInput.healedFrom } : {},
1868
2574
  value: filesLabel
1869
- };
1870
- } else if ("runHunt" in step) {
1871
- const huntName = typeof step.runHunt === "string" ? step.runHunt : step.runHunt.name;
1872
- const overrideVars = typeof step.runHunt === "string" ? void 0 : step.runHunt.vars;
1873
- const stack = context.huntStack ?? [];
1874
- if (stack.includes(huntName)) {
1875
- throw new Error(`Circular hunt dependency: ${[...stack, huntName].join(" \u2192 ")}`);
1876
- }
1877
- const subHunt = loadHunt(huntName, context.configDir);
1878
- if (overrideVars) {
1879
- subHunt.vars = { ...subHunt.vars, ...overrideVars };
1880
- }
1881
- const {
1882
- hunt: interpolatedSubHunt,
1883
- redactedFillSteps: subRedacted,
1884
- randomVars
1885
- } = interpolateHunt(
1886
- subHunt,
1887
- process.env,
1888
- context.randomVars
1889
- );
1890
- assertWithinMaxSteps(interpolatedSubHunt.steps.length, context.maxSteps, huntName);
1891
- const subResult = await executeNestedSteps(context, {
1892
- steps: interpolatedSubHunt.steps,
1893
- redactedFillSteps: subRedacted,
1894
- randomVars,
1895
- stepPathPrefix: void 0,
1896
- huntStack: [...stack, huntName],
1897
- onStep: context.onStep
1898
- });
1899
- for (const sr of subResult.results) {
1900
- results.push({ ...sr, type: `${huntName} > ${sr.type}` });
1901
- }
1902
- screenshots.push(...subResult.screenshots);
1903
- if (subResult.failed) {
1904
- return {
1905
- results,
1906
- screenshots,
1907
- failed: true,
1908
- error: `Sub-hunt "${huntName}" failed: ${subResult.error}`
1909
- };
1910
2575
  }
1911
- stepResult = {
1912
- type: "runHunt",
1913
- status: "pass",
1914
- durationMs: Date.now() - stepStart,
1915
- value: huntName
1916
- };
1917
- } else if ("press" in step) {
1918
- const resolved = await resolveActionSelector(context, step.press.selector);
1919
- await pressKey(context.page, resolved.selector, step.press.key);
1920
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1921
- stepResult = {
2576
+ };
2577
+ }
2578
+ },
2579
+ runHunt: {
2580
+ capabilities: [],
2581
+ run: async (h) => {
2582
+ if (!("runHunt" in h.step)) unknownStep();
2583
+ const huntName = typeof h.step.runHunt === "string" ? h.step.runHunt : h.step.runHunt.name;
2584
+ const overrideVars = typeof h.step.runHunt === "string" ? void 0 : h.step.runHunt.vars;
2585
+ const stack = h.context.huntStack ?? [];
2586
+ if (stack.includes(huntName)) {
2587
+ throw new Error(`Circular hunt dependency: ${[...stack, huntName].join(" \u2192 ")}`);
2588
+ }
2589
+ const subHunt = loadHunt(huntName, h.context.configDir);
2590
+ if (overrideVars) {
2591
+ subHunt.vars = { ...subHunt.vars, ...overrideVars };
2592
+ }
2593
+ const {
2594
+ hunt: interpolatedSubHunt,
2595
+ redactedFillSteps: subRedacted,
2596
+ randomVars
2597
+ } = interpolateHunt(subHunt, process.env, h.context.randomVars);
2598
+ const subTargetType = h.driver.capabilities.has("navigate") ? "web" : "macos";
2599
+ assertStepsSupportedByTarget(interpolatedSubHunt.steps, subTargetType);
2600
+ assertHuntAssertionsSupportedByTarget(interpolatedSubHunt.assertions, subTargetType);
2601
+ h.policy.assertWithinMaxSteps(interpolatedSubHunt.steps.length, huntName);
2602
+ const subResult = await h.executeNested({
2603
+ steps: interpolatedSubHunt.steps,
2604
+ redactedFillSteps: subRedacted,
2605
+ randomVars,
2606
+ stepPathPrefix: void 0,
2607
+ huntStack: [...stack, huntName],
2608
+ onStep: h.context.onStep
2609
+ });
2610
+ for (const sr of subResult.results) {
2611
+ h.results.push({ ...sr, type: `${huntName} > ${sr.type}` });
2612
+ }
2613
+ h.screenshots.push(...subResult.screenshots);
2614
+ if (subResult.failed) {
2615
+ return { kind: "abort", error: `Sub-hunt "${huntName}" failed: ${subResult.error}` };
2616
+ }
2617
+ return {
2618
+ kind: "result",
2619
+ result: { type: "runHunt", status: "pass", durationMs: Date.now() - h.stepStart, value: huntName }
2620
+ };
2621
+ }
2622
+ },
2623
+ press: {
2624
+ capabilities: ["interact", "query"],
2625
+ run: async (h) => {
2626
+ if (!("press" in h.step)) unknownStep();
2627
+ const resolved = await h.policy.resolveActionSelector(h.step.press.selector);
2628
+ await h.driver.press(resolved.selector, h.step.press.key);
2629
+ h.policy.ensureLocationAllowed(h.driver);
2630
+ return {
2631
+ kind: "result",
2632
+ result: {
1922
2633
  type: "press",
1923
2634
  status: "pass",
1924
- durationMs: Date.now() - stepStart,
2635
+ durationMs: Date.now() - h.stepStart,
1925
2636
  selector: resolved.selector,
1926
2637
  ...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
1927
- };
1928
- } else if ("assert" in step) {
1929
- const value = await runInlineAssert(context.page, step.assert, context.forbiddenSelectors);
1930
- stepResult = {
1931
- type: "assert",
1932
- status: "pass",
1933
- durationMs: Date.now() - stepStart,
1934
- value
1935
- };
1936
- } else if ("wait" in step) {
1937
- const text = typeof step.wait === "string" ? step.wait : step.wait.for;
1938
- const timeout = typeof step.wait === "string" ? void 0 : step.wait.timeout;
1939
- const selector = `text=${escapeForText(text)}`;
1940
- assertAllowedSelector(selector, context.forbiddenSelectors);
1941
- await context.page.waitForSelector(selector, { timeout });
1942
- stepResult = {
1943
- type: "wait",
1944
- status: "pass",
1945
- durationMs: Date.now() - stepStart,
1946
- selector
1947
- };
1948
- } else if ("waitForSelector" in step) {
1949
- assertAllowedSelector(step.waitForSelector.selector, context.forbiddenSelectors);
1950
- await context.page.waitForSelector(step.waitForSelector.selector, {
1951
- timeout: step.waitForSelector.timeout
1952
- });
1953
- stepResult = {
2638
+ }
2639
+ };
2640
+ }
2641
+ },
2642
+ assert: {
2643
+ capabilities: ["query"],
2644
+ run: async (h) => {
2645
+ if (!("assert" in h.step)) unknownStep();
2646
+ const value = await runInlineAssert(h.driver, h.policy, h.step.assert);
2647
+ return {
2648
+ kind: "result",
2649
+ result: { type: "assert", status: "pass", durationMs: Date.now() - h.stepStart, value }
2650
+ };
2651
+ }
2652
+ },
2653
+ wait: {
2654
+ capabilities: ["wait"],
2655
+ run: async (h) => {
2656
+ if (!("wait" in h.step)) unknownStep();
2657
+ const text = typeof h.step.wait === "string" ? h.step.wait : h.step.wait.for;
2658
+ const timeout = typeof h.step.wait === "string" ? void 0 : h.step.wait.timeout;
2659
+ const selector = `text=${escapeForText(text)}`;
2660
+ h.policy.assertAllowedSelector(selector);
2661
+ await h.driver.waitForSelector(selector, { timeout });
2662
+ return {
2663
+ kind: "result",
2664
+ result: { type: "wait", status: "pass", durationMs: Date.now() - h.stepStart, selector }
2665
+ };
2666
+ }
2667
+ },
2668
+ waitForSelector: {
2669
+ capabilities: ["wait"],
2670
+ run: async (h) => {
2671
+ if (!("waitForSelector" in h.step)) unknownStep();
2672
+ h.policy.assertAllowedSelector(h.step.waitForSelector.selector);
2673
+ await h.driver.waitForSelector(h.step.waitForSelector.selector, {
2674
+ timeout: h.step.waitForSelector.timeout
2675
+ });
2676
+ return {
2677
+ kind: "result",
2678
+ result: {
1954
2679
  type: "waitForSelector",
1955
2680
  status: "pass",
1956
- durationMs: Date.now() - stepStart,
1957
- selector: step.waitForSelector.selector
1958
- };
1959
- } else if ("waitForUrl" in step) {
1960
- await context.page.waitForURL(
1961
- (url) => url.toString().includes(step.waitForUrl.value),
1962
- { timeout: step.waitForUrl.timeout }
1963
- );
1964
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1965
- stepResult = {
1966
- type: "waitForUrl",
1967
- status: "pass",
1968
- durationMs: Date.now() - stepStart,
1969
- value: step.waitForUrl.value
1970
- };
1971
- } else if ("waitForNetworkIdle" in step) {
1972
- await context.page.waitForLoadState("networkidle", {
1973
- timeout: step.waitForNetworkIdle.timeout
1974
- });
1975
- stepResult = {
1976
- type: "waitForNetworkIdle",
1977
- status: "pass",
1978
- durationMs: Date.now() - stepStart
1979
- };
1980
- } else if ("hover" in step) {
1981
- const resolved = await resolveActionSelector(context, step.hover.selector);
1982
- await context.page.locator(resolved.selector).hover();
1983
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1984
- stepResult = {
2681
+ durationMs: Date.now() - h.stepStart,
2682
+ selector: h.step.waitForSelector.selector
2683
+ }
2684
+ };
2685
+ }
2686
+ },
2687
+ waitForUrl: {
2688
+ capabilities: ["navigate", "wait"],
2689
+ run: async (h) => {
2690
+ if (!("waitForUrl" in h.step)) unknownStep();
2691
+ const value = h.step.waitForUrl.value;
2692
+ await h.driver.waitForUrl((url) => url.includes(value), { timeout: h.step.waitForUrl.timeout });
2693
+ h.policy.ensureLocationAllowed(h.driver);
2694
+ return {
2695
+ kind: "result",
2696
+ result: { type: "waitForUrl", status: "pass", durationMs: Date.now() - h.stepStart, value }
2697
+ };
2698
+ }
2699
+ },
2700
+ waitForNetworkIdle: {
2701
+ capabilities: ["wait"],
2702
+ run: async (h) => {
2703
+ if (!("waitForNetworkIdle" in h.step)) unknownStep();
2704
+ await h.driver.waitForNetworkIdle({ timeout: h.step.waitForNetworkIdle.timeout });
2705
+ return {
2706
+ kind: "result",
2707
+ result: { type: "waitForNetworkIdle", status: "pass", durationMs: Date.now() - h.stepStart }
2708
+ };
2709
+ }
2710
+ },
2711
+ hover: {
2712
+ capabilities: ["interact", "query"],
2713
+ run: async (h) => {
2714
+ if (!("hover" in h.step)) unknownStep();
2715
+ const resolved = await h.policy.resolveActionSelector(h.step.hover.selector);
2716
+ await h.driver.hover(resolved.selector);
2717
+ h.policy.ensureLocationAllowed(h.driver);
2718
+ return {
2719
+ kind: "result",
2720
+ result: {
1985
2721
  type: "hover",
1986
2722
  status: "pass",
1987
- durationMs: Date.now() - stepStart,
2723
+ durationMs: Date.now() - h.stepStart,
1988
2724
  selector: resolved.selector,
1989
2725
  ...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
1990
- };
1991
- } else if ("scroll" in step) {
1992
- const amount = step.scroll.amount ?? 500;
1993
- const scrollMap = {
1994
- up: [0, -amount],
1995
- down: [0, amount],
1996
- left: [-amount, 0],
1997
- right: [amount, 0]
1998
- };
1999
- const [x, y] = scrollMap[step.scroll.direction];
2000
- await context.page.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
2001
- stepResult = {
2726
+ }
2727
+ };
2728
+ }
2729
+ },
2730
+ scroll: {
2731
+ capabilities: ["evaluate"],
2732
+ run: async (h) => {
2733
+ if (!("scroll" in h.step)) unknownStep();
2734
+ const amount = h.step.scroll.amount ?? 500;
2735
+ const scrollMap = {
2736
+ up: [0, -amount],
2737
+ down: [0, amount],
2738
+ left: [-amount, 0],
2739
+ right: [amount, 0]
2740
+ };
2741
+ const [x, y] = scrollMap[h.step.scroll.direction];
2742
+ await h.driver.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
2743
+ return {
2744
+ kind: "result",
2745
+ result: {
2002
2746
  type: "scroll",
2003
2747
  status: "pass",
2004
- durationMs: Date.now() - stepStart,
2005
- value: `${step.scroll.direction} ${amount}px`
2006
- };
2007
- } else if ("scrollTo" in step) {
2008
- const resolved = await resolveActionSelector(context, step.scrollTo.selector);
2009
- await context.page.locator(resolved.selector).scrollIntoViewIfNeeded();
2010
- stepResult = {
2748
+ durationMs: Date.now() - h.stepStart,
2749
+ value: `${h.step.scroll.direction} ${amount}px`
2750
+ }
2751
+ };
2752
+ }
2753
+ },
2754
+ scrollTo: {
2755
+ capabilities: ["interact", "query"],
2756
+ run: async (h) => {
2757
+ if (!("scrollTo" in h.step)) unknownStep();
2758
+ const resolved = await h.policy.resolveActionSelector(h.step.scrollTo.selector);
2759
+ await h.driver.scrollIntoView(resolved.selector);
2760
+ return {
2761
+ kind: "result",
2762
+ result: {
2011
2763
  type: "scrollTo",
2012
2764
  status: "pass",
2013
- durationMs: Date.now() - stepStart,
2765
+ durationMs: Date.now() - h.stepStart,
2014
2766
  selector: resolved.selector,
2015
2767
  ...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
2016
- };
2017
- } else if ("screenshot" in step) {
2018
- const name = step.screenshot.name ?? `manual_step_${index + 1}.png`;
2019
- if (/[/\\]|\.\./.test(name)) {
2020
- throw new Error(`Invalid screenshot name: "${name}" must not contain path separators or ".."`);
2021
2768
  }
2022
- const fileName = name.endsWith(".png") ? name : `${name}.png`;
2023
- const relative = await addScreenshot(fileName);
2024
- stepResult = {
2025
- type: "screenshot",
2026
- status: "pass",
2027
- durationMs: Date.now() - stepStart,
2028
- screenshot: relative
2029
- };
2030
- } else if ("if" in step) {
2031
- const condition = step.if;
2032
- const selector = condition.visible ?? condition.notVisible;
2033
- assertAllowedSelector(selector, context.forbiddenSelectors);
2034
- const count = await context.page.locator(selector).count();
2035
- const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
2036
- if (conditionMet) {
2037
- const subResult = await executeNestedSteps(context, {
2038
- steps: condition.then,
2039
- stepPathPrefix: `${currentStepPath}.if.then`
2040
- });
2041
- for (const sr of subResult.results) {
2042
- results.push({ ...sr, type: `if > ${sr.type}` });
2043
- }
2044
- screenshots.push(...subResult.screenshots);
2045
- if (subResult.failed) {
2046
- return {
2047
- results,
2048
- screenshots,
2049
- failed: true,
2050
- error: subResult.error
2051
- };
2052
- }
2053
- stepResult = {
2769
+ };
2770
+ }
2771
+ },
2772
+ screenshot: {
2773
+ capabilities: ["screenshot"],
2774
+ run: async (h) => {
2775
+ if (!("screenshot" in h.step)) unknownStep();
2776
+ const name = h.step.screenshot.name ?? `manual_step_${h.index + 1}.png`;
2777
+ if (/[/\\]|\.\./.test(name)) {
2778
+ throw new Error(`Invalid screenshot name: "${name}" must not contain path separators or ".."`);
2779
+ }
2780
+ const fileName = name.endsWith(".png") ? name : `${name}.png`;
2781
+ const relative = await h.addScreenshot(fileName);
2782
+ return {
2783
+ kind: "result",
2784
+ result: { type: "screenshot", status: "pass", durationMs: Date.now() - h.stepStart, screenshot: relative }
2785
+ };
2786
+ }
2787
+ },
2788
+ if: {
2789
+ capabilities: ["query"],
2790
+ run: async (h) => {
2791
+ if (!("if" in h.step)) unknownStep();
2792
+ const condition = h.step.if;
2793
+ const selector = condition.visible ?? condition.notVisible;
2794
+ h.policy.assertAllowedSelector(selector);
2795
+ const count = await h.driver.count(selector);
2796
+ const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
2797
+ if (conditionMet) {
2798
+ const subResult = await h.executeNested({
2799
+ steps: condition.then,
2800
+ stepPathPrefix: `${h.stepPath}.if.then`
2801
+ });
2802
+ for (const sr of subResult.results) {
2803
+ h.results.push({ ...sr, type: `if > ${sr.type}` });
2804
+ }
2805
+ h.screenshots.push(...subResult.screenshots);
2806
+ if (subResult.failed) {
2807
+ return { kind: "abort", error: subResult.error };
2808
+ }
2809
+ return {
2810
+ kind: "result",
2811
+ result: {
2054
2812
  type: "if",
2055
2813
  status: "pass",
2056
- durationMs: Date.now() - stepStart,
2814
+ durationMs: Date.now() - h.stepStart,
2057
2815
  value: `condition met, executed ${condition.then.length} steps`
2058
- };
2059
- } else {
2060
- if (condition.else && condition.else.length > 0) {
2061
- const subResult = await executeNestedSteps(context, {
2062
- steps: condition.else,
2063
- stepPathPrefix: `${currentStepPath}.if.else`
2064
- });
2065
- for (const sr of subResult.results) {
2066
- results.push({ ...sr, type: `if > ${sr.type}` });
2067
- }
2068
- screenshots.push(...subResult.screenshots);
2069
- if (subResult.failed) {
2070
- return {
2071
- results,
2072
- screenshots,
2073
- failed: true,
2074
- error: subResult.error
2075
- };
2076
- }
2077
- stepResult = {
2078
- type: "if",
2079
- status: "pass",
2080
- durationMs: Date.now() - stepStart,
2081
- value: `condition not met, executed ${condition.else.length} else steps`
2082
- };
2083
- } else {
2084
- stepResult = {
2085
- type: "if",
2086
- status: "pass",
2087
- durationMs: Date.now() - stepStart,
2088
- value: "condition not met, skipped"
2089
- };
2090
2816
  }
2817
+ };
2818
+ }
2819
+ if (condition.else && condition.else.length > 0) {
2820
+ const subResult = await h.executeNested({
2821
+ steps: condition.else,
2822
+ stepPathPrefix: `${h.stepPath}.if.else`
2823
+ });
2824
+ for (const sr of subResult.results) {
2825
+ h.results.push({ ...sr, type: `if > ${sr.type}` });
2826
+ }
2827
+ h.screenshots.push(...subResult.screenshots);
2828
+ if (subResult.failed) {
2829
+ return { kind: "abort", error: subResult.error };
2091
2830
  }
2092
- } else if ("repeat" in step) {
2093
- const repeat = step.repeat;
2094
- let totalSubSteps = 0;
2095
- if (repeat.times !== void 0) {
2096
- const totalPlanned = repeat.times * repeat.steps.length;
2097
- if (totalPlanned + totalSubSteps > context.maxSteps) {
2098
- throw new Error(`Repeat exceeded maxSteps guardrail (${context.maxSteps})`);
2831
+ return {
2832
+ kind: "result",
2833
+ result: {
2834
+ type: "if",
2835
+ status: "pass",
2836
+ durationMs: Date.now() - h.stepStart,
2837
+ value: `condition not met, executed ${condition.else.length} else steps`
2099
2838
  }
2100
- for (let i = 0; i < repeat.times; i++) {
2101
- totalSubSteps += repeat.steps.length;
2102
- const subResult = await executeNestedSteps(context, {
2103
- steps: repeat.steps,
2104
- stepPathPrefix: `${currentStepPath}.repeat.steps`
2105
- });
2106
- for (const sr of subResult.results) {
2107
- results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
2108
- }
2109
- screenshots.push(...subResult.screenshots);
2110
- if (subResult.failed) {
2111
- return {
2112
- results,
2113
- screenshots,
2114
- failed: true,
2115
- error: subResult.error
2116
- };
2117
- }
2839
+ };
2840
+ }
2841
+ return {
2842
+ kind: "result",
2843
+ result: {
2844
+ type: "if",
2845
+ status: "pass",
2846
+ durationMs: Date.now() - h.stepStart,
2847
+ value: "condition not met, skipped"
2848
+ }
2849
+ };
2850
+ }
2851
+ },
2852
+ repeat: {
2853
+ capabilities: ["query"],
2854
+ run: async (h) => {
2855
+ if (!("repeat" in h.step)) unknownStep();
2856
+ const repeat = h.step.repeat;
2857
+ let totalSubSteps = 0;
2858
+ if (repeat.times !== void 0) {
2859
+ const totalPlanned = repeat.times * repeat.steps.length;
2860
+ if (totalPlanned + totalSubSteps > h.context.maxSteps) {
2861
+ throw new Error(`Repeat exceeded maxSteps guardrail (${h.context.maxSteps})`);
2862
+ }
2863
+ for (let i = 0; i < repeat.times; i++) {
2864
+ totalSubSteps += repeat.steps.length;
2865
+ const subResult = await h.executeNested({
2866
+ steps: repeat.steps,
2867
+ stepPathPrefix: `${h.stepPath}.repeat.steps`
2868
+ });
2869
+ for (const sr of subResult.results) {
2870
+ h.results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
2118
2871
  }
2119
- } else if (repeat.while !== void 0) {
2120
- const maxIter = repeat.maxIterations;
2121
- const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
2122
- assertAllowedSelector(whileSelector, context.forbiddenSelectors);
2123
- for (let i = 0; i < maxIter; i++) {
2124
- const whileCount = await context.page.locator(whileSelector).count();
2125
- const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
2126
- if (!shouldContinue) break;
2127
- totalSubSteps += repeat.steps.length;
2128
- if (totalSubSteps > context.maxSteps) {
2129
- throw new Error(`Repeat exceeded maxSteps guardrail (${context.maxSteps})`);
2130
- }
2131
- const subResult = await executeNestedSteps(context, {
2132
- steps: repeat.steps,
2133
- stepPathPrefix: `${currentStepPath}.repeat.steps`
2134
- });
2135
- for (const sr of subResult.results) {
2136
- results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
2137
- }
2138
- screenshots.push(...subResult.screenshots);
2139
- if (subResult.failed) {
2140
- return {
2141
- results,
2142
- screenshots,
2143
- failed: true,
2144
- error: subResult.error
2145
- };
2146
- }
2872
+ h.screenshots.push(...subResult.screenshots);
2873
+ if (subResult.failed) {
2874
+ return { kind: "abort", error: subResult.error };
2147
2875
  }
2148
2876
  }
2149
- stepResult = {
2150
- type: "repeat",
2151
- status: "pass",
2152
- durationMs: Date.now() - stepStart
2153
- };
2154
- } else if ("mockRoute" in step) {
2155
- const mock = step.mockRoute;
2156
- const mocks = context.activeMocks ?? /* @__PURE__ */ new Map();
2157
- context.activeMocks = mocks;
2158
- let responseBody;
2159
- if (mock.response.body !== void 0) {
2160
- responseBody = mock.response.body;
2161
- } else {
2162
- const responseFile = mock.response.file;
2163
- if (!responseFile) {
2164
- throw new Error("mock.response must include either body or file");
2877
+ } else if (repeat.while !== void 0) {
2878
+ const maxIter = repeat.maxIterations;
2879
+ const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
2880
+ h.policy.assertAllowedSelector(whileSelector);
2881
+ for (let i = 0; i < maxIter; i++) {
2882
+ const whileCount = await h.driver.count(whileSelector);
2883
+ const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
2884
+ if (!shouldContinue) break;
2885
+ totalSubSteps += repeat.steps.length;
2886
+ if (totalSubSteps > h.context.maxSteps) {
2887
+ throw new Error(`Repeat exceeded maxSteps guardrail (${h.context.maxSteps})`);
2888
+ }
2889
+ const subResult = await h.executeNested({
2890
+ steps: repeat.steps,
2891
+ stepPathPrefix: `${h.stepPath}.repeat.steps`
2892
+ });
2893
+ for (const sr of subResult.results) {
2894
+ h.results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
2165
2895
  }
2166
- const candidateFilePath = import_node_path4.default.isAbsolute(responseFile) ? responseFile : import_node_path4.default.join(context.configDir, responseFile);
2167
- const resolvedConfigDir = import_node_path4.default.resolve(context.configDir);
2168
- const resolvedFilePath = import_node_path4.default.resolve(candidateFilePath);
2169
- const relativePath = import_node_path4.default.relative(resolvedConfigDir, resolvedFilePath);
2170
- const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path4.default.sep}`) && !import_node_path4.default.isAbsolute(relativePath);
2171
- if (!isWithinConfigDir) {
2172
- throw new Error("mock.response.file must resolve within config directory");
2896
+ h.screenshots.push(...subResult.screenshots);
2897
+ if (subResult.failed) {
2898
+ return { kind: "abort", error: subResult.error };
2173
2899
  }
2174
- responseBody = await import_node_fs4.default.promises.readFile(resolvedFilePath, "utf-8");
2175
2900
  }
2176
- const contentType = mock.response.contentType ?? "application/json";
2177
- const status = mock.response.status;
2178
- await context.page.route(mock.url, (route) => {
2179
- route.fulfill({
2180
- status,
2181
- contentType,
2182
- body: responseBody
2183
- });
2184
- });
2185
- mocks.set(mock.url, async () => {
2186
- await context.page.unroute(mock.url);
2187
- });
2188
- stepResult = {
2189
- type: "mockRoute",
2190
- status: "pass",
2191
- durationMs: Date.now() - stepStart,
2192
- value: mock.url
2193
- };
2194
- } else if ("unmockRoute" in step) {
2195
- const url = typeof step.unmockRoute === "string" ? step.unmockRoute : step.unmockRoute.url;
2196
- const mocks = context.activeMocks;
2197
- if (!mocks || !mocks.has(url)) {
2198
- throw new Error(`No active mock for URL: ${url}`);
2901
+ }
2902
+ return {
2903
+ kind: "result",
2904
+ result: { type: "repeat", status: "pass", durationMs: Date.now() - h.stepStart }
2905
+ };
2906
+ }
2907
+ },
2908
+ mockRoute: {
2909
+ capabilities: ["route"],
2910
+ run: async (h) => {
2911
+ if (!("mockRoute" in h.step)) unknownStep();
2912
+ const mock = h.step.mockRoute;
2913
+ const mocks = h.context.activeMocks ?? /* @__PURE__ */ new Map();
2914
+ h.context.activeMocks = mocks;
2915
+ let responseBody;
2916
+ if (mock.response.body !== void 0) {
2917
+ responseBody = mock.response.body;
2918
+ } else {
2919
+ const responseFile = mock.response.file;
2920
+ if (!responseFile) {
2921
+ throw new Error("mock.response must include either body or file");
2199
2922
  }
2200
- const cleanup = mocks.get(url);
2201
- await cleanup();
2202
- mocks.delete(url);
2203
- stepResult = {
2204
- type: "unmockRoute",
2205
- status: "pass",
2206
- durationMs: Date.now() - stepStart,
2207
- value: url
2208
- };
2209
- } else if ("evalScript" in step) {
2210
- const expression = typeof step.evalScript === "string" ? step.evalScript : step.evalScript.expression;
2211
- const result = await context.page.evaluate(expression);
2212
- const resultStr = String(result);
2213
- if (typeof step.evalScript !== "string" && step.evalScript.as) {
2214
- runtimeVars.set(step.evalScript.as, resultStr);
2923
+ const candidateFilePath = import_node_path6.default.isAbsolute(responseFile) ? responseFile : import_node_path6.default.join(h.context.configDir, responseFile);
2924
+ const resolvedConfigDir = import_node_path6.default.resolve(h.context.configDir);
2925
+ const resolvedFilePath = import_node_path6.default.resolve(candidateFilePath);
2926
+ const relativePath = import_node_path6.default.relative(resolvedConfigDir, resolvedFilePath);
2927
+ const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path6.default.sep}`) && !import_node_path6.default.isAbsolute(relativePath);
2928
+ if (!isWithinConfigDir) {
2929
+ throw new Error("mock.response.file must resolve within config directory");
2215
2930
  }
2216
- stepResult = {
2931
+ responseBody = await import_node_fs6.default.promises.readFile(resolvedFilePath, "utf-8");
2932
+ }
2933
+ const contentType = mock.response.contentType ?? "application/json";
2934
+ const status = mock.response.status;
2935
+ await h.driver.route(mock.url, async (route) => {
2936
+ await route.fulfill({
2937
+ status,
2938
+ contentType,
2939
+ body: responseBody
2940
+ });
2941
+ });
2942
+ mocks.set(mock.url, async () => {
2943
+ await h.driver.unroute(mock.url);
2944
+ });
2945
+ return {
2946
+ kind: "result",
2947
+ result: { type: "mockRoute", status: "pass", durationMs: Date.now() - h.stepStart, value: mock.url }
2948
+ };
2949
+ }
2950
+ },
2951
+ unmockRoute: {
2952
+ capabilities: ["route"],
2953
+ run: async (h) => {
2954
+ if (!("unmockRoute" in h.step)) unknownStep();
2955
+ const url = typeof h.step.unmockRoute === "string" ? h.step.unmockRoute : h.step.unmockRoute.url;
2956
+ const mocks = h.context.activeMocks;
2957
+ if (!mocks || !mocks.has(url)) {
2958
+ throw new Error(`No active mock for URL: ${url}`);
2959
+ }
2960
+ const cleanup = mocks.get(url);
2961
+ await cleanup();
2962
+ mocks.delete(url);
2963
+ return {
2964
+ kind: "result",
2965
+ result: { type: "unmockRoute", status: "pass", durationMs: Date.now() - h.stepStart, value: url }
2966
+ };
2967
+ }
2968
+ },
2969
+ evalScript: {
2970
+ capabilities: ["evaluate"],
2971
+ run: async (h) => {
2972
+ if (!("evalScript" in h.step)) unknownStep();
2973
+ const expression = typeof h.step.evalScript === "string" ? h.step.evalScript : h.step.evalScript.expression;
2974
+ const result = await h.driver.evaluate(expression);
2975
+ const resultStr = String(result);
2976
+ if (typeof h.step.evalScript !== "string" && h.step.evalScript.as) {
2977
+ h.runtimeVars.set(h.step.evalScript.as, resultStr);
2978
+ }
2979
+ return {
2980
+ kind: "result",
2981
+ result: {
2217
2982
  type: "evalScript",
2218
2983
  status: "pass",
2219
- durationMs: Date.now() - stepStart,
2984
+ durationMs: Date.now() - h.stepStart,
2220
2985
  value: resultStr.length > 200 ? resultStr.slice(0, 200) + "\u2026" : resultStr
2221
- };
2222
- } else if ("runScript" in step) {
2223
- const filePath = import_node_path4.default.isAbsolute(step.runScript.file) ? step.runScript.file : import_node_path4.default.join(context.configDir, step.runScript.file);
2224
- const fileContents = import_node_fs4.default.readFileSync(filePath, "utf-8");
2225
- await context.page.evaluate(fileContents);
2226
- stepResult = {
2986
+ }
2987
+ };
2988
+ }
2989
+ },
2990
+ runScript: {
2991
+ capabilities: ["evaluate"],
2992
+ run: async (h) => {
2993
+ if (!("runScript" in h.step)) unknownStep();
2994
+ const filePath = import_node_path6.default.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : import_node_path6.default.join(h.context.configDir, h.step.runScript.file);
2995
+ const fileContents = import_node_fs6.default.readFileSync(filePath, "utf-8");
2996
+ await h.driver.evaluate(fileContents);
2997
+ return {
2998
+ kind: "result",
2999
+ result: {
2227
3000
  type: "runScript",
2228
3001
  status: "pass",
2229
- durationMs: Date.now() - stepStart,
2230
- value: step.runScript.file
3002
+ durationMs: Date.now() - h.stepStart,
3003
+ value: h.step.runScript.file
3004
+ }
3005
+ };
3006
+ }
3007
+ },
3008
+ assertScreenshot: {
3009
+ capabilities: ["screenshot"],
3010
+ run: async (h) => {
3011
+ if (!("assertScreenshot" in h.step)) unknownStep();
3012
+ const { compareScreenshots: compareScreenshots2, ensureBaselineDir: ensureBaselineDir2 } = await Promise.resolve().then(() => (init_visual(), visual_exports));
3013
+ const name = h.step.assertScreenshot.name;
3014
+ const threshold = h.step.assertScreenshot.threshold ?? 0.1;
3015
+ const baselineDir = ensureBaselineDir2(h.context.configDir);
3016
+ const baselinePath = import_node_path6.default.join(baselineDir, `${name}.png`);
3017
+ const currentScreenshotPath = import_node_path6.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
3018
+ import_node_fs6.default.mkdirSync(import_node_path6.default.dirname(currentScreenshotPath), { recursive: true });
3019
+ await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
3020
+ h.screenshots.push(import_node_path6.default.join("screenshots", `${name}-current.png`));
3021
+ if (!import_node_fs6.default.existsSync(baselinePath)) {
3022
+ import_node_fs6.default.copyFileSync(currentScreenshotPath, baselinePath);
3023
+ return {
3024
+ kind: "result",
3025
+ result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
2231
3026
  };
2232
- } else if ("assertScreenshot" in step) {
2233
- const { compareScreenshots: compareScreenshots2, ensureBaselineDir: ensureBaselineDir2 } = await Promise.resolve().then(() => (init_visual(), visual_exports));
2234
- const name = step.assertScreenshot.name;
2235
- const threshold = step.assertScreenshot.threshold ?? 0.1;
2236
- const baselineDir = ensureBaselineDir2(context.configDir);
2237
- const baselinePath = import_node_path4.default.join(baselineDir, `${name}.png`);
2238
- const currentScreenshotPath = import_node_path4.default.join(context.runDir, "screenshots", `${name}-current.png`);
2239
- import_node_fs4.default.mkdirSync(import_node_path4.default.dirname(currentScreenshotPath), { recursive: true });
2240
- await context.page.screenshot({ path: currentScreenshotPath, fullPage: true });
2241
- screenshots.push(import_node_path4.default.join("screenshots", `${name}-current.png`));
2242
- if (!import_node_fs4.default.existsSync(baselinePath)) {
2243
- import_node_fs4.default.copyFileSync(currentScreenshotPath, baselinePath);
2244
- stepResult = {
3027
+ }
3028
+ const diffPath = import_node_path6.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
3029
+ const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
3030
+ if (comparison.match) {
3031
+ return {
3032
+ kind: "result",
3033
+ result: {
2245
3034
  type: "assertScreenshot",
2246
3035
  status: "pass",
2247
- durationMs: Date.now() - stepStart,
2248
- value: "baseline created"
2249
- };
2250
- } else {
2251
- const diffPath = import_node_path4.default.join(context.runDir, "screenshots", `${name}-diff.png`);
2252
- const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
2253
- if (comparison.match) {
2254
- stepResult = {
2255
- type: "assertScreenshot",
2256
- status: "pass",
2257
- durationMs: Date.now() - stepStart,
2258
- value: `diff: ${(comparison.diffPercentage * 100).toFixed(2)}%`
2259
- };
2260
- } else {
2261
- screenshots.push(import_node_path4.default.join("screenshots", `${name}-diff.png`));
2262
- throw new Error(
2263
- `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
2264
- );
3036
+ durationMs: Date.now() - h.stepStart,
3037
+ value: `diff: ${(comparison.diffPercentage * 100).toFixed(2)}%`
2265
3038
  }
2266
- }
2267
- } else if ("copyText" in step) {
2268
- assertAllowedSelector(step.copyText.selector, context.forbiddenSelectors);
2269
- const text = await context.page.locator(step.copyText.selector).textContent();
2270
- if (text === null) {
2271
- throw new Error(`No text content found for selector: ${step.copyText.selector}`);
2272
- }
2273
- runtimeVars.set(step.copyText.as, text);
2274
- stepResult = {
3039
+ };
3040
+ }
3041
+ h.screenshots.push(import_node_path6.default.join("screenshots", `${name}-diff.png`));
3042
+ throw new Error(
3043
+ `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
3044
+ );
3045
+ }
3046
+ },
3047
+ copyText: {
3048
+ capabilities: ["query"],
3049
+ run: async (h) => {
3050
+ if (!("copyText" in h.step)) unknownStep();
3051
+ h.policy.assertAllowedSelector(h.step.copyText.selector);
3052
+ const text = await h.driver.textContent(h.step.copyText.selector);
3053
+ if (text === null) {
3054
+ throw new Error(`No text content found for selector: ${h.step.copyText.selector}`);
3055
+ }
3056
+ h.runtimeVars.set(h.step.copyText.as, text);
3057
+ return {
3058
+ kind: "result",
3059
+ result: {
2275
3060
  type: "copyText",
2276
3061
  status: "pass",
2277
- durationMs: Date.now() - stepStart,
2278
- selector: step.copyText.selector,
3062
+ durationMs: Date.now() - h.stepStart,
3063
+ selector: h.step.copyText.selector,
2279
3064
  value: "[REDACTED]"
2280
- };
2281
- } else if ("waitForDownload" in step) {
2282
- const opts = step.waitForDownload;
2283
- const downloadPromise = context.pendingDownload ?? armDownloadListener(
2284
- context.page,
2285
- opts?.timeout ?? 3e4
2286
- );
2287
- context.pendingDownload = void 0;
2288
- const download = await downloadPromise;
2289
- const suggestedFilename = validateDownloadFilename(download.suggestedFilename());
2290
- if (opts?.filename !== void 0 && suggestedFilename !== opts.filename) {
2291
- throw new Error(
2292
- `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
2293
- );
2294
3065
  }
2295
- const savePath = import_node_path4.default.join(context.runDir, suggestedFilename);
2296
- await download.saveAs(savePath);
2297
- stepResult = {
3066
+ };
3067
+ }
3068
+ },
3069
+ waitForDownload: {
3070
+ capabilities: ["download"],
3071
+ run: async (h) => {
3072
+ if (!("waitForDownload" in h.step)) unknownStep();
3073
+ const opts = h.step.waitForDownload;
3074
+ const downloadPromise = h.context.pendingDownload ?? armDownloadListener(h.driver, opts?.timeout ?? 3e4);
3075
+ h.context.pendingDownload = void 0;
3076
+ const download = await downloadPromise;
3077
+ const suggestedFilename = validateDownloadFilename(download.suggestedFilename());
3078
+ if (opts?.filename !== void 0 && suggestedFilename !== opts.filename) {
3079
+ throw new Error(
3080
+ `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
3081
+ );
3082
+ }
3083
+ const savePath = import_node_path6.default.join(h.context.runDir, suggestedFilename);
3084
+ await download.saveAs(savePath);
3085
+ return {
3086
+ kind: "result",
3087
+ result: {
2298
3088
  type: "waitForDownload",
2299
3089
  status: "pass",
2300
- durationMs: Date.now() - stepStart,
3090
+ durationMs: Date.now() - h.stepStart,
2301
3091
  value: suggestedFilename
2302
- };
2303
- }
2304
- if (!stepResult) {
3092
+ }
3093
+ };
3094
+ }
3095
+ }
3096
+ };
3097
+ async function executeSteps(context) {
3098
+ let driver = context.driver;
3099
+ if (!driver) {
3100
+ if (!context.page) {
3101
+ throw new Error("executeSteps requires a driver or a Playwright page");
3102
+ }
3103
+ driver = createPlaywrightDriver(context.page);
3104
+ }
3105
+ const policy = createRunPolicy(driver, {
3106
+ forbiddenSelectors: context.forbiddenSelectors,
3107
+ allowedDomains: context.allowedDomains,
3108
+ allowedApps: context.allowedApps,
3109
+ maxSteps: context.maxSteps,
3110
+ selfHealing: context.selfHealing
3111
+ });
3112
+ const screenshotsDir = import_node_path6.default.join(context.runDir, "screenshots");
3113
+ import_node_fs6.default.mkdirSync(screenshotsDir, { recursive: true });
3114
+ const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
3115
+ policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
3116
+ const results = [];
3117
+ const screenshots = [];
3118
+ const runStartedAtMs = context.runStartedAtMs ?? Date.now();
3119
+ context.runStartedAtMs = runStartedAtMs;
3120
+ const addScreenshot = async (fileName) => {
3121
+ const fullPath = screenshotPath(screenshotsDir, fileName);
3122
+ await captureScreenshot(driver, fullPath);
3123
+ const relative = import_node_path6.default.join("screenshots", fileName);
3124
+ screenshots.push(relative);
3125
+ return relative;
3126
+ };
3127
+ const executeNested = (overrides) => executeNestedSteps(context, { driver, ...overrides });
3128
+ for (let index = 0; index < context.steps.length; index += 1) {
3129
+ const currentStepPath = stepPath(context.stepPathPrefix, index);
3130
+ if (Date.now() - runStartedAtMs > context.maxTotalTimeMs) {
3131
+ results.push({
3132
+ type: "timeout",
3133
+ status: "fail",
3134
+ durationMs: 0,
3135
+ error: `Max total time exceeded (${context.maxTotalTimeMs}ms)`
3136
+ });
3137
+ return { results, screenshots, failed: true, error: "Max total time exceeded" };
3138
+ }
3139
+ const runtimeVars = context.runtimeVars ?? /* @__PURE__ */ new Map();
3140
+ context.runtimeVars = runtimeVars;
3141
+ let step = context.steps[index];
3142
+ if (runtimeVars.size > 0) {
3143
+ step = applyRuntimeVars(step, runtimeVars);
3144
+ }
3145
+ const nextStep = context.steps[index + 1];
3146
+ if (!isWaitForDownloadStep(step) && context.pendingDownload === void 0 && isWaitForDownloadStep(nextStep)) {
3147
+ context.pendingDownload = armDownloadListener(
3148
+ driver,
3149
+ nextStep.waitForDownload?.timeout ?? 3e4
3150
+ );
3151
+ }
3152
+ const stepStart = Date.now();
3153
+ const stepType = getStepType(step);
3154
+ let stepResult = null;
3155
+ try {
3156
+ const handler = STEP_HANDLERS[stepType];
3157
+ if (!handler) {
2305
3158
  throw new Error("Unknown step type");
2306
3159
  }
3160
+ for (const capability of handler.capabilities) {
3161
+ if (!driver.capabilities.has(capability)) {
3162
+ throw new Error(
3163
+ `Driver does not support capability "${capability}" required by step "${stepType}"`
3164
+ );
3165
+ }
3166
+ }
3167
+ const outcome = await handler.run({
3168
+ driver,
3169
+ policy,
3170
+ context,
3171
+ step,
3172
+ index,
3173
+ stepPath: currentStepPath,
3174
+ stepStart,
3175
+ runtimeVars,
3176
+ results,
3177
+ screenshots,
3178
+ addScreenshot,
3179
+ executeNested
3180
+ });
3181
+ if (outcome.kind === "abort") {
3182
+ return { results, screenshots, failed: true, error: outcome.error };
3183
+ }
3184
+ stepResult = outcome.result;
2307
3185
  if (context.screenshotsMode === "all" && stepResult.type !== "screenshot") {
2308
3186
  const fileName = `step_${index + 1}.png`;
2309
3187
  await addScreenshot(fileName);
@@ -2330,12 +3208,12 @@ async function executeSteps(context) {
2330
3208
  return { results, screenshots, failed: false };
2331
3209
  }
2332
3210
  async function captureFinalScreenshot(page, runDir) {
2333
- const screenshotsDir = import_node_path4.default.join(runDir, "screenshots");
2334
- import_node_fs4.default.mkdirSync(screenshotsDir, { recursive: true });
3211
+ const screenshotsDir = import_node_path6.default.join(runDir, "screenshots");
3212
+ import_node_fs6.default.mkdirSync(screenshotsDir, { recursive: true });
2335
3213
  const fileName = "final.png";
2336
3214
  const filePath = screenshotPath(screenshotsDir, fileName);
2337
3215
  await captureScreenshot(page, filePath);
2338
- return import_node_path4.default.join("screenshots", fileName);
3216
+ return import_node_path6.default.join("screenshots", fileName);
2339
3217
  }
2340
3218
 
2341
3219
  // src/runner/assertions.ts
@@ -2495,18 +3373,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
2495
3373
  }
2496
3374
 
2497
3375
  // src/reporter/result.ts
2498
- var import_node_fs5 = __toESM(require("fs"), 1);
2499
- var import_node_path5 = __toESM(require("path"), 1);
3376
+ var import_node_fs7 = __toESM(require("fs"), 1);
3377
+ var import_node_path7 = __toESM(require("path"), 1);
2500
3378
  function writeResult(runDir, result) {
2501
3379
  const fileName = "result.json";
2502
- const fullPath = import_node_path5.default.join(runDir, fileName);
2503
- import_node_fs5.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
3380
+ const fullPath = import_node_path7.default.join(runDir, fileName);
3381
+ import_node_fs7.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
2504
3382
  return fileName;
2505
3383
  }
2506
3384
 
2507
3385
  // src/reporter/summary.ts
2508
- var import_node_fs6 = __toESM(require("fs"), 1);
2509
- var import_node_path6 = __toESM(require("path"), 1);
3386
+ var import_node_fs8 = __toESM(require("fs"), 1);
3387
+ var import_node_path8 = __toESM(require("path"), 1);
2510
3388
  function escapeMd(text) {
2511
3389
  return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
2512
3390
  }
@@ -2584,15 +3462,15 @@ function writeSummary(runDir, result) {
2584
3462
  }
2585
3463
  }
2586
3464
  const fileName = "summary.md";
2587
- const fullPath = import_node_path6.default.join(runDir, fileName);
2588
- import_node_fs6.default.writeFileSync(fullPath, `${lines.join("\n")}
3465
+ const fullPath = import_node_path8.default.join(runDir, fileName);
3466
+ import_node_fs8.default.writeFileSync(fullPath, `${lines.join("\n")}
2589
3467
  `);
2590
3468
  return fileName;
2591
3469
  }
2592
3470
 
2593
3471
  // src/reporter/junit.ts
2594
- var import_node_fs7 = __toESM(require("fs"), 1);
2595
- var import_node_path7 = __toESM(require("path"), 1);
3472
+ var import_node_fs9 = __toESM(require("fs"), 1);
3473
+ var import_node_path9 = __toESM(require("path"), 1);
2596
3474
  function escapeXml(text) {
2597
3475
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
2598
3476
  }
@@ -2636,8 +3514,8 @@ function writeJunit(runDir, result) {
2636
3514
  lines.push(" </testsuite>");
2637
3515
  lines.push("</testsuites>");
2638
3516
  const fileName = "junit.xml";
2639
- const fullPath = import_node_path7.default.join(runDir, fileName);
2640
- import_node_fs7.default.writeFileSync(fullPath, `${lines.join("\n")}
3517
+ const fullPath = import_node_path9.default.join(runDir, fileName);
3518
+ import_node_fs9.default.writeFileSync(fullPath, `${lines.join("\n")}
2641
3519
  `);
2642
3520
  return fileName;
2643
3521
  }
@@ -2671,15 +3549,15 @@ function timestamp(prefix) {
2671
3549
  }
2672
3550
 
2673
3551
  // src/runner/history.ts
2674
- var import_node_fs8 = __toESM(require("fs"), 1);
2675
- var import_node_path8 = __toESM(require("path"), 1);
3552
+ var import_node_fs10 = __toESM(require("fs"), 1);
3553
+ var import_node_path10 = __toESM(require("path"), 1);
2676
3554
  var HISTORY_FILE = "history.json";
2677
3555
  var LOCK_FILE_SUFFIX = ".lock";
2678
3556
  var LOCK_RETRY_MS = 10;
2679
3557
  var LOCK_TIMEOUT_MS = 5e3;
2680
3558
  var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
2681
3559
  function historyPath(configDir) {
2682
- return import_node_path8.default.join(configDir, HISTORY_FILE);
3560
+ return import_node_path10.default.join(configDir, HISTORY_FILE);
2683
3561
  }
2684
3562
  function isHistoryEntry(value) {
2685
3563
  if (!value || typeof value !== "object") {
@@ -2690,11 +3568,11 @@ function isHistoryEntry(value) {
2690
3568
  }
2691
3569
  function readHistory(configDir) {
2692
3570
  const filePath = historyPath(configDir);
2693
- if (!import_node_fs8.default.existsSync(filePath)) {
3571
+ if (!import_node_fs10.default.existsSync(filePath)) {
2694
3572
  return { entries: [] };
2695
3573
  }
2696
3574
  try {
2697
- const raw = import_node_fs8.default.readFileSync(filePath, "utf-8");
3575
+ const raw = import_node_fs10.default.readFileSync(filePath, "utf-8");
2698
3576
  const parsed = JSON.parse(raw);
2699
3577
  if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
2700
3578
  const validatedEntries = parsed.entries.filter(isHistoryEntry);
@@ -2733,12 +3611,12 @@ function sleepSync(ms) {
2733
3611
  function withHistoryLock(configDir, fn) {
2734
3612
  const filePath = historyPath(configDir);
2735
3613
  const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
2736
- import_node_fs8.default.mkdirSync(import_node_path8.default.dirname(filePath), { recursive: true });
3614
+ import_node_fs10.default.mkdirSync(import_node_path10.default.dirname(filePath), { recursive: true });
2737
3615
  const startedAt = Date.now();
2738
3616
  while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
2739
3617
  let fd;
2740
3618
  try {
2741
- fd = import_node_fs8.default.openSync(lockPath, "wx");
3619
+ fd = import_node_fs10.default.openSync(lockPath, "wx");
2742
3620
  } catch (error) {
2743
3621
  if (error.code === "EEXIST") {
2744
3622
  sleepSync(LOCK_RETRY_MS);
@@ -2750,10 +3628,10 @@ function withHistoryLock(configDir, fn) {
2750
3628
  return fn();
2751
3629
  } finally {
2752
3630
  try {
2753
- import_node_fs8.default.closeSync(fd);
3631
+ import_node_fs10.default.closeSync(fd);
2754
3632
  } catch {
2755
3633
  }
2756
- import_node_fs8.default.rmSync(lockPath, { force: true });
3634
+ import_node_fs10.default.rmSync(lockPath, { force: true });
2757
3635
  }
2758
3636
  }
2759
3637
  throw new Error(
@@ -2766,9 +3644,9 @@ function appendEntry(configDir, entry, maxRuns) {
2766
3644
  const current = readHistory(configDir);
2767
3645
  const next = pruneEntries([...current.entries, entry], maxRuns);
2768
3646
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
2769
- import_node_fs8.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
3647
+ import_node_fs10.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
2770
3648
  `);
2771
- import_node_fs8.default.renameSync(tempPath, filePath);
3649
+ import_node_fs10.default.renameSync(tempPath, filePath);
2772
3650
  });
2773
3651
  }
2774
3652
 
@@ -2781,11 +3659,11 @@ function parseViewportFlag(value) {
2781
3659
  return value;
2782
3660
  }
2783
3661
  function resolvePath(configDir, inputPath) {
2784
- if (import_node_path9.default.isAbsolute(inputPath)) {
3662
+ if (import_node_path11.default.isAbsolute(inputPath)) {
2785
3663
  return inputPath;
2786
3664
  }
2787
- const projectRoot = import_node_path9.default.dirname(configDir);
2788
- return import_node_path9.default.join(projectRoot, inputPath);
3665
+ const projectRoot = import_node_path11.default.dirname(configDir);
3666
+ return import_node_path11.default.join(projectRoot, inputPath);
2789
3667
  }
2790
3668
  function buildRunResult(options) {
2791
3669
  return {
@@ -2804,12 +3682,12 @@ function buildRunResult(options) {
2804
3682
  }
2805
3683
  function writeConsoleLog(runDir, entries) {
2806
3684
  const fileName = "console.log";
2807
- const filePath = import_node_path9.default.join(runDir, fileName);
3685
+ const filePath = import_node_path11.default.join(runDir, fileName);
2808
3686
  const lines = entries.map((entry) => {
2809
3687
  const location = entry.location ? ` (${entry.location})` : "";
2810
3688
  return `[${entry.type}] ${entry.text}${location}`;
2811
3689
  });
2812
- import_node_fs9.default.writeFileSync(filePath, `${lines.join("\n")}
3690
+ import_node_fs11.default.writeFileSync(filePath, `${lines.join("\n")}
2813
3691
  `);
2814
3692
  return fileName;
2815
3693
  }
@@ -2817,8 +3695,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
2817
3695
  const headless = options.headed ? false : config.browser.headless;
2818
3696
  const slowMo = options.slowMo ?? config.browser.slowMo;
2819
3697
  const maxSteps = config.guardrails.maxSteps;
2820
- const runDir = import_node_path9.default.join(configDir, "runs", timestamp());
2821
- import_node_fs9.default.mkdirSync(runDir, { recursive: true });
3698
+ const runDir = import_node_path11.default.join(configDir, "runs", timestamp());
3699
+ import_node_fs11.default.mkdirSync(runDir, { recursive: true });
2822
3700
  const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
2823
3701
  const engine = options.browser ?? config.browser.engine;
2824
3702
  const channel = options.channel ?? config.browser.channel;
@@ -2837,6 +3715,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
2837
3715
  });
2838
3716
  let result;
2839
3717
  try {
3718
+ const driver = createPlaywrightDriver(session.page);
2840
3719
  const consoleEntries = [];
2841
3720
  const networkEntries = [];
2842
3721
  const traceCorrelations = [];
@@ -2848,7 +3727,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
2848
3727
  location: message.location().url
2849
3728
  });
2850
3729
  });
2851
- session.page.on("response", (response) => {
3730
+ driver.onResponse((response) => {
2852
3731
  if (response.status() >= 400) {
2853
3732
  networkEntries.push({ url: response.url(), status: response.status() });
2854
3733
  captureTraceCorrelation(response, traceHeader, traceCorrelations, redactionValues);
@@ -2862,6 +3741,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
2862
3741
  try {
2863
3742
  const stepExecution = await executeSteps({
2864
3743
  page: session.page,
3744
+ driver,
2865
3745
  steps: interpolatedHunt.steps,
2866
3746
  targetUrl,
2867
3747
  runDir,
@@ -2894,7 +3774,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
2894
3774
  }
2895
3775
  let finalScreenshot;
2896
3776
  try {
2897
- finalScreenshot = await captureFinalScreenshot(session.page, runDir);
3777
+ finalScreenshot = await captureFinalScreenshot(driver, runDir);
2898
3778
  } catch {
2899
3779
  finalScreenshot = void 0;
2900
3780
  }
@@ -2938,6 +3818,9 @@ function delay(ms) {
2938
3818
  }
2939
3819
  async function runHunt(options) {
2940
3820
  const { config, configDir } = loadConfig(options.configPath);
3821
+ if (config.target.type === "macos") {
3822
+ return runMacHunt(options, config, configDir, config.target);
3823
+ }
2941
3824
  const hunt = loadHunt(options.huntName, configDir);
2942
3825
  const {
2943
3826
  hunt: interpolatedHunt,
@@ -2988,9 +3871,124 @@ async function runHunt(options) {
2988
3871
  }
2989
3872
  return lastResult;
2990
3873
  }
3874
+ async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
3875
+ const maxSteps = config.guardrails.maxSteps;
3876
+ const runDir = import_node_path11.default.join(configDir, "runs", timestamp());
3877
+ import_node_fs11.default.mkdirSync(runDir, { recursive: true });
3878
+ const session = await launchMacSession({
3879
+ app: target.app,
3880
+ timeoutMs: config.browser.timeout,
3881
+ clientFactory: options.macClientFactory
3882
+ });
3883
+ let result;
3884
+ try {
3885
+ const targetLabel = `macos:${session.bundleId}`;
3886
+ const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, target.app, session.bundleId])];
3887
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3888
+ const startTime = Date.now();
3889
+ let stepResults = [];
3890
+ let stepScreenshots = [];
3891
+ let stepFailed = false;
3892
+ try {
3893
+ const stepExecution = await executeSteps({
3894
+ driver: session.driver,
3895
+ steps: interpolatedHunt.steps,
3896
+ targetUrl: targetLabel,
3897
+ runDir,
3898
+ screenshotsMode: config.artifacts.screenshots,
3899
+ forbiddenSelectors: config.guardrails.forbiddenSelectors,
3900
+ allowedDomains: [],
3901
+ allowedApps: effectiveAllowedApps,
3902
+ maxSteps,
3903
+ maxTotalTimeMs: config.assertions.maxTotalTimeMs,
3904
+ selfHealing: config.guardrails.selfHealing,
3905
+ redactedFillSteps,
3906
+ randomVars,
3907
+ configDir,
3908
+ huntStack: [options.huntName],
3909
+ onStep: options.onStep
3910
+ });
3911
+ stepResults = stepExecution.results;
3912
+ stepScreenshots = stepExecution.screenshots;
3913
+ stepFailed = stepExecution.failed;
3914
+ } catch (error) {
3915
+ const message = error instanceof Error ? error.message : "Step execution failed";
3916
+ stepResults = [{ type: "steps", status: "fail", durationMs: 0, error: message }];
3917
+ stepFailed = true;
3918
+ }
3919
+ let finalScreenshot;
3920
+ try {
3921
+ finalScreenshot = await captureFinalScreenshot(session.driver, runDir);
3922
+ } catch {
3923
+ finalScreenshot = void 0;
3924
+ }
3925
+ const durationMs = Date.now() - startTime;
3926
+ const status = stepFailed ? "fail" : "pass";
3927
+ const artifacts = {
3928
+ screenshots: finalScreenshot ? [...stepScreenshots, finalScreenshot] : stepScreenshots
3929
+ };
3930
+ const runResult = buildRunResult({
3931
+ status,
3932
+ startedAt,
3933
+ durationMs,
3934
+ hunt: options.huntName,
3935
+ targetUrl: targetLabel,
3936
+ steps: stepResults,
3937
+ assertions: [],
3938
+ artifacts
3939
+ });
3940
+ result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
3941
+ } finally {
3942
+ await closeMacSession(session);
3943
+ }
3944
+ return { result, runDir, steps: interpolatedHunt.steps };
3945
+ }
3946
+ async function runMacHunt(options, config, configDir, target) {
3947
+ const hunt = loadHunt(options.huntName, configDir);
3948
+ const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
3949
+ assertStepsSupportedByTarget(interpolatedHunt.steps, "macos");
3950
+ assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, "macos");
3951
+ assertTargetAppAllowed(config.guardrails.allowedApps, target.app);
3952
+ const maxSteps = config.guardrails.maxSteps;
3953
+ if (interpolatedHunt.steps.length > maxSteps) {
3954
+ throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
3955
+ }
3956
+ const maxRetries = hunt.retry?.maxRetries ?? 0;
3957
+ const retryDelay = hunt.retry?.delay ?? 0;
3958
+ let lastResult;
3959
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
3960
+ if (attempt > 0 && retryDelay > 0) {
3961
+ await delay(retryDelay);
3962
+ }
3963
+ lastResult = await executeMacHuntAttempt(
3964
+ options,
3965
+ config,
3966
+ configDir,
3967
+ target,
3968
+ interpolatedHunt,
3969
+ redactedFillSteps,
3970
+ randomVars,
3971
+ config.guardrails.allowedApps
3972
+ );
3973
+ if (lastResult.result.status === "pass") {
3974
+ if (attempt > 0) {
3975
+ lastResult.result.artifacts.summary = `Passed on attempt ${attempt + 1} of ${maxRetries + 1}`;
3976
+ }
3977
+ recordHistory(configDir, lastResult, config.history.maxRuns);
3978
+ return lastResult;
3979
+ }
3980
+ }
3981
+ if (maxRetries > 0 && lastResult) {
3982
+ lastResult.result.artifacts.summary = `Failed after ${maxRetries + 1} attempts`;
3983
+ }
3984
+ if (lastResult) {
3985
+ recordHistory(configDir, lastResult, config.history.maxRuns);
3986
+ }
3987
+ return lastResult;
3988
+ }
2991
3989
  function recordHistory(configDir, outcome, maxRuns) {
2992
3990
  try {
2993
- const relativeRunDir = import_node_path9.default.relative(configDir, outcome.runDir);
3991
+ const relativeRunDir = import_node_path11.default.relative(configDir, outcome.runDir);
2994
3992
  appendEntry(
2995
3993
  configDir,
2996
3994
  {
@@ -3215,37 +4213,37 @@ function buildRunCommand() {
3215
4213
  }
3216
4214
 
3217
4215
  // src/cli/commands/init.ts
3218
- var import_node_fs10 = __toESM(require("fs"), 1);
3219
- var import_node_path10 = __toESM(require("path"), 1);
3220
- var import_node_url = require("url");
4216
+ var import_node_fs12 = __toESM(require("fs"), 1);
4217
+ var import_node_path12 = __toESM(require("path"), 1);
4218
+ var import_node_url2 = require("url");
3221
4219
  var import_commander2 = require("commander");
3222
4220
  var import_chalk4 = __toESM(require("chalk"), 1);
3223
4221
  init_loader();
3224
- var import_meta = {};
3225
- function getPackageRoot() {
3226
- const currentFile = (0, import_node_url.fileURLToPath)(import_meta.url);
3227
- let dir = import_node_path10.default.dirname(currentFile);
3228
- const root = import_node_path10.default.parse(dir).root;
4222
+ var import_meta2 = {};
4223
+ function getPackageRoot2() {
4224
+ const currentFile = (0, import_node_url2.fileURLToPath)(import_meta2.url);
4225
+ let dir = import_node_path12.default.dirname(currentFile);
4226
+ const root = import_node_path12.default.parse(dir).root;
3229
4227
  while (dir !== root) {
3230
- if (import_node_fs10.default.existsSync(import_node_path10.default.join(dir, "package.json"))) {
4228
+ if (import_node_fs12.default.existsSync(import_node_path12.default.join(dir, "package.json"))) {
3231
4229
  return dir;
3232
4230
  }
3233
- dir = import_node_path10.default.dirname(dir);
4231
+ dir = import_node_path12.default.dirname(dir);
3234
4232
  }
3235
- if (import_node_fs10.default.existsSync(import_node_path10.default.join(root, "package.json"))) {
4233
+ if (import_node_fs12.default.existsSync(import_node_path12.default.join(root, "package.json"))) {
3236
4234
  return root;
3237
4235
  }
3238
4236
  throw new Error("Cannot find package root. Reinstall prowl-tools.");
3239
4237
  }
3240
4238
  function copyFile(source, destination) {
3241
- import_node_fs10.default.mkdirSync(import_node_path10.default.dirname(destination), { recursive: true });
3242
- import_node_fs10.default.copyFileSync(source, destination);
4239
+ import_node_fs12.default.mkdirSync(import_node_path12.default.dirname(destination), { recursive: true });
4240
+ import_node_fs12.default.copyFileSync(source, destination);
3243
4241
  }
3244
4242
  function buildInitCommand() {
3245
4243
  const command = new import_commander2.Command("init").option("--force", `Overwrite existing ${CONFIG_DIR} directory`).action((options) => {
3246
4244
  const root = process.cwd();
3247
- const prowlDir = import_node_path10.default.join(root, CONFIG_DIR);
3248
- if (import_node_fs10.default.existsSync(prowlDir) && !options.force) {
4245
+ const prowlDir = import_node_path12.default.join(root, CONFIG_DIR);
4246
+ if (import_node_fs12.default.existsSync(prowlDir) && !options.force) {
3249
4247
  console.error(
3250
4248
  import_chalk4.default.red(
3251
4249
  `${CONFIG_DIR} already exists. Run with --force to reinitialize prowl configuration without deleting existing files.`
@@ -3254,21 +4252,21 @@ function buildInitCommand() {
3254
4252
  process.exitCode = 1;
3255
4253
  return;
3256
4254
  }
3257
- const packageRoot = getPackageRoot();
3258
- const examplesDir = import_node_path10.default.join(packageRoot, "examples");
3259
- const exampleConfig = import_node_path10.default.join(examplesDir, "config.yml");
3260
- const exampleHuntsDir = import_node_path10.default.join(examplesDir, "hunts");
3261
- if (!import_node_fs10.default.existsSync(exampleConfig) || !import_node_fs10.default.existsSync(exampleHuntsDir)) {
4255
+ const packageRoot = getPackageRoot2();
4256
+ const examplesDir = import_node_path12.default.join(packageRoot, "examples");
4257
+ const exampleConfig = import_node_path12.default.join(examplesDir, "config.yml");
4258
+ const exampleHuntsDir = import_node_path12.default.join(examplesDir, "hunts");
4259
+ if (!import_node_fs12.default.existsSync(exampleConfig) || !import_node_fs12.default.existsSync(exampleHuntsDir)) {
3262
4260
  console.error(import_chalk4.default.red("Examples not found in package. Reinstall prowl-tools."));
3263
4261
  process.exitCode = 1;
3264
4262
  return;
3265
4263
  }
3266
- copyFile(exampleConfig, import_node_path10.default.join(prowlDir, "config.yml"));
3267
- const huntFiles = import_node_fs10.default.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
4264
+ copyFile(exampleConfig, import_node_path12.default.join(prowlDir, "config.yml"));
4265
+ const huntFiles = import_node_fs12.default.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
3268
4266
  for (const huntFile of huntFiles) {
3269
4267
  copyFile(
3270
- import_node_path10.default.join(exampleHuntsDir, huntFile),
3271
- import_node_path10.default.join(prowlDir, "hunts", huntFile)
4268
+ import_node_path12.default.join(exampleHuntsDir, huntFile),
4269
+ import_node_path12.default.join(prowlDir, "hunts", huntFile)
3272
4270
  );
3273
4271
  }
3274
4272
  const gitignore = [
@@ -3282,7 +4280,7 @@ function buildInitCommand() {
3282
4280
  ".env",
3283
4281
  ""
3284
4282
  ].join("\n");
3285
- import_node_fs10.default.writeFileSync(import_node_path10.default.join(prowlDir, ".gitignore"), gitignore);
4283
+ import_node_fs12.default.writeFileSync(import_node_path12.default.join(prowlDir, ".gitignore"), gitignore);
3286
4284
  console.log(welcomeBanner());
3287
4285
  console.log(import_chalk4.default.green(` Initialized ${CONFIG_DIR} directory.`));
3288
4286
  console.log(import_chalk4.default.gray(" Run ") + import_chalk4.default.bold("prowl run hello") + import_chalk4.default.gray(" to get started."));
@@ -3292,18 +4290,17 @@ function buildInitCommand() {
3292
4290
  }
3293
4291
 
3294
4292
  // src/cli/commands/login.ts
3295
- var import_node_path11 = __toESM(require("path"), 1);
4293
+ var import_node_path13 = __toESM(require("path"), 1);
3296
4294
  var import_node_readline = __toESM(require("readline"), 1);
3297
4295
  var import_chalk5 = __toESM(require("chalk"), 1);
3298
4296
  var import_commander3 = require("commander");
3299
- var import_playwright2 = require("playwright");
3300
4297
  init_loader();
3301
4298
  function resolvePath2(configDir, inputPath) {
3302
- if (import_node_path11.default.isAbsolute(inputPath)) {
4299
+ if (import_node_path13.default.isAbsolute(inputPath)) {
3303
4300
  return inputPath;
3304
4301
  }
3305
- const projectRoot = import_node_path11.default.dirname(configDir);
3306
- return import_node_path11.default.join(projectRoot, inputPath);
4302
+ const projectRoot = import_node_path13.default.dirname(configDir);
4303
+ return import_node_path13.default.join(projectRoot, inputPath);
3307
4304
  }
3308
4305
  function waitForEnter(prompt) {
3309
4306
  return new Promise((resolve) => {
@@ -3316,30 +4313,35 @@ function waitForEnter(prompt) {
3316
4313
  }
3317
4314
  function buildLoginCommand() {
3318
4315
  const command = new import_commander3.Command("login").option("--url <target>", "Override target URL").option("--config <path>", "Custom config path").action(async (options) => {
3319
- let browser = null;
3320
- let context = null;
4316
+ let session = null;
3321
4317
  try {
3322
4318
  const { config, configDir } = loadConfig(options.config);
4319
+ if (config.target.type === "macos") {
4320
+ throw new Error("`prowl login` captures browser auth state and only applies to web targets.");
4321
+ }
3323
4322
  const targetUrl = options.url ?? config.target.url;
3324
4323
  const storageStatePath = config.auth.storageStatePath ? resolvePath2(configDir, config.auth.storageStatePath) : resolvePath2(configDir, ".prowl/auth-state.json");
3325
- browser = await import_playwright2.chromium.launch({ headless: false });
3326
- context = await browser.newContext();
3327
- const page = await context.newPage();
3328
- await page.goto(targetUrl);
4324
+ session = await launchBrowser({
4325
+ headless: false,
4326
+ slowMo: 0,
4327
+ timeout: config.browser.timeout,
4328
+ trace: false,
4329
+ recordHar: false,
4330
+ runDir: configDir
4331
+ });
4332
+ const driver = createPlaywrightDriver(session.page);
4333
+ await driver.goto(targetUrl);
3329
4334
  console.log(import_chalk5.default.green("Browser opened. Log in manually."));
3330
4335
  await waitForEnter("Press Enter to save auth state and close the browser... ");
3331
- await context.storageState({ path: storageStatePath });
4336
+ await saveStorageState(session, storageStatePath);
3332
4337
  console.log(import_chalk5.default.green(`Saved auth state to ${storageStatePath}`));
3333
4338
  } catch (error) {
3334
4339
  const message = error instanceof Error ? error.message : "Login failed";
3335
4340
  console.error(import_chalk5.default.red(`Error: ${message}`));
3336
4341
  process.exitCode = 1;
3337
4342
  } finally {
3338
- if (context) {
3339
- await context.close();
3340
- }
3341
- if (browser) {
3342
- await browser.close();
4343
+ if (session) {
4344
+ await closeBrowser(session);
3343
4345
  }
3344
4346
  }
3345
4347
  });
@@ -3385,18 +4387,18 @@ function buildListCommand() {
3385
4387
  }
3386
4388
 
3387
4389
  // src/cli/commands/watch.ts
3388
- var import_node_fs11 = __toESM(require("fs"), 1);
4390
+ var import_node_fs13 = __toESM(require("fs"), 1);
3389
4391
  var import_chalk7 = __toESM(require("chalk"), 1);
3390
4392
  var import_commander5 = require("commander");
3391
4393
  init_loader();
3392
4394
 
3393
4395
  // src/cli/watch-utils.ts
3394
- var import_node_path12 = __toESM(require("path"), 1);
4396
+ var import_node_path14 = __toESM(require("path"), 1);
3395
4397
  function getWatchTargets(configDir, huntName) {
3396
4398
  return [
3397
- import_node_path12.default.join(configDir, "hunts", `${huntName}.yml`),
3398
- import_node_path12.default.join(configDir, "config.yml"),
3399
- import_node_path12.default.join(configDir, ".env")
4399
+ import_node_path14.default.join(configDir, "hunts", `${huntName}.yml`),
4400
+ import_node_path14.default.join(configDir, "config.yml"),
4401
+ import_node_path14.default.join(configDir, ".env")
3400
4402
  ];
3401
4403
  }
3402
4404
  function createDebouncer(delayMs, fn) {
@@ -3465,14 +4467,14 @@ function buildWatchCommand() {
3465
4467
  });
3466
4468
  const unwatch = [];
3467
4469
  for (const target of watchTargets) {
3468
- import_node_fs11.default.watchFile(target, { interval: 150 }, (curr, prev) => {
4470
+ import_node_fs13.default.watchFile(target, { interval: 150 }, (curr, prev) => {
3469
4471
  if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) {
3470
4472
  return;
3471
4473
  }
3472
4474
  console.log(import_chalk7.default.gray(`Change detected: ${target}`));
3473
4475
  debounced.trigger();
3474
4476
  });
3475
- unwatch.push(() => import_node_fs11.default.unwatchFile(target));
4477
+ unwatch.push(() => import_node_fs13.default.unwatchFile(target));
3476
4478
  }
3477
4479
  const stop = () => {
3478
4480
  if (stopped) {
@@ -3498,12 +4500,12 @@ var import_commander6 = require("commander");
3498
4500
  var import_chalk9 = __toESM(require("chalk"), 1);
3499
4501
 
3500
4502
  // src/runner/suite.ts
3501
- var import_node_path15 = __toESM(require("path"), 1);
4503
+ var import_node_path17 = __toESM(require("path"), 1);
3502
4504
  init_loader();
3503
4505
 
3504
4506
  // src/reporter/ci-summary.ts
3505
- var import_node_fs12 = __toESM(require("fs"), 1);
3506
- var import_node_path13 = __toESM(require("path"), 1);
4507
+ var import_node_fs14 = __toESM(require("fs"), 1);
4508
+ var import_node_path15 = __toESM(require("path"), 1);
3507
4509
  var import_chalk8 = __toESM(require("chalk"), 1);
3508
4510
  function countCiResults(results) {
3509
4511
  return {
@@ -3567,9 +4569,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
3567
4569
  ...flaky.length > 0 ? { flaky } : {},
3568
4570
  ...clusters.length > 0 ? { clusters } : {}
3569
4571
  };
3570
- import_node_fs12.default.mkdirSync(ciRunDir, { recursive: true });
3571
- const filePath = import_node_path13.default.join(ciRunDir, "ci-result.json");
3572
- import_node_fs12.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
4572
+ import_node_fs14.default.mkdirSync(ciRunDir, { recursive: true });
4573
+ const filePath = import_node_path15.default.join(ciRunDir, "ci-result.json");
4574
+ import_node_fs14.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
3573
4575
  return filePath;
3574
4576
  }
3575
4577
 
@@ -3703,8 +4705,8 @@ function clusterFailures(failures) {
3703
4705
  }
3704
4706
 
3705
4707
  // src/backlog/index.ts
3706
- var import_node_fs13 = __toESM(require("fs"), 1);
3707
- var import_node_path14 = __toESM(require("path"), 1);
4708
+ var import_node_fs15 = __toESM(require("fs"), 1);
4709
+ var import_node_path16 = __toESM(require("path"), 1);
3708
4710
 
3709
4711
  // src/backlog/parse.ts
3710
4712
  var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
@@ -3794,7 +4796,7 @@ ${after}`;
3794
4796
  // src/backlog/index.ts
3795
4797
  function readFileOrEmpty(filePath) {
3796
4798
  try {
3797
- return import_node_fs13.default.readFileSync(filePath, "utf-8");
4799
+ return import_node_fs15.default.readFileSync(filePath, "utf-8");
3798
4800
  } catch (error) {
3799
4801
  const err = error;
3800
4802
  if (err.code === "ENOENT") return "";
@@ -3810,7 +4812,7 @@ function buildFailure(hunt) {
3810
4812
  if (!hunt.runDir) return failure;
3811
4813
  let run;
3812
4814
  try {
3813
- const resultJson = readFileOrEmpty(import_node_path14.default.join(hunt.runDir, "result.json"));
4815
+ const resultJson = readFileOrEmpty(import_node_path16.default.join(hunt.runDir, "result.json"));
3814
4816
  if (!resultJson) return failure;
3815
4817
  run = JSON.parse(resultJson);
3816
4818
  } catch (error) {
@@ -3839,8 +4841,8 @@ function extractFailures(suiteResult) {
3839
4841
  }
3840
4842
  function updateBacklogFromSuite(suiteResult, options = {}) {
3841
4843
  const projectRoot = options.projectRoot ?? process.cwd();
3842
- const backlogPath = options.backlogPath ?? import_node_path14.default.join(projectRoot, "docs", "backlog.md");
3843
- const resolvedPath = options.resolvedPath ?? import_node_path14.default.join(projectRoot, "docs", "resolved.md");
4844
+ const backlogPath = options.backlogPath ?? import_node_path16.default.join(projectRoot, "docs", "backlog.md");
4845
+ const resolvedPath = options.resolvedPath ?? import_node_path16.default.join(projectRoot, "docs", "resolved.md");
3844
4846
  const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
3845
4847
  const summary = { created: [], regressions: [], skipped: [], backlogPath };
3846
4848
  const failures = extractFailures(suiteResult);
@@ -3872,8 +4874,8 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
3872
4874
  }
3873
4875
  }
3874
4876
  if (ticketsToAdd.length > 0) {
3875
- import_node_fs13.default.mkdirSync(import_node_path14.default.dirname(backlogPath), { recursive: true });
3876
- import_node_fs13.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
4877
+ import_node_fs15.default.mkdirSync(import_node_path16.default.dirname(backlogPath), { recursive: true });
4878
+ import_node_fs15.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
3877
4879
  }
3878
4880
  return summary;
3879
4881
  }
@@ -4034,7 +5036,7 @@ async function runSuite(options = {}) {
4034
5036
  const clusters = clusterFailures(
4035
5037
  extractFailures({ result: { hunts: results }, resultPath: null })
4036
5038
  ).filter((cluster) => cluster.count > 1);
4037
- const ciRunDir = import_node_path15.default.join(configDir, "runs", timestamp("ci"));
5039
+ const ciRunDir = import_node_path17.default.join(configDir, "runs", timestamp("ci"));
4038
5040
  const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
4039
5041
  const { passed, failed, skipped } = countCiResults(results);
4040
5042
  return {
@@ -4152,8 +5154,8 @@ function buildCiCommand() {
4152
5154
  }
4153
5155
 
4154
5156
  // src/cli/commands/update-baselines.ts
4155
- var import_node_fs14 = __toESM(require("fs"), 1);
4156
- var import_node_path16 = __toESM(require("path"), 1);
5157
+ var import_node_fs16 = __toESM(require("fs"), 1);
5158
+ var import_node_path18 = __toESM(require("path"), 1);
4157
5159
  var import_commander7 = require("commander");
4158
5160
  var import_chalk10 = __toESM(require("chalk"), 1);
4159
5161
  init_loader();
@@ -4161,33 +5163,33 @@ function buildUpdateBaselinesCommand() {
4161
5163
  const command = new import_commander7.Command("update-baselines").description("Accept current screenshots as new visual regression baselines").option("--run <dir>", "Specific run directory to use").option("--name <name>", "Update only a specific baseline by name").option("--config <path>", "Custom config path").option("-y, --yes", "Skip confirmation prompt").action(async (options) => {
4162
5164
  try {
4163
5165
  const { configDir } = loadConfig(options.config);
4164
- const baselinesDir = import_node_path16.default.join(configDir, "baselines");
4165
- import_node_fs14.default.mkdirSync(baselinesDir, { recursive: true });
5166
+ const baselinesDir = import_node_path18.default.join(configDir, "baselines");
5167
+ import_node_fs16.default.mkdirSync(baselinesDir, { recursive: true });
4166
5168
  let runDir;
4167
5169
  if (options.run) {
4168
- runDir = import_node_path16.default.isAbsolute(options.run) ? options.run : import_node_path16.default.resolve(options.run);
5170
+ runDir = import_node_path18.default.isAbsolute(options.run) ? options.run : import_node_path18.default.resolve(options.run);
4169
5171
  } else {
4170
- const runsDir = import_node_path16.default.join(configDir, "runs");
4171
- if (!import_node_fs14.default.existsSync(runsDir)) {
5172
+ const runsDir = import_node_path18.default.join(configDir, "runs");
5173
+ if (!import_node_fs16.default.existsSync(runsDir)) {
4172
5174
  console.error(import_chalk10.default.red(" No runs directory found. Run a hunt first."));
4173
5175
  process.exitCode = 1;
4174
5176
  return;
4175
5177
  }
4176
- const entries = import_node_fs14.default.readdirSync(runsDir).filter((e) => import_node_fs14.default.statSync(import_node_path16.default.join(runsDir, e)).isDirectory()).sort().reverse();
5178
+ const entries = import_node_fs16.default.readdirSync(runsDir).filter((e) => import_node_fs16.default.statSync(import_node_path18.default.join(runsDir, e)).isDirectory()).sort().reverse();
4177
5179
  if (entries.length === 0) {
4178
5180
  console.error(import_chalk10.default.red(" No run directories found. Run a hunt first."));
4179
5181
  process.exitCode = 1;
4180
5182
  return;
4181
5183
  }
4182
- runDir = import_node_path16.default.join(runsDir, entries[0]);
5184
+ runDir = import_node_path18.default.join(runsDir, entries[0]);
4183
5185
  }
4184
- const screenshotsDir = import_node_path16.default.join(runDir, "screenshots");
4185
- if (!import_node_fs14.default.existsSync(screenshotsDir)) {
5186
+ const screenshotsDir = import_node_path18.default.join(runDir, "screenshots");
5187
+ if (!import_node_fs16.default.existsSync(screenshotsDir)) {
4186
5188
  console.error(import_chalk10.default.red(` No screenshots found in ${runDir}`));
4187
5189
  process.exitCode = 1;
4188
5190
  return;
4189
5191
  }
4190
- const screenshots = import_node_fs14.default.readdirSync(screenshotsDir).filter((f) => f.endsWith("-current.png"));
5192
+ const screenshots = import_node_fs16.default.readdirSync(screenshotsDir).filter((f) => f.endsWith("-current.png"));
4191
5193
  if (screenshots.length === 0) {
4192
5194
  console.log(import_chalk10.default.yellow(" No assertScreenshot results found in this run."));
4193
5195
  return;
@@ -4201,10 +5203,10 @@ function buildUpdateBaselinesCommand() {
4201
5203
  let updated = 0;
4202
5204
  for (const file of filtered) {
4203
5205
  const baselineName = file.replace("-current.png", ".png");
4204
- const sourcePath = import_node_path16.default.join(screenshotsDir, file);
4205
- const destPath = import_node_path16.default.join(baselinesDir, baselineName);
4206
- const exists = import_node_fs14.default.existsSync(destPath);
4207
- import_node_fs14.default.copyFileSync(sourcePath, destPath);
5206
+ const sourcePath = import_node_path18.default.join(screenshotsDir, file);
5207
+ const destPath = import_node_path18.default.join(baselinesDir, baselineName);
5208
+ const exists = import_node_fs16.default.existsSync(destPath);
5209
+ import_node_fs16.default.copyFileSync(sourcePath, destPath);
4208
5210
  updated++;
4209
5211
  const status = exists ? import_chalk10.default.yellow("updated") : import_chalk10.default.green("created");
4210
5212
  console.log(` ${status} ${baselineName}`);
@@ -4226,7 +5228,21 @@ function buildUpdateBaselinesCommand() {
4226
5228
  // src/cli/commands/analyze.ts
4227
5229
  var import_commander8 = require("commander");
4228
5230
  var import_chalk11 = __toESM(require("chalk"), 1);
4229
- var import_playwright3 = require("playwright");
5231
+
5232
+ // src/browser/engines.ts
5233
+ init_types();
5234
+ function formatSupportedBrowserEngines() {
5235
+ return SUPPORTED_BROWSER_ENGINES.join(", ");
5236
+ }
5237
+ function parseBrowserEngine(value, fallback = "chromium") {
5238
+ if (value === void 0 || value.length === 0) {
5239
+ return fallback;
5240
+ }
5241
+ if (SUPPORTED_BROWSER_ENGINES.includes(value)) {
5242
+ return value;
5243
+ }
5244
+ throw new Error(`Unsupported browser engine "${value}". Use ${formatSupportedBrowserEngines()}.`);
5245
+ }
4230
5246
 
4231
5247
  // src/analyzer/index.ts
4232
5248
  async function analyzePage(page) {
@@ -4345,7 +5361,6 @@ async function analyzePage(page) {
4345
5361
 
4346
5362
  // src/cli/commands/analyze.ts
4347
5363
  init_loader();
4348
- var ENGINES2 = { chromium: import_playwright3.chromium, firefox: import_playwright3.firefox, webkit: import_playwright3.webkit };
4349
5364
  function parseViewportFlag2(value) {
4350
5365
  const match = /^(\d+)x(\d+)$/i.exec(value);
4351
5366
  if (match) {
@@ -4356,19 +5371,24 @@ function parseViewportFlag2(value) {
4356
5371
  function buildAnalyzeCommand() {
4357
5372
  const command = new import_commander8.Command("analyze").argument("<url>", "URL to analyze").description("Analyze a page to discover interactive elements and selectors").option("--json", "Output as JSON").option("--browser <engine>", "Browser engine: chromium, firefox, or webkit").option("--channel <name>", "Browser channel: chrome, msedge, etc.").option("--viewport <size>", "Viewport size: WxH or preset (mobile, tablet, desktop)").option("--headed", "Show browser window").option("--config <path>", "Custom config path").action(async (url, options) => {
4358
5373
  try {
4359
- const engine = options.browser ?? "chromium";
5374
+ const engine = parseBrowserEngine(options.browser);
4360
5375
  const channel = options.channel;
4361
5376
  const viewport = options.viewport ? resolveViewport(parseViewportFlag2(options.viewport)) : { width: 1280, height: 720 };
4362
- const browserEngine = ENGINES2[engine];
4363
- const browser = await browserEngine.launch({
5377
+ const session = await launchBrowser({
4364
5378
  headless: !options.headed,
4365
- channel
5379
+ slowMo: 0,
5380
+ timeout: 3e4,
5381
+ trace: false,
5382
+ recordHar: false,
5383
+ runDir: process.cwd(),
5384
+ engine,
5385
+ channel,
5386
+ viewport
4366
5387
  });
4367
- const context = await browser.newContext({ viewport });
4368
- const page = await context.newPage();
5388
+ const driver = createPlaywrightDriver(session.page);
4369
5389
  try {
4370
- await page.goto(url, { waitUntil: "networkidle" });
4371
- const result = await analyzePage(page);
5390
+ await driver.goto(url, { waitUntil: "networkidle" });
5391
+ const result = await analyzePage(driver);
4372
5392
  if (options.json) {
4373
5393
  console.log(JSON.stringify(result, null, 2));
4374
5394
  } else {
@@ -4413,8 +5433,7 @@ function buildAnalyzeCommand() {
4413
5433
  `));
4414
5434
  }
4415
5435
  } finally {
4416
- await context.close();
4417
- await browser.close();
5436
+ await closeBrowser(session);
4418
5437
  }
4419
5438
  } catch (error) {
4420
5439
  const message = error instanceof Error ? error.message : "Analysis failed";
@@ -4432,15 +5451,14 @@ function buildAnalyzeCommand() {
4432
5451
  }
4433
5452
 
4434
5453
  // src/cli/commands/generate.ts
4435
- var import_node_fs15 = __toESM(require("fs"), 1);
4436
- var import_node_path17 = __toESM(require("path"), 1);
5454
+ var import_node_fs17 = __toESM(require("fs"), 1);
5455
+ var import_node_path19 = __toESM(require("path"), 1);
4437
5456
  var import_commander9 = require("commander");
4438
5457
  var import_chalk12 = __toESM(require("chalk"), 1);
4439
5458
  var import_ora = __toESM(require("ora"), 1);
4440
5459
 
4441
5460
  // src/generator/index.ts
4442
5461
  var import_yaml2 = __toESM(require("yaml"), 1);
4443
- var import_playwright4 = require("playwright");
4444
5462
 
4445
5463
  // src/generator/prompt.ts
4446
5464
  var STEP_REFERENCE = `
@@ -4602,19 +5620,36 @@ async function generateWithOpenAi(prompt, config) {
4602
5620
  }
4603
5621
 
4604
5622
  // src/generator/index.ts
5623
+ init_loader();
4605
5624
  init_schema();
5625
+ function parseViewportFlag3(value) {
5626
+ const match = /^(\d+)x(\d+)$/i.exec(value);
5627
+ if (match) {
5628
+ return { width: Number(match[1]), height: Number(match[2]) };
5629
+ }
5630
+ return value;
5631
+ }
4606
5632
  async function generateHunt(options) {
4607
5633
  let analysis = options.analysis;
4608
5634
  if (!analysis && options.url) {
4609
- const browser = await import_playwright4.chromium.launch({ headless: true });
4610
- const context = await browser.newContext();
4611
- const page = await context.newPage();
5635
+ const engine = parseBrowserEngine(options.browser);
5636
+ const viewport = options.viewport ? resolveViewport(parseViewportFlag3(options.viewport)) : resolveViewport(void 0);
5637
+ const session = await launchBrowser({
5638
+ headless: true,
5639
+ slowMo: 0,
5640
+ timeout: 3e4,
5641
+ trace: false,
5642
+ recordHar: false,
5643
+ runDir: process.cwd(),
5644
+ engine,
5645
+ viewport
5646
+ });
5647
+ const driver = createPlaywrightDriver(session.page);
4612
5648
  try {
4613
- await page.goto(options.url, { waitUntil: "networkidle" });
4614
- analysis = await analyzePage(page);
5649
+ await driver.goto(options.url, { waitUntil: "networkidle" });
5650
+ analysis = await analyzePage(driver);
4615
5651
  } finally {
4616
- await context.close();
4617
- await browser.close();
5652
+ await closeBrowser(session);
4618
5653
  }
4619
5654
  }
4620
5655
  if (!analysis) {
@@ -4686,13 +5721,13 @@ function buildGenerateCommand() {
4686
5721
  const result = loadConfig2(options.config);
4687
5722
  configDir = result.configDir;
4688
5723
  } catch {
4689
- configDir = import_node_path17.default.join(process.cwd(), ".prowl");
5724
+ configDir = import_node_path19.default.join(process.cwd(), ".prowl");
4690
5725
  }
4691
- const huntsDir = import_node_path17.default.join(configDir, "hunts");
4692
- import_node_fs15.default.mkdirSync(huntsDir, { recursive: true });
5726
+ const huntsDir = import_node_path19.default.join(configDir, "hunts");
5727
+ import_node_fs17.default.mkdirSync(huntsDir, { recursive: true });
4693
5728
  const fileName = options.output.endsWith(".yml") ? options.output : `${options.output}.yml`;
4694
- const filePath = import_node_path17.default.join(huntsDir, fileName);
4695
- import_node_fs15.default.writeFileSync(filePath, yamlStr + "\n", "utf-8");
5729
+ const filePath = import_node_path19.default.join(huntsDir, fileName);
5730
+ import_node_fs17.default.writeFileSync(filePath, yamlStr + "\n", "utf-8");
4696
5731
  console.log(import_chalk12.default.green(` Saved to ${filePath}`));
4697
5732
  } else {
4698
5733
  console.log(yamlStr);
@@ -4889,7 +5924,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
4889
5924
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
4890
5925
 
4891
5926
  // src/mcp/tools.ts
4892
- var import_node_path18 = __toESM(require("path"), 1);
5927
+ var import_node_path20 = __toESM(require("path"), 1);
4893
5928
  init_loader();
4894
5929
  function listHuntsTool(configPath) {
4895
5930
  const { configDir } = loadConfig(configPath);
@@ -4897,7 +5932,7 @@ function listHuntsTool(configPath) {
4897
5932
  }
4898
5933
  async function runSuiteTool(args = {}, options = {}) {
4899
5934
  const { configPath: resolvedConfigPath, configDir, config } = loadConfig(options.configPath);
4900
- const projectRoot = options.projectRoot ?? import_node_path18.default.dirname(configDir);
5935
+ const projectRoot = options.projectRoot ?? import_node_path20.default.dirname(configDir);
4901
5936
  const suite = await runSuite({
4902
5937
  configPath: resolvedConfigPath,
4903
5938
  includeTags: args.includeTags,
@@ -4906,8 +5941,8 @@ async function runSuiteTool(args = {}, options = {}) {
4906
5941
  });
4907
5942
  const bugLogCfg = config.bugLog ?? {};
4908
5943
  const logBugs = args.logBugs ?? bugLogCfg.enabled ?? true;
4909
- const backlogPath = bugLogCfg.backlogPath ? import_node_path18.default.resolve(projectRoot, bugLogCfg.backlogPath) : void 0;
4910
- const resolvedPath = bugLogCfg.resolvedPath ? import_node_path18.default.resolve(projectRoot, bugLogCfg.resolvedPath) : backlogPath ? import_node_path18.default.join(import_node_path18.default.dirname(backlogPath), "resolved.md") : void 0;
5944
+ const backlogPath = bugLogCfg.backlogPath ? import_node_path20.default.resolve(projectRoot, bugLogCfg.backlogPath) : void 0;
5945
+ const resolvedPath = bugLogCfg.resolvedPath ? import_node_path20.default.resolve(projectRoot, bugLogCfg.resolvedPath) : backlogPath ? import_node_path20.default.join(import_node_path20.default.dirname(backlogPath), "resolved.md") : void 0;
4911
5946
  const bugs = logBugs ? updateBacklogFromSuite(suite, { projectRoot, backlogPath, resolvedPath }) : { created: [], regressions: [], skipped: [], backlogPath: null };
4912
5947
  const { status, totalHunts, passed, failed, skipped } = suite.result;
4913
5948
  return {
@@ -4934,9 +5969,9 @@ async function runHuntTool(args, configPath) {
4934
5969
  }
4935
5970
 
4936
5971
  // src/mcp/projects.ts
4937
- var import_node_fs16 = __toESM(require("fs"), 1);
5972
+ var import_node_fs18 = __toESM(require("fs"), 1);
4938
5973
  var import_node_os = __toESM(require("os"), 1);
4939
- var import_node_path19 = __toESM(require("path"), 1);
5974
+ var import_node_path21 = __toESM(require("path"), 1);
4940
5975
  var import_yaml3 = __toESM(require("yaml"), 1);
4941
5976
  var import_zod2 = require("zod");
4942
5977
  var projectEntrySchema = import_zod2.z.object({
@@ -4948,37 +5983,37 @@ var projectRegistrySchema = import_zod2.z.object({
4948
5983
  projects: import_zod2.z.record(import_zod2.z.string().min(1), projectEntrySchema)
4949
5984
  }).strict();
4950
5985
  function defaultRegistryPath() {
4951
- return import_node_path19.default.join(import_node_os.default.homedir(), ".prowl", "projects.yml");
5986
+ return import_node_path21.default.join(import_node_os.default.homedir(), ".prowl", "projects.yml");
4952
5987
  }
4953
5988
  function legacyRegistryPath() {
4954
- return import_node_path19.default.join(import_node_os.default.homedir(), ".prowlqa", "projects.yml");
5989
+ return import_node_path21.default.join(import_node_os.default.homedir(), ".prowlqa", "projects.yml");
4955
5990
  }
4956
5991
  function resolveProjectConfigPath(root) {
4957
- const preferred = import_node_path19.default.join(root, ".prowl", "config.yml");
4958
- if (import_node_fs16.default.existsSync(preferred)) return preferred;
4959
- const legacy = import_node_path19.default.join(root, ".prowlqa", "config.yml");
4960
- if (import_node_fs16.default.existsSync(legacy)) return legacy;
5992
+ const preferred = import_node_path21.default.join(root, ".prowl", "config.yml");
5993
+ if (import_node_fs18.default.existsSync(preferred)) return preferred;
5994
+ const legacy = import_node_path21.default.join(root, ".prowlqa", "config.yml");
5995
+ if (import_node_fs18.default.existsSync(legacy)) return legacy;
4961
5996
  return preferred;
4962
5997
  }
4963
5998
  function resolveRegistryRelativePath(registry, inputPath) {
4964
- return import_node_path19.default.isAbsolute(inputPath) ? inputPath : import_node_path19.default.resolve(import_node_path19.default.dirname(registry.registryPath), inputPath);
5999
+ return import_node_path21.default.isAbsolute(inputPath) ? inputPath : import_node_path21.default.resolve(import_node_path21.default.dirname(registry.registryPath), inputPath);
4965
6000
  }
4966
6001
  function resolveRegistryPath(explicitPath) {
4967
- if (explicitPath) return import_node_path19.default.resolve(explicitPath);
6002
+ if (explicitPath) return import_node_path21.default.resolve(explicitPath);
4968
6003
  const envPath = process.env.PROWL_PROJECTS ?? process.env.PROWLQA_PROJECTS;
4969
- if (envPath) return import_node_path19.default.resolve(envPath);
6004
+ if (envPath) return import_node_path21.default.resolve(envPath);
4970
6005
  const fallback = defaultRegistryPath();
4971
- if (import_node_fs16.default.existsSync(fallback)) return fallback;
6006
+ if (import_node_fs18.default.existsSync(fallback)) return fallback;
4972
6007
  const legacy = legacyRegistryPath();
4973
- return import_node_fs16.default.existsSync(legacy) ? legacy : null;
6008
+ return import_node_fs18.default.existsSync(legacy) ? legacy : null;
4974
6009
  }
4975
6010
  function loadProjectRegistry(explicitPath) {
4976
6011
  const registryPath = resolveRegistryPath(explicitPath);
4977
6012
  if (!registryPath) return null;
4978
- if (!import_node_fs16.default.existsSync(registryPath)) {
6013
+ if (!import_node_fs18.default.existsSync(registryPath)) {
4979
6014
  throw new Error(`Project registry not found at ${registryPath}`);
4980
6015
  }
4981
- const raw = import_node_fs16.default.readFileSync(registryPath, "utf-8");
6016
+ const raw = import_node_fs18.default.readFileSync(registryPath, "utf-8");
4982
6017
  const parsed = import_yaml3.default.parse(raw) ?? {};
4983
6018
  const validated = projectRegistrySchema.parse(parsed);
4984
6019
  return { projects: validated.projects, registryPath };