prowl-tools 0.1.4 → 0.1.5

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
@@ -59,7 +59,7 @@ var init_hunt_name = __esm({
59
59
  });
60
60
 
61
61
  // src/config/schema.ts
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;
62
+ var import_zod, webTargetSchema, macosTargetSchema, androidTargetSchema, iosTargetSchema, 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;
63
63
  var init_schema = __esm({
64
64
  "src/config/schema.ts"() {
65
65
  "use strict";
@@ -74,7 +74,24 @@ var init_schema = __esm({
74
74
  type: import_zod.z.literal("macos"),
75
75
  app: import_zod.z.string().min(1)
76
76
  }).strict();
77
- targetSchema = import_zod.z.union([macosTargetSchema, webTargetSchema]);
77
+ androidTargetSchema = import_zod.z.object({
78
+ type: import_zod.z.literal("android"),
79
+ app: import_zod.z.string().min(1),
80
+ deviceSerial: import_zod.z.string().min(1).optional(),
81
+ coldStart: import_zod.z.boolean().optional()
82
+ }).strict();
83
+ iosTargetSchema = import_zod.z.object({
84
+ type: import_zod.z.literal("ios"),
85
+ app: import_zod.z.string().min(1),
86
+ udid: import_zod.z.string().min(1).optional(),
87
+ coldStart: import_zod.z.boolean().optional()
88
+ }).strict();
89
+ targetSchema = import_zod.z.union([
90
+ macosTargetSchema,
91
+ androidTargetSchema,
92
+ iosTargetSchema,
93
+ webTargetSchema
94
+ ]);
78
95
  configSchema = import_zod.z.object({
79
96
  target: targetSchema,
80
97
  browser: import_zod.z.object({
@@ -417,9 +434,28 @@ function resolveViewport(value) {
417
434
  return value;
418
435
  }
419
436
  function resolveTarget(target) {
420
- if (target && target.type === "macos") {
437
+ const type = target?.type;
438
+ if (type === "macos") {
421
439
  return { type: "macos", app: target.app };
422
440
  }
441
+ if (type === "android") {
442
+ const androidTarget = target;
443
+ return {
444
+ type: "android",
445
+ app: androidTarget.app,
446
+ ...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
447
+ ...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
448
+ };
449
+ }
450
+ if (type === "ios") {
451
+ const iosTarget = target;
452
+ return {
453
+ type: "ios",
454
+ app: iosTarget.app,
455
+ ...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
456
+ ...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
457
+ };
458
+ }
423
459
  return {
424
460
  type: "web",
425
461
  url: target?.url ?? DEFAULT_WEB_URL
@@ -628,12 +664,12 @@ __export(visual_exports, {
628
664
  ensureBaselineDir: () => ensureBaselineDir
629
665
  });
630
666
  async function compareScreenshots(baselinePath, currentPath, diffPath, threshold) {
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));
667
+ const baselineData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(baselinePath));
668
+ const currentData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(currentPath));
633
669
  const { width, height } = baselineData;
634
670
  if (currentData.width !== width || currentData.height !== height) {
635
671
  const diff2 = new import_pngjs.PNG({ width, height });
636
- import_node_fs5.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
672
+ import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
637
673
  return {
638
674
  match: false,
639
675
  diffPercentage: 1,
@@ -651,7 +687,7 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
651
687
  );
652
688
  const totalPixels = width * height;
653
689
  const diffPercentage = diffPixels / totalPixels;
654
- import_node_fs5.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
690
+ import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
655
691
  return {
656
692
  match: diffPercentage <= threshold,
657
693
  diffPercentage,
@@ -659,16 +695,16 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
659
695
  };
660
696
  }
661
697
  function ensureBaselineDir(configDir) {
662
- const baselineDir = import_node_path5.default.join(configDir, "baselines");
663
- import_node_fs5.default.mkdirSync(baselineDir, { recursive: true });
698
+ const baselineDir = import_node_path8.default.join(configDir, "baselines");
699
+ import_node_fs8.default.mkdirSync(baselineDir, { recursive: true });
664
700
  return baselineDir;
665
701
  }
666
- var import_node_fs5, import_node_path5, import_pngjs, import_pixelmatch;
702
+ var import_node_fs8, import_node_path8, import_pngjs, import_pixelmatch;
667
703
  var init_visual = __esm({
668
704
  "src/runner/visual.ts"() {
669
705
  "use strict";
670
- import_node_fs5 = __toESM(require("fs"), 1);
671
- import_node_path5 = __toESM(require("path"), 1);
706
+ import_node_fs8 = __toESM(require("fs"), 1);
707
+ import_node_path8 = __toESM(require("path"), 1);
672
708
  import_pngjs = require("pngjs");
673
709
  import_pixelmatch = __toESM(require("pixelmatch"), 1);
674
710
  }
@@ -680,7 +716,7 @@ var import_commander13 = require("commander");
680
716
  // package.json
681
717
  var package_default = {
682
718
  name: "prowl-tools",
683
- version: "0.1.4",
719
+ version: "0.1.5",
684
720
  description: "CLI-first QA testing tool for deterministic Playwright flows.",
685
721
  type: "module",
686
722
  license: "Apache-2.0",
@@ -750,6 +786,10 @@ var package_default = {
750
786
  yaml: "^2.6.1",
751
787
  zod: "^3.23.8"
752
788
  },
789
+ optionalDependencies: {
790
+ "appium-uiautomator2-server": "10.6.2",
791
+ "appium-webdriveragent": "16.4.0"
792
+ },
753
793
  devDependencies: {
754
794
  "@types/node": "^22.13.1",
755
795
  "@types/pngjs": "^6.0.5",
@@ -767,8 +807,8 @@ var import_commander = require("commander");
767
807
  var import_chalk3 = __toESM(require("chalk"), 1);
768
808
 
769
809
  // src/runner/index.ts
770
- var import_node_fs11 = __toESM(require("fs"), 1);
771
- var import_node_path11 = __toESM(require("path"), 1);
810
+ var import_node_fs14 = __toESM(require("fs"), 1);
811
+ var import_node_path14 = __toESM(require("path"), 1);
772
812
  init_loader();
773
813
 
774
814
  // src/config/interpolate.ts
@@ -1171,15 +1211,25 @@ function webOnlyReason(step) {
1171
1211
  }
1172
1212
  return null;
1173
1213
  }
1214
+ function nativeTargetLabel(target) {
1215
+ if (target === "android") {
1216
+ return "Android";
1217
+ }
1218
+ if (target === "ios") {
1219
+ return "iOS";
1220
+ }
1221
+ return "macOS";
1222
+ }
1174
1223
  function assertStepsSupportedByTarget(steps, target) {
1175
- if (target !== "macos") {
1224
+ if (target === "web") {
1176
1225
  return;
1177
1226
  }
1227
+ const label = nativeTargetLabel(target);
1178
1228
  for (const step of steps) {
1179
1229
  const reason = webOnlyReason(step);
1180
1230
  if (reason) {
1181
1231
  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.).`
1232
+ `Step "${reason}" is not supported by the ${label} target. It is web-only; use a portable step (click, fill, type, press, wait, assert visible, screenshot, etc.).`
1183
1233
  );
1184
1234
  }
1185
1235
  if ("if" in step) {
@@ -1194,20 +1244,34 @@ function assertStepsSupportedByTarget(steps, target) {
1194
1244
  }
1195
1245
  }
1196
1246
  function assertHuntAssertionsSupportedByTarget(assertions, target) {
1197
- if (target !== "macos" || !assertions || assertions.length === 0) {
1247
+ if (target === "web" || !assertions || assertions.length === 0) {
1198
1248
  return;
1199
1249
  }
1200
1250
  throw new Error(
1201
- "Hunt-level assertions are not supported by the macOS target. Use inline assert visible/notVisible steps instead."
1251
+ `Hunt-level assertions are not supported by the ${nativeTargetLabel(target)} target. Use inline assert visible/notVisible steps instead.`
1202
1252
  );
1203
1253
  }
1204
1254
  function trimTrailingPathSeparators(value) {
1205
1255
  return value.replace(/[\\/]+$/g, "");
1206
1256
  }
1207
- function looksLikeMacosAppPath(app) {
1257
+ function looksLikeAppBundlePath(app) {
1208
1258
  const trimmed = trimTrailingPathSeparators(app);
1209
1259
  return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
1210
1260
  }
1261
+ function looksLikeIosAppPath(app) {
1262
+ const trimmed = trimTrailingPathSeparators(app);
1263
+ if (trimmed.includes("/") || trimmed.includes("\\")) {
1264
+ return true;
1265
+ }
1266
+ if (trimmed.toLowerCase().endsWith(".app")) {
1267
+ try {
1268
+ return import_node_fs2.default.statSync(normalizeAppPath(app)).isDirectory();
1269
+ } catch {
1270
+ return false;
1271
+ }
1272
+ }
1273
+ return false;
1274
+ }
1211
1275
  function normalizeAppPath(app) {
1212
1276
  return import_node_path2.default.resolve(trimTrailingPathSeparators(app));
1213
1277
  }
@@ -1215,8 +1279,8 @@ function parseBundleIdentifier(plist) {
1215
1279
  const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
1216
1280
  return match?.[1]?.trim() || null;
1217
1281
  }
1218
- function readBundleIdentifier(appPath) {
1219
- const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), "Contents", "Info.plist");
1282
+ function readBundleIdentifier(appPath, ...plistSubPath) {
1283
+ const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), ...plistSubPath);
1220
1284
  if (!import_node_fs2.default.existsSync(infoPlistPath)) {
1221
1285
  return null;
1222
1286
  }
@@ -1241,9 +1305,15 @@ function readBundleIdentifier(appPath) {
1241
1305
  return null;
1242
1306
  }
1243
1307
  }
1244
- function macosAppAllowedIdentities(app) {
1308
+ function readMacosBundleIdentifier(appPath) {
1309
+ return readBundleIdentifier(appPath, "Contents", "Info.plist");
1310
+ }
1311
+ function readIosBundleIdentifier(appPath) {
1312
+ return readBundleIdentifier(appPath, "Info.plist");
1313
+ }
1314
+ function appBundleAllowedIdentities(app, readBundleId, isAppPath = looksLikeAppBundlePath) {
1245
1315
  const identities = /* @__PURE__ */ new Set([app]);
1246
- if (looksLikeMacosAppPath(app)) {
1316
+ if (isAppPath(app)) {
1247
1317
  const normalizedPath = normalizeAppPath(app);
1248
1318
  identities.add(trimTrailingPathSeparators(app));
1249
1319
  identities.add(normalizedPath);
@@ -1251,19 +1321,56 @@ function macosAppAllowedIdentities(app) {
1251
1321
  if (bundleName) {
1252
1322
  identities.add(bundleName);
1253
1323
  }
1254
- const bundleId = readBundleIdentifier(app);
1324
+ const bundleId = readBundleId(app);
1255
1325
  if (bundleId) {
1256
1326
  identities.add(bundleId);
1257
1327
  }
1258
1328
  }
1259
1329
  return [...identities];
1260
1330
  }
1331
+ function macosAppAllowedIdentities(app) {
1332
+ return appBundleAllowedIdentities(app, readMacosBundleIdentifier);
1333
+ }
1334
+ function iosAppAllowedIdentities(app) {
1335
+ return appBundleAllowedIdentities(app, readIosBundleIdentifier, looksLikeIosAppPath);
1336
+ }
1261
1337
  function assertTargetAppAllowed(allowedApps, app) {
1338
+ assertNativeAppAllowed(allowedApps, app, macosAppAllowedIdentities);
1339
+ }
1340
+ function assertIosAppAllowed(allowedApps, app) {
1341
+ assertNativeAppAllowed(allowedApps, app, iosAppAllowedIdentities);
1342
+ }
1343
+ function looksLikeApkPath(app) {
1344
+ const trimmed = trimTrailingPathSeparators(app);
1345
+ return trimmed.includes("/") || trimmed.includes("\\") || trimmed.toLowerCase().endsWith(".apk");
1346
+ }
1347
+ function normalizeAndroidApkPath(app) {
1348
+ const resolved = import_node_path2.default.resolve(trimTrailingPathSeparators(app));
1349
+ try {
1350
+ return import_node_fs2.default.realpathSync.native(resolved);
1351
+ } catch {
1352
+ return resolved;
1353
+ }
1354
+ }
1355
+ function androidAppAllowedIdentities(app, resolvedPackage) {
1356
+ if (looksLikeApkPath(app)) {
1357
+ return resolvedPackage ? [normalizeAndroidApkPath(app), resolvedPackage] : [normalizeAndroidApkPath(app)];
1358
+ }
1359
+ return [app];
1360
+ }
1361
+ function assertAndroidAppAllowed(allowedApps, app, resolvedPackage) {
1362
+ assertNativeAppAllowed(
1363
+ allowedApps,
1364
+ app,
1365
+ (value) => androidAppAllowedIdentities(value, value === app ? resolvedPackage : void 0)
1366
+ );
1367
+ }
1368
+ function assertNativeAppAllowed(allowedApps, app, resolveIdentities) {
1262
1369
  if (allowedApps.length === 0) {
1263
1370
  return;
1264
1371
  }
1265
- const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => macosAppAllowedIdentities(allowedApp)));
1266
- if (macosAppAllowedIdentities(app).some((identity) => allowedIdentities.has(identity))) {
1372
+ const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => resolveIdentities(allowedApp)));
1373
+ if (resolveIdentities(app).some((identity) => allowedIdentities.has(identity))) {
1267
1374
  return;
1268
1375
  }
1269
1376
  throw new Error(
@@ -1641,7 +1748,11 @@ function createMacDriver(client, options = {}) {
1641
1748
  return rejectUnsupported("evalScript");
1642
1749
  },
1643
1750
  async screenshot(screenshotOptions) {
1644
- await client.request("screenshot", { path: screenshotOptions.path });
1751
+ const result = await client.request("screenshot", { path: screenshotOptions.path });
1752
+ const warning = result.warning;
1753
+ if (typeof warning === "string" && warning.length > 0) {
1754
+ console.warn(`macOS screenshot fell back to full screen: ${warning}`);
1755
+ }
1645
1756
  },
1646
1757
  // network / dialogs / downloads (all web-only) -------------------------
1647
1758
  onResponse(_handler) {
@@ -1812,65 +1923,1732 @@ var SpawnMacHelperClient = class {
1812
1923
  });
1813
1924
  });
1814
1925
  }
1815
- async close() {
1816
- if (this.closed) {
1926
+ async close() {
1927
+ if (this.closed) {
1928
+ return;
1929
+ }
1930
+ this.closed = true;
1931
+ try {
1932
+ this.child.stdin?.write(JSON.stringify({ cmd: "shutdown" }) + "\n");
1933
+ this.child.stdin?.end();
1934
+ } catch {
1935
+ }
1936
+ await new Promise((resolve) => {
1937
+ if (this.child.exitCode !== null || this.child.signalCode !== null) {
1938
+ resolve();
1939
+ return;
1940
+ }
1941
+ const timer = setTimeout(() => {
1942
+ this.child.kill("SIGKILL");
1943
+ resolve();
1944
+ }, 2e3);
1945
+ this.child.once("exit", () => {
1946
+ clearTimeout(timer);
1947
+ resolve();
1948
+ });
1949
+ });
1950
+ this.failAll(new Error("prowl-macdriver client is closed"));
1951
+ }
1952
+ };
1953
+ async function launchMacSession(options) {
1954
+ const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_REQUEST_TIMEOUT_MS) + 5e3;
1955
+ const client = options.clientFactory ? options.clientFactory() : new SpawnMacHelperClient(resolveHelperBinary(), { requestTimeoutMs });
1956
+ const timeoutSeconds = (options.timeoutMs ?? 1e4) / 1e3;
1957
+ try {
1958
+ const trust = await client.request("check");
1959
+ if (trust.trusted !== true) {
1960
+ throw new Error(
1961
+ "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."
1962
+ );
1963
+ }
1964
+ const launched = await client.request("launch", { app: options.app, timeout: timeoutSeconds });
1965
+ const bundleId = String(launched.bundleId ?? options.app);
1966
+ const driver = createMacDriver(client, { appLabel: bundleId });
1967
+ return { client, driver, bundleId };
1968
+ } catch (error) {
1969
+ await client.close().catch(() => void 0);
1970
+ throw error;
1971
+ }
1972
+ }
1973
+ async function closeMacSession(session) {
1974
+ try {
1975
+ await session.client.request("quit");
1976
+ } catch {
1977
+ } finally {
1978
+ await session.client.close();
1979
+ }
1980
+ }
1981
+
1982
+ // src/browser/android-helper.ts
1983
+ var import_node_module = require("module");
1984
+ var import_node_child_process4 = require("child_process");
1985
+ var import_node_fs6 = __toESM(require("fs"), 1);
1986
+ var import_node_path5 = __toESM(require("path"), 1);
1987
+
1988
+ // src/browser/android-driver.ts
1989
+ var import_node_fs5 = __toESM(require("fs"), 1);
1990
+ var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
1991
+ "query",
1992
+ "interact",
1993
+ "wait",
1994
+ "screenshot"
1995
+ ]);
1996
+ var WAIT_POLL_INTERVAL_MS = 250;
1997
+ var DEFAULT_WAIT_TIMEOUT_MS = 5e3;
1998
+ var ANDROID_KEYCODES = {
1999
+ enter: 66,
2000
+ return: 66,
2001
+ tab: 61,
2002
+ space: 62,
2003
+ backspace: 67,
2004
+ delete: 67,
2005
+ del: 67,
2006
+ escape: 111,
2007
+ esc: 111,
2008
+ back: 4,
2009
+ home: 3,
2010
+ menu: 82,
2011
+ search: 84,
2012
+ up: 19,
2013
+ arrowup: 19,
2014
+ down: 20,
2015
+ arrowdown: 20,
2016
+ left: 21,
2017
+ arrowleft: 21,
2018
+ right: 22,
2019
+ arrowright: 22,
2020
+ pageup: 92,
2021
+ pagedown: 93
2022
+ };
2023
+ function unquote2(value) {
2024
+ const trimmed = value.trim();
2025
+ const first = trimmed[0];
2026
+ if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
2027
+ return trimmed.slice(1, -1);
2028
+ }
2029
+ return trimmed;
2030
+ }
2031
+ function escapeUiSelectorArg(value) {
2032
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2033
+ }
2034
+ function parseAndroidSelector(selector) {
2035
+ const trimmed = selector.trim();
2036
+ if (trimmed === ":focus") {
2037
+ return { by: "focused" };
2038
+ }
2039
+ const idMatch = /^id=(.+)$/s.exec(trimmed);
2040
+ if (idMatch) {
2041
+ return { by: "id", value: unquote2(idMatch[1]) };
2042
+ }
2043
+ const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
2044
+ if (roleMatch) {
2045
+ const name = roleMatch[2] !== void 0 ? unquote2(roleMatch[2]) : void 0;
2046
+ return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
2047
+ }
2048
+ const labelMatch = /^label=(.+)$/s.exec(trimmed);
2049
+ if (labelMatch) {
2050
+ return { by: "accessibilityId", value: unquote2(labelMatch[1]) };
2051
+ }
2052
+ const textMatch = /^text=(.+)$/s.exec(trimmed);
2053
+ if (textMatch) {
2054
+ return { by: "text", value: unquote2(textMatch[1]) };
2055
+ }
2056
+ return { by: "text", value: trimmed };
2057
+ }
2058
+ function locator(strategy, selector) {
2059
+ return { strategy, selector, context: "" };
2060
+ }
2061
+ function qualifyResourceId(value, appPackage) {
2062
+ if (value.includes(":") || !appPackage) {
2063
+ return value;
2064
+ }
2065
+ return `${appPackage}:id/${value}`;
2066
+ }
2067
+ function androidQueryToLocator(query, options = {}) {
2068
+ switch (query.by) {
2069
+ case "id":
2070
+ return locator("id", qualifyResourceId(query.value, options.appPackage));
2071
+ case "accessibilityId":
2072
+ return locator("accessibility id", query.value);
2073
+ case "text":
2074
+ return locator(
2075
+ "-android uiautomator",
2076
+ `new UiSelector().textContains("${escapeUiSelectorArg(query.value)}")`
2077
+ );
2078
+ case "focused":
2079
+ return locator("-android uiautomator", "new UiSelector().focused(true)");
2080
+ case "role": {
2081
+ const className = escapeUiSelectorArg(query.role);
2082
+ if (query.name === void 0 || query.name.length === 0) {
2083
+ return locator("class name", query.role);
2084
+ }
2085
+ return locator(
2086
+ "-android uiautomator",
2087
+ `new UiSelector().className("${className}").textContains("${escapeUiSelectorArg(query.name)}")`
2088
+ );
2089
+ }
2090
+ }
2091
+ }
2092
+ function unwrapAndroidTextSelector(selector) {
2093
+ const trimmed = selector.trim();
2094
+ if (!trimmed.startsWith("text=")) {
2095
+ return null;
2096
+ }
2097
+ return unquote2(trimmed.slice(5));
2098
+ }
2099
+ function keyCodeFor(key) {
2100
+ const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
2101
+ if (code === void 0) {
2102
+ throw new Error(
2103
+ `Unsupported key "${key}" for the Android target. Supported keys: ${Object.keys(ANDROID_KEYCODES).sort().join(", ")}.`
2104
+ );
2105
+ }
2106
+ return code;
2107
+ }
2108
+ function delay(ms) {
2109
+ return new Promise((resolve) => {
2110
+ setTimeout(resolve, ms);
2111
+ });
2112
+ }
2113
+ function createAndroidDriver(client, options = {}) {
2114
+ const unsupported = (verb) => new Error(`${verb} is not supported by the Android target`);
2115
+ const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
2116
+ async function resolveOne(selector) {
2117
+ const id = await client.findElement(parseAndroidSelector(selector));
2118
+ if (id === null) {
2119
+ throw new Error(`No element matched selector: ${selector}`);
2120
+ }
2121
+ return id;
2122
+ }
2123
+ async function clickSelector(selector) {
2124
+ await client.click(await resolveOne(selector));
2125
+ }
2126
+ async function fillSelector(selector, value) {
2127
+ await client.setValue(await resolveOne(selector), value);
2128
+ }
2129
+ return {
2130
+ capabilities: ANDROID_CAPABILITIES,
2131
+ // navigation -----------------------------------------------------------
2132
+ goto(_url, _options) {
2133
+ return rejectUnsupported("navigate");
2134
+ },
2135
+ currentUrl() {
2136
+ return `android:${options.appLabel ?? ""}`;
2137
+ },
2138
+ // queries --------------------------------------------------------------
2139
+ async count(selector) {
2140
+ return (await client.findElements(parseAndroidSelector(selector))).length;
2141
+ },
2142
+ async textContent(selector) {
2143
+ const id = await client.findElement(parseAndroidSelector(selector));
2144
+ if (id === null) {
2145
+ return null;
2146
+ }
2147
+ return client.getText(id);
2148
+ },
2149
+ // interactions ---------------------------------------------------------
2150
+ click: clickSelector,
2151
+ clickFirst: clickSelector,
2152
+ fill: fillSelector,
2153
+ fillFirst: fillSelector,
2154
+ async press(_selector, key) {
2155
+ await client.pressKeyCode(keyCodeFor(key));
2156
+ },
2157
+ selectOption() {
2158
+ return rejectUnsupported("select");
2159
+ },
2160
+ selectOptionFirst() {
2161
+ return rejectUnsupported("select");
2162
+ },
2163
+ hover() {
2164
+ return rejectUnsupported("hover");
2165
+ },
2166
+ scrollIntoView() {
2167
+ return rejectUnsupported("scrollTo");
2168
+ },
2169
+ setInputFiles() {
2170
+ return rejectUnsupported("setInputFiles");
2171
+ },
2172
+ // semantic locators ----------------------------------------------------
2173
+ async countByRole(role, name) {
2174
+ return (await client.findElements({ by: "role", role, name })).length;
2175
+ },
2176
+ async clickFirstByRole(role, name) {
2177
+ const id = await client.findElement({ by: "role", role, name });
2178
+ if (id === null) {
2179
+ throw new Error(`No element matched role=${role}[name="${name}"]`);
2180
+ }
2181
+ await client.click(id);
2182
+ },
2183
+ async countByLabel(label) {
2184
+ return (await client.findElements({ by: "accessibilityId", value: label })).length;
2185
+ },
2186
+ async fillFirstByLabel(label, value) {
2187
+ const id = await client.findElement({ by: "accessibilityId", value: label });
2188
+ if (id === null) {
2189
+ throw new Error(`No element matched label="${label}"`);
2190
+ }
2191
+ await client.setValue(id, value);
2192
+ },
2193
+ selectOptionFirstByLabel() {
2194
+ return rejectUnsupported("select");
2195
+ },
2196
+ // waiting --------------------------------------------------------------
2197
+ async waitForSelector(selector, waitOptions) {
2198
+ const query = parseAndroidSelector(selector);
2199
+ const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS;
2200
+ const deadline = Date.now() + timeoutMs;
2201
+ for (; ; ) {
2202
+ if ((await client.findElements(query)).length > 0) {
2203
+ return;
2204
+ }
2205
+ if (Date.now() >= deadline) {
2206
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
2207
+ }
2208
+ await delay(Math.min(WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
2209
+ }
2210
+ },
2211
+ waitForUrl() {
2212
+ return rejectUnsupported("waitForUrl");
2213
+ },
2214
+ waitForNetworkIdle() {
2215
+ return rejectUnsupported("waitForNetworkIdle");
2216
+ },
2217
+ // scripting & artifacts ------------------------------------------------
2218
+ evaluate() {
2219
+ return rejectUnsupported("evalScript");
2220
+ },
2221
+ async screenshot(screenshotOptions) {
2222
+ const png = await client.screenshotPng();
2223
+ import_node_fs5.default.writeFileSync(screenshotOptions.path, png);
2224
+ },
2225
+ // network / dialogs / downloads (all web-only) -------------------------
2226
+ onResponse(_handler) {
2227
+ throw unsupported("onResponse");
2228
+ },
2229
+ route(_url, _handler) {
2230
+ return rejectUnsupported("mockRoute");
2231
+ },
2232
+ unroute() {
2233
+ return rejectUnsupported("unmockRoute");
2234
+ },
2235
+ onDialog(_action) {
2236
+ throw unsupported("onDialog");
2237
+ },
2238
+ waitForDownloadEvent() {
2239
+ return rejectUnsupported("waitForDownload");
2240
+ },
2241
+ parseTextSelector(selector) {
2242
+ return unwrapAndroidTextSelector(selector);
2243
+ }
2244
+ };
2245
+ }
2246
+
2247
+ // src/browser/android-adb.ts
2248
+ var import_node_child_process3 = require("child_process");
2249
+ var execFileAdbRunner = (args, options) => new Promise((resolve) => {
2250
+ (0, import_node_child_process3.execFile)(
2251
+ "adb",
2252
+ args,
2253
+ { encoding: "utf-8", timeout: options?.timeoutMs, maxBuffer: 16 * 1024 * 1024 },
2254
+ (error, stdout, stderr) => {
2255
+ const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
2256
+ const capturedStderr = stderr ?? "";
2257
+ resolve({
2258
+ stdout: stdout ?? "",
2259
+ stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
2260
+ code
2261
+ });
2262
+ }
2263
+ );
2264
+ });
2265
+ var spawnAdbProcess = (args) => {
2266
+ const child = (0, import_node_child_process3.spawn)("adb", args, { stdio: "ignore" });
2267
+ child.on("error", () => {
2268
+ });
2269
+ return { kill: () => child.kill() };
2270
+ };
2271
+ function withSerial(serial, args) {
2272
+ return serial ? ["-s", serial, ...args] : args;
2273
+ }
2274
+ function parseAdbDevices(stdout) {
2275
+ const devices = [];
2276
+ for (const rawLine of stdout.split(/\r?\n/)) {
2277
+ const line = rawLine.trim();
2278
+ if (!line || /^list of devices attached/i.test(line)) {
2279
+ continue;
2280
+ }
2281
+ const [serial, state, ...rest] = line.split(/\s+/);
2282
+ if (!serial || !state) {
2283
+ continue;
2284
+ }
2285
+ const description = {};
2286
+ for (const token of rest) {
2287
+ const eq = token.indexOf(":");
2288
+ if (eq > 0) {
2289
+ description[token.slice(0, eq)] = token.slice(eq + 1);
2290
+ }
2291
+ }
2292
+ devices.push({ serial, state, description });
2293
+ }
2294
+ return devices;
2295
+ }
2296
+ function bootedDevices(devices) {
2297
+ return devices.filter((device) => device.state === "device");
2298
+ }
2299
+ function selectDeviceSerial(devices, requested) {
2300
+ const booted = bootedDevices(devices);
2301
+ if (requested) {
2302
+ const match = devices.find((device) => device.serial === requested);
2303
+ if (!match) {
2304
+ const attached = devices.length > 0 ? devices.map((d) => d.serial).join(", ") : "none";
2305
+ throw new Error(
2306
+ `Android device "${requested}" is not attached. Attached devices: ${attached}. Check \`adb devices -l\`.`
2307
+ );
2308
+ }
2309
+ if (match.state !== "device") {
2310
+ throw new Error(
2311
+ `Android device "${requested}" is present but not ready (state: ${match.state}). Boot or authorize it, then retry.`
2312
+ );
2313
+ }
2314
+ return requested;
2315
+ }
2316
+ if (booted.length === 0) {
2317
+ throw new Error(
2318
+ "No booted Android device found. Start an emulator or connect a device with USB debugging, then confirm it appears in `adb devices -l`."
2319
+ );
2320
+ }
2321
+ if (booted.length > 1) {
2322
+ throw new Error(
2323
+ `Multiple Android devices attached (${booted.map((d) => d.serial).join(", ")}). Set target.deviceSerial to pick one.`
2324
+ );
2325
+ }
2326
+ return booted[0].serial;
2327
+ }
2328
+ async function listDevices(runner) {
2329
+ const result = await runner(["devices", "-l"], { timeoutMs: 1e4 });
2330
+ if (result.code !== 0) {
2331
+ throw new Error(
2332
+ `\`adb devices\` failed (exit ${result.code}). Is adb on PATH and the server running? ` + (result.stderr.trim() || "").slice(0, 400)
2333
+ );
2334
+ }
2335
+ return parseAdbDevices(result.stdout);
2336
+ }
2337
+ function parseForwardPort(stdout) {
2338
+ const port = Number.parseInt(stdout.trim(), 10);
2339
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
2340
+ throw new Error(`Could not parse a forwarded port from adb output: "${stdout.trim()}"`);
2341
+ }
2342
+ return port;
2343
+ }
2344
+ function parseAaptPackage(stdout) {
2345
+ const match = /package:\s*name='([^']+)'/.exec(stdout);
2346
+ return match?.[1] ?? null;
2347
+ }
2348
+ async function forwardDynamicPort(runner, serial, remotePort) {
2349
+ const result = await runner(withSerial(serial, ["forward", "tcp:0", `tcp:${remotePort}`]), {
2350
+ timeoutMs: 1e4
2351
+ });
2352
+ if (result.code !== 0) {
2353
+ throw new Error(`adb forward failed (exit ${result.code}): ${result.stderr.trim()}`);
2354
+ }
2355
+ return parseForwardPort(result.stdout);
2356
+ }
2357
+ async function removeForward(runner, serial, localPort) {
2358
+ await runner(withSerial(serial, ["forward", "--remove", `tcp:${localPort}`]), { timeoutMs: 1e4 }).catch(
2359
+ () => void 0
2360
+ );
2361
+ }
2362
+ async function installApk(runner, serial, apkPath) {
2363
+ const result = await runner(withSerial(serial, ["install", "-r", "-t", "-g", apkPath]), {
2364
+ timeoutMs: 12e4
2365
+ });
2366
+ if (result.code !== 0 || /failure/i.test(result.stdout)) {
2367
+ throw new Error(
2368
+ `Failed to install APK "${apkPath}" (exit ${result.code}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
2369
+ );
2370
+ }
2371
+ }
2372
+ async function launchPackage(runner, serial, pkg) {
2373
+ const result = await runner(
2374
+ withSerial(serial, [
2375
+ "shell",
2376
+ "monkey",
2377
+ "-p",
2378
+ pkg,
2379
+ "-c",
2380
+ "android.intent.category.LAUNCHER",
2381
+ "1"
2382
+ ]),
2383
+ { timeoutMs: 3e4 }
2384
+ );
2385
+ if (result.code !== 0 || /no activities found|aborted|cannot launch/i.test(result.stdout + result.stderr)) {
2386
+ throw new Error(
2387
+ `Failed to launch Android package "${pkg}" (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 400)}. Is it installed?`
2388
+ );
2389
+ }
2390
+ }
2391
+ async function forceStop(runner, serial, pkg) {
2392
+ await runner(withSerial(serial, ["shell", "am", "force-stop", pkg]), { timeoutMs: 3e4 });
2393
+ }
2394
+ async function clearPackage(runner, serial, pkg) {
2395
+ const result = await runner(withSerial(serial, ["shell", "pm", "clear", pkg]), { timeoutMs: 3e4 });
2396
+ if (result.code !== 0 || !/success/i.test(result.stdout)) {
2397
+ throw new Error(
2398
+ `Failed to clear Android package "${pkg}" for cold start (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 300)}`
2399
+ );
2400
+ }
2401
+ }
2402
+ function startInstrumentation(spawner, serial) {
2403
+ return spawner(
2404
+ withSerial(serial, [
2405
+ "shell",
2406
+ "am",
2407
+ "instrument",
2408
+ "-w",
2409
+ "-e",
2410
+ "disableAnalytics",
2411
+ "true",
2412
+ "io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner"
2413
+ ])
2414
+ );
2415
+ }
2416
+
2417
+ // src/browser/android-agent.ts
2418
+ var DEFAULT_AGENT_REQUEST_TIMEOUT_MS = 3e4;
2419
+ var W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
2420
+ var Uia2HttpError = class extends Error {
2421
+ constructor(message, status, webdriverError) {
2422
+ super(message);
2423
+ this.status = status;
2424
+ this.webdriverError = webdriverError;
2425
+ this.name = "Uia2HttpError";
2426
+ }
2427
+ };
2428
+ var Uia2Transport = class {
2429
+ baseUrl;
2430
+ requestTimeoutMs;
2431
+ fetchImpl;
2432
+ constructor(options) {
2433
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
2434
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_AGENT_REQUEST_TIMEOUT_MS;
2435
+ const injected = options.fetchImpl;
2436
+ if (injected) {
2437
+ this.fetchImpl = injected;
2438
+ } else if (typeof fetch === "function") {
2439
+ this.fetchImpl = (url, init) => fetch(url, init);
2440
+ } else {
2441
+ throw new Error("global fetch is unavailable; Node 20+ is required for the Android target");
2442
+ }
2443
+ }
2444
+ /**
2445
+ * Send one request and return the parsed `value` field. Rejects with a
2446
+ * {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
2447
+ * per-request deadline elapses.
2448
+ */
2449
+ async request(method, path25, body, timeoutMs) {
2450
+ const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
2451
+ const controller = new AbortController();
2452
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
2453
+ timer.unref?.();
2454
+ const url = `${this.baseUrl}${path25}`;
2455
+ let response;
2456
+ try {
2457
+ response = await this.fetchImpl(url, {
2458
+ method,
2459
+ signal: controller.signal,
2460
+ headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
2461
+ body: body !== void 0 ? JSON.stringify(body) : void 0
2462
+ });
2463
+ } catch (error) {
2464
+ if (controller.signal.aborted) {
2465
+ const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
2466
+ throw new Error(`uiautomator2 request ${method} ${path25} timed out after ${shown}`);
2467
+ }
2468
+ throw error instanceof Error ? error : new Error(String(error));
2469
+ } finally {
2470
+ clearTimeout(timer);
2471
+ }
2472
+ const text = await response.text();
2473
+ const parsed = parseJson(text);
2474
+ if (!response.ok) {
2475
+ const wdError = extractWebdriverError(parsed);
2476
+ throw new Uia2HttpError(
2477
+ `uiautomator2 ${method} ${path25} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2478
+ response.status,
2479
+ wdError
2480
+ );
2481
+ }
2482
+ return parsed?.value;
2483
+ }
2484
+ };
2485
+ function parseJson(text) {
2486
+ if (!text) {
2487
+ return void 0;
2488
+ }
2489
+ try {
2490
+ return JSON.parse(text);
2491
+ } catch {
2492
+ return void 0;
2493
+ }
2494
+ }
2495
+ function extractWebdriverError(parsed) {
2496
+ const value = parsed?.value;
2497
+ if (value && typeof value === "object") {
2498
+ const record = value;
2499
+ const error = typeof record.error === "string" ? record.error : void 0;
2500
+ const message = typeof record.message === "string" ? record.message : void 0;
2501
+ return error ?? message;
2502
+ }
2503
+ return void 0;
2504
+ }
2505
+ function extractElementId(value) {
2506
+ if (!value || typeof value !== "object") {
2507
+ return null;
2508
+ }
2509
+ const record = value;
2510
+ const id = record[W3C_ELEMENT_KEY] ?? record.ELEMENT;
2511
+ return typeof id === "string" ? id : null;
2512
+ }
2513
+ function isNoSuchElement(error) {
2514
+ if (error instanceof Uia2HttpError) {
2515
+ return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
2516
+ }
2517
+ return false;
2518
+ }
2519
+ async function createUia2Session(transport) {
2520
+ const value = await transport.request("POST", "/session", {
2521
+ capabilities: { alwaysMatch: {}, firstMatch: [{}] }
2522
+ });
2523
+ const record = value ?? {};
2524
+ const sessionId = record.sessionId;
2525
+ if (typeof sessionId === "string" && sessionId.length > 0) {
2526
+ return sessionId;
2527
+ }
2528
+ throw new Error("uiautomator2 did not return a session id");
2529
+ }
2530
+ async function waitForAgentReady(transport, options = { deadlineMs: 3e4 }) {
2531
+ const interval = options.intervalMs ?? 300;
2532
+ const deadline = Date.now() + options.deadlineMs;
2533
+ let lastError;
2534
+ for (; ; ) {
2535
+ try {
2536
+ const remainingMs = Math.max(1, deadline - Date.now());
2537
+ const value = await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
2538
+ const ready = value?.ready;
2539
+ if (ready === void 0 || ready === true) {
2540
+ return;
2541
+ }
2542
+ } catch (error) {
2543
+ lastError = error;
2544
+ }
2545
+ if (Date.now() >= deadline) {
2546
+ const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
2547
+ throw new Error(`uiautomator2 agent did not become ready within ${options.deadlineMs}ms${detail}`);
2548
+ }
2549
+ await sleep(Math.min(interval, Math.max(0, deadline - Date.now())));
2550
+ }
2551
+ }
2552
+ function sleep(ms) {
2553
+ return new Promise((resolve) => {
2554
+ const timer = setTimeout(resolve, ms);
2555
+ timer.unref?.();
2556
+ });
2557
+ }
2558
+ function createUia2AgentClient(transport, sessionId, options = {}) {
2559
+ const base = `/session/${sessionId}`;
2560
+ async function locate(query, path25) {
2561
+ return transport.request(
2562
+ "POST",
2563
+ `${base}${path25}`,
2564
+ androidQueryToLocator(query, { appPackage: options.appPackage })
2565
+ );
2566
+ }
2567
+ return {
2568
+ async findElement(query) {
2569
+ try {
2570
+ return extractElementId(await locate(query, "/element"));
2571
+ } catch (error) {
2572
+ if (isNoSuchElement(error)) {
2573
+ return null;
2574
+ }
2575
+ throw error;
2576
+ }
2577
+ },
2578
+ async findElements(query) {
2579
+ const value = await locate(query, "/elements");
2580
+ if (!Array.isArray(value)) {
2581
+ return [];
2582
+ }
2583
+ return value.map((entry) => extractElementId(entry)).filter((id) => id !== null);
2584
+ },
2585
+ async click(elementId) {
2586
+ await transport.request("POST", `${base}/element/${elementId}/click`, {});
2587
+ },
2588
+ async setValue(elementId, text) {
2589
+ await transport.request("POST", `${base}/element/${elementId}/value`, { text });
2590
+ },
2591
+ async getText(elementId) {
2592
+ const value = await transport.request("GET", `${base}/element/${elementId}/text`);
2593
+ return typeof value === "string" ? value : value == null ? null : String(value);
2594
+ },
2595
+ async pressKeyCode(keyCode) {
2596
+ await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
2597
+ },
2598
+ async screenshotPng() {
2599
+ const value = await transport.request("GET", `${base}/screenshot`);
2600
+ if (typeof value !== "string") {
2601
+ throw new Error("uiautomator2 screenshot did not return base64 data");
2602
+ }
2603
+ return Buffer.from(value, "base64");
2604
+ },
2605
+ async close() {
2606
+ await transport.request("DELETE", base).catch(() => void 0);
2607
+ }
2608
+ };
2609
+ }
2610
+
2611
+ // src/browser/android-helper.ts
2612
+ var import_meta2 = {};
2613
+ var UIA2_REMOTE_PORT = 6790;
2614
+ function resolveAgentApks(requireFn = (0, import_node_module.createRequire)(import_meta2.url)) {
2615
+ let pkgJsonPath;
2616
+ try {
2617
+ pkgJsonPath = requireFn.resolve("appium-uiautomator2-server/package.json");
2618
+ } catch {
2619
+ throw new Error(
2620
+ "The Android target requires the `appium-uiautomator2-server` package (its prebuilt APKs). It is an optional dependency of prowl-tools; it may have been skipped (--omit=optional) or failed to install. Restore it for a global Prowl install with: npm install -g appium-uiautomator2-server@10.6.2. If Prowl is installed locally in a project, run: npm install appium-uiautomator2-server@10.6.2"
2621
+ );
2622
+ }
2623
+ const pkgDir = import_node_path5.default.dirname(pkgJsonPath);
2624
+ const version = requireFn(pkgJsonPath).version;
2625
+ const serverApk = import_node_path5.default.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
2626
+ const testApk = import_node_path5.default.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
2627
+ for (const apk of [serverApk, testApk]) {
2628
+ if (!import_node_fs6.default.existsSync(apk)) {
2629
+ throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
2630
+ }
2631
+ }
2632
+ return { serverApk, testApk };
2633
+ }
2634
+ function looksLikeApk(app) {
2635
+ return app.toLowerCase().endsWith(".apk");
2636
+ }
2637
+ var execFileAaptResolver = async (apkPath) => {
2638
+ for (const tool of ["aapt", "aapt2"]) {
2639
+ const output = await new Promise((resolve) => {
2640
+ (0, import_node_child_process4.execFile)(
2641
+ tool,
2642
+ ["dump", "badging", apkPath],
2643
+ { encoding: "utf-8", timeout: 2e4, maxBuffer: 8 * 1024 * 1024 },
2644
+ (error, stdout) => resolve(error ? null : stdout)
2645
+ );
2646
+ });
2647
+ const pkg = output ? parseAaptPackage(output) : null;
2648
+ if (pkg) {
2649
+ return pkg;
2650
+ }
2651
+ }
2652
+ return null;
2653
+ };
2654
+ var defaultAgentConnector = async ({
2655
+ host,
2656
+ port,
2657
+ requestTimeoutMs,
2658
+ readyDeadlineMs,
2659
+ appPackage
2660
+ }) => {
2661
+ const transport = new Uia2Transport({
2662
+ baseUrl: `http://${host}:${port}/wd/hub`,
2663
+ requestTimeoutMs
2664
+ });
2665
+ await waitForAgentReady(transport, { deadlineMs: readyDeadlineMs });
2666
+ const sessionId = await createUia2Session(transport);
2667
+ return createUia2AgentClient(transport, sessionId, { appPackage });
2668
+ };
2669
+ async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
2670
+ if (!looksLikeApk(app)) {
2671
+ assertAndroidAppAllowed(allowedApps, app);
2672
+ return app;
2673
+ }
2674
+ const apkPath = import_node_path5.default.resolve(app);
2675
+ if (!import_node_fs6.default.existsSync(apkPath)) {
2676
+ throw new Error(`APK not found: ${apkPath}`);
2677
+ }
2678
+ const pkg = await aaptResolver(apkPath);
2679
+ if (!pkg) {
2680
+ throw new Error(
2681
+ `Could not determine the package name for "${app}". Put Android build-tools \`aapt\`/\`aapt2\` on PATH, or set target.app to the package name instead of the .apk path.`
2682
+ );
2683
+ }
2684
+ assertAndroidAppAllowed(allowedApps, apkPath, pkg);
2685
+ await installApk(runner, serial, apkPath);
2686
+ return pkg;
2687
+ }
2688
+ async function launchAndroidSession(options) {
2689
+ const runner = options.runner ?? execFileAdbRunner;
2690
+ const spawner = options.spawner ?? spawnAdbProcess;
2691
+ const connector = options.agentConnector ?? defaultAgentConnector;
2692
+ const aaptResolver = options.aaptResolver ?? execFileAaptResolver;
2693
+ const apks = options.apks ?? resolveAgentApks();
2694
+ const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS) + 5e3;
2695
+ const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS);
2696
+ const devices = await listDevices(runner);
2697
+ const serial = selectDeviceSerial(devices, options.deviceSerial);
2698
+ const pkg = await resolvePackage(options.app, runner, serial, aaptResolver, options.allowedApps ?? []);
2699
+ await installApk(runner, serial, apks.serverApk);
2700
+ await installApk(runner, serial, apks.testApk);
2701
+ if (options.coldStart) {
2702
+ await clearPackage(runner, serial, pkg);
2703
+ }
2704
+ await launchPackage(runner, serial, pkg);
2705
+ let instrumentation;
2706
+ let localPort;
2707
+ let client;
2708
+ let tornDown = false;
2709
+ const teardown = async () => {
2710
+ if (tornDown) {
2711
+ return;
2712
+ }
2713
+ tornDown = true;
2714
+ if (client) {
2715
+ await client.close().catch(() => void 0);
2716
+ }
2717
+ instrumentation?.kill();
2718
+ const forwardedPort = localPort;
2719
+ localPort = void 0;
2720
+ if (forwardedPort !== void 0) {
2721
+ await removeForward(runner, serial, forwardedPort);
2722
+ }
2723
+ await forceStop(runner, serial, pkg).catch(() => void 0);
2724
+ };
2725
+ try {
2726
+ instrumentation = startInstrumentation(spawner, serial);
2727
+ localPort = await forwardDynamicPort(runner, serial, UIA2_REMOTE_PORT);
2728
+ client = await connector({
2729
+ host: "127.0.0.1",
2730
+ port: localPort,
2731
+ requestTimeoutMs,
2732
+ readyDeadlineMs,
2733
+ appPackage: pkg
2734
+ });
2735
+ const driver = createAndroidDriver(client, { appLabel: pkg });
2736
+ return { client, driver, package: pkg, serial, teardown };
2737
+ } catch (error) {
2738
+ await teardown();
2739
+ throw error;
2740
+ }
2741
+ }
2742
+ async function closeAndroidSession(session) {
2743
+ await session.teardown();
2744
+ }
2745
+
2746
+ // src/browser/ios-helper.ts
2747
+ var import_node_module2 = require("module");
2748
+ var import_node_fs7 = __toESM(require("fs"), 1);
2749
+ var import_node_os2 = __toESM(require("os"), 1);
2750
+ var import_node_path7 = __toESM(require("path"), 1);
2751
+
2752
+ // src/browser/ios-driver.ts
2753
+ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
2754
+ "query",
2755
+ "interact",
2756
+ "wait",
2757
+ "screenshot"
2758
+ ]);
2759
+ var WAIT_POLL_INTERVAL_MS2 = 250;
2760
+ var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
2761
+ var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
2762
+ function unquote3(value) {
2763
+ const trimmed = value.trim();
2764
+ const first = trimmed[0];
2765
+ if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
2766
+ return trimmed.slice(1, -1);
2767
+ }
2768
+ return trimmed;
2769
+ }
2770
+ function escapePredicateArg(value) {
2771
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2772
+ }
2773
+ function normalizeXcuiClassName(role) {
2774
+ return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
2775
+ }
2776
+ function parseIosSelector(selector) {
2777
+ const trimmed = selector.trim();
2778
+ if (trimmed === ":focus") {
2779
+ return { by: "focused" };
2780
+ }
2781
+ const idMatch = /^id=(.+)$/s.exec(trimmed);
2782
+ if (idMatch) {
2783
+ return { by: "accessibilityId", value: unquote3(idMatch[1]) };
2784
+ }
2785
+ const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
2786
+ if (roleMatch) {
2787
+ const name = roleMatch[2] !== void 0 ? unquote3(roleMatch[2]) : void 0;
2788
+ return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
2789
+ }
2790
+ const labelMatch = /^label=(.+)$/s.exec(trimmed);
2791
+ if (labelMatch) {
2792
+ return { by: "label", value: unquote3(labelMatch[1]) };
2793
+ }
2794
+ const textMatch = /^text=(.+)$/s.exec(trimmed);
2795
+ if (textMatch) {
2796
+ return { by: "text", value: unquote3(textMatch[1]) };
2797
+ }
2798
+ return { by: "text", value: trimmed };
2799
+ }
2800
+ function iosQueryToLocator(query) {
2801
+ switch (query.by) {
2802
+ case "accessibilityId":
2803
+ return { using: "accessibility id", value: query.value };
2804
+ case "label":
2805
+ return { using: "predicate string", value: `label == "${escapePredicateArg(query.value)}"` };
2806
+ case "text": {
2807
+ const escaped = escapePredicateArg(query.value);
2808
+ return {
2809
+ using: "predicate string",
2810
+ value: `label CONTAINS "${escaped}" OR value CONTAINS "${escaped}"`
2811
+ };
2812
+ }
2813
+ case "focused":
2814
+ return { using: "predicate string", value: "hasKeyboardFocus == 1" };
2815
+ case "role": {
2816
+ const className = normalizeXcuiClassName(query.role);
2817
+ if (query.name === void 0 || query.name.length === 0) {
2818
+ return { using: "class name", value: className };
2819
+ }
2820
+ const escapedClass = escapePredicateArg(className);
2821
+ const escapedName = escapePredicateArg(query.name);
2822
+ return {
2823
+ using: "predicate string",
2824
+ value: `type == "${escapedClass}" AND (label CONTAINS "${escapedName}" OR value CONTAINS "${escapedName}")`
2825
+ };
2826
+ }
2827
+ }
2828
+ }
2829
+ function unwrapIosTextSelector(selector) {
2830
+ const trimmed = selector.trim();
2831
+ if (!trimmed.startsWith("text=")) {
2832
+ return null;
2833
+ }
2834
+ return unquote3(trimmed.slice(5));
2835
+ }
2836
+ function delay2(ms) {
2837
+ return new Promise((resolve) => {
2838
+ setTimeout(resolve, ms);
2839
+ });
2840
+ }
2841
+ function createIosDriver(client, options) {
2842
+ const unsupported = (verb) => new Error(`${verb} is not supported by the iOS target`);
2843
+ const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
2844
+ async function resolveOne(selector) {
2845
+ const id = await client.findElement(parseIosSelector(selector));
2846
+ if (id === null) {
2847
+ throw new Error(`No element matched selector: ${selector}`);
2848
+ }
2849
+ return id;
2850
+ }
2851
+ async function clickSelector(selector) {
2852
+ await client.click(await resolveOne(selector));
2853
+ }
2854
+ async function fillSelector(selector, value) {
2855
+ await client.setValue(await resolveOne(selector), value);
2856
+ }
2857
+ async function pressKey(key) {
2858
+ const name = key.trim().toLowerCase();
2859
+ if (name === "enter" || name === "return") {
2860
+ await client.sendKeys(["\n"]);
2861
+ return;
2862
+ }
2863
+ if (name === "delete" || name === "backspace" || name === "del") {
2864
+ await client.sendKeys(["\b"]);
2865
+ return;
2866
+ }
2867
+ if (name === "home") {
2868
+ await client.homescreen();
2869
+ return;
2870
+ }
2871
+ throw new Error(
2872
+ `Unsupported key "${key}" for the iOS target. Supported keys: ${IOS_PRESS_KEYS.join(", ")}.`
2873
+ );
2874
+ }
2875
+ return {
2876
+ capabilities: IOS_CAPABILITIES,
2877
+ // navigation -----------------------------------------------------------
2878
+ goto(_url, _options) {
2879
+ return rejectUnsupported("navigate");
2880
+ },
2881
+ currentUrl() {
2882
+ return `ios:${options.appLabel ?? ""}`;
2883
+ },
2884
+ // queries --------------------------------------------------------------
2885
+ async count(selector) {
2886
+ return (await client.findElements(parseIosSelector(selector))).length;
2887
+ },
2888
+ async textContent(selector) {
2889
+ const id = await client.findElement(parseIosSelector(selector));
2890
+ if (id === null) {
2891
+ return null;
2892
+ }
2893
+ return client.getText(id);
2894
+ },
2895
+ // interactions ---------------------------------------------------------
2896
+ click: clickSelector,
2897
+ clickFirst: clickSelector,
2898
+ fill: fillSelector,
2899
+ fillFirst: fillSelector,
2900
+ async press(_selector, key) {
2901
+ await pressKey(key);
2902
+ },
2903
+ selectOption() {
2904
+ return rejectUnsupported("select");
2905
+ },
2906
+ selectOptionFirst() {
2907
+ return rejectUnsupported("select");
2908
+ },
2909
+ hover() {
2910
+ return rejectUnsupported("hover");
2911
+ },
2912
+ scrollIntoView() {
2913
+ return rejectUnsupported("scrollTo");
2914
+ },
2915
+ setInputFiles() {
2916
+ return rejectUnsupported("setInputFiles");
2917
+ },
2918
+ // semantic locators ----------------------------------------------------
2919
+ async countByRole(role, name) {
2920
+ return (await client.findElements({ by: "role", role, name })).length;
2921
+ },
2922
+ async clickFirstByRole(role, name) {
2923
+ const id = await client.findElement({ by: "role", role, name });
2924
+ if (id === null) {
2925
+ throw new Error(`No element matched role=${role}[name="${name}"]`);
2926
+ }
2927
+ await client.click(id);
2928
+ },
2929
+ async countByLabel(label) {
2930
+ return (await client.findElements({ by: "label", value: label })).length;
2931
+ },
2932
+ async fillFirstByLabel(label, value) {
2933
+ const id = await client.findElement({ by: "label", value: label });
2934
+ if (id === null) {
2935
+ throw new Error(`No element matched label="${label}"`);
2936
+ }
2937
+ await client.setValue(id, value);
2938
+ },
2939
+ selectOptionFirstByLabel() {
2940
+ return rejectUnsupported("select");
2941
+ },
2942
+ // waiting --------------------------------------------------------------
2943
+ async waitForSelector(selector, waitOptions) {
2944
+ const query = parseIosSelector(selector);
2945
+ const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
2946
+ const deadline = Date.now() + timeoutMs;
2947
+ for (; ; ) {
2948
+ if ((await client.findElements(query)).length > 0) {
2949
+ return;
2950
+ }
2951
+ if (Date.now() >= deadline) {
2952
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
2953
+ }
2954
+ await delay2(Math.min(WAIT_POLL_INTERVAL_MS2, Math.max(0, deadline - Date.now())));
2955
+ }
2956
+ },
2957
+ waitForUrl() {
2958
+ return rejectUnsupported("waitForUrl");
2959
+ },
2960
+ waitForNetworkIdle() {
2961
+ return rejectUnsupported("waitForNetworkIdle");
2962
+ },
2963
+ // scripting & artifacts ------------------------------------------------
2964
+ evaluate() {
2965
+ return rejectUnsupported("evalScript");
2966
+ },
2967
+ async screenshot(screenshotOptions) {
2968
+ await options.captureScreenshot(screenshotOptions.path);
2969
+ },
2970
+ // network / dialogs / downloads (all web-only) -------------------------
2971
+ onResponse(_handler) {
2972
+ throw unsupported("onResponse");
2973
+ },
2974
+ route(_url, _handler) {
2975
+ return rejectUnsupported("mockRoute");
2976
+ },
2977
+ unroute() {
2978
+ return rejectUnsupported("unmockRoute");
2979
+ },
2980
+ onDialog(_action) {
2981
+ throw unsupported("onDialog");
2982
+ },
2983
+ waitForDownloadEvent() {
2984
+ return rejectUnsupported("waitForDownload");
2985
+ },
2986
+ parseTextSelector(selector) {
2987
+ return unwrapIosTextSelector(selector);
2988
+ }
2989
+ };
2990
+ }
2991
+
2992
+ // src/browser/ios-simctl.ts
2993
+ var import_node_child_process5 = require("child_process");
2994
+ var import_promises = require("fs/promises");
2995
+ var import_node_net = __toESM(require("net"), 1);
2996
+ var import_node_os = __toESM(require("os"), 1);
2997
+ var import_node_path6 = __toESM(require("path"), 1);
2998
+ var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path6.default.join(import_node_os.default.tmpdir(), "prowl-ios-simulator-locks");
2999
+ var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
3000
+ var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
3001
+ (0, import_node_child_process5.execFile)(
3002
+ "xcrun",
3003
+ args,
3004
+ {
3005
+ encoding: "utf-8",
3006
+ timeout: options?.timeoutMs,
3007
+ maxBuffer: 32 * 1024 * 1024,
3008
+ env: options?.env ? { ...process.env, ...options.env } : process.env
3009
+ },
3010
+ (error, stdout, stderr) => {
3011
+ const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
3012
+ const capturedStderr = stderr ?? "";
3013
+ resolve({
3014
+ stdout: stdout ?? "",
3015
+ stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
3016
+ code
3017
+ });
3018
+ }
3019
+ );
3020
+ });
3021
+ function simulatorLockName(udid) {
3022
+ const safe = udid.replace(/[^A-Za-z0-9_.-]/g, "_");
3023
+ return `${safe || "simulator"}.lock`;
3024
+ }
3025
+ function isErrno(error, code) {
3026
+ return error?.code === code;
3027
+ }
3028
+ function isProcessAlive(pid) {
3029
+ try {
3030
+ process.kill(pid, 0);
3031
+ return true;
3032
+ } catch (error) {
3033
+ return isErrno(error, "EPERM");
3034
+ }
3035
+ }
3036
+ async function removeStaleSimulatorLock(lockPath) {
3037
+ try {
3038
+ const ownerText = await (0, import_promises.readFile)(import_node_path6.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
3039
+ const owner = JSON.parse(ownerText);
3040
+ if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
3041
+ await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
3042
+ return true;
3043
+ }
3044
+ } catch {
3045
+ return false;
3046
+ }
3047
+ return false;
3048
+ }
3049
+ function simulatorReservedError(udid) {
3050
+ return new Error(
3051
+ `iOS simulator "${udid}" is already reserved by another Prowl process. Wait for that run to finish, or boot/select a different simulator with target.udid.`
3052
+ );
3053
+ }
3054
+ async function reserveSimulatorUdid(udid, options = {}) {
3055
+ const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
3056
+ const lockPath = import_node_path6.default.join(lockRoot, simulatorLockName(udid));
3057
+ await (0, import_promises.mkdir)(lockRoot, { recursive: true });
3058
+ for (let attempt = 0; attempt < 2; attempt += 1) {
3059
+ try {
3060
+ await (0, import_promises.mkdir)(lockPath);
3061
+ } catch (error) {
3062
+ if (!isErrno(error, "EEXIST")) {
3063
+ throw error instanceof Error ? error : new Error(String(error));
3064
+ }
3065
+ if (attempt === 0 && await removeStaleSimulatorLock(lockPath)) {
3066
+ continue;
3067
+ }
3068
+ throw simulatorReservedError(udid);
3069
+ }
3070
+ let released = false;
3071
+ const release = async () => {
3072
+ if (released) {
3073
+ return;
3074
+ }
3075
+ released = true;
3076
+ await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
3077
+ };
3078
+ try {
3079
+ await (0, import_promises.writeFile)(
3080
+ import_node_path6.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
3081
+ `${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
3082
+ `,
3083
+ { flag: "wx" }
3084
+ );
3085
+ } catch (error) {
3086
+ await release().catch(() => void 0);
3087
+ throw error instanceof Error ? error : new Error(String(error));
3088
+ }
3089
+ return { udid, release };
3090
+ }
3091
+ throw simulatorReservedError(udid);
3092
+ }
3093
+ function findFreePort() {
3094
+ return new Promise((resolve, reject) => {
3095
+ const server = import_node_net.default.createServer();
3096
+ server.on("error", reject);
3097
+ server.listen(0, "127.0.0.1", () => {
3098
+ const address = server.address();
3099
+ if (address && typeof address === "object") {
3100
+ const { port } = address;
3101
+ server.close(() => resolve(port));
3102
+ } else {
3103
+ server.close(() => reject(new Error("Could not allocate a local port")));
3104
+ }
3105
+ });
3106
+ });
3107
+ }
3108
+ function parseSimctlDevices(json) {
3109
+ let parsed;
3110
+ try {
3111
+ parsed = JSON.parse(json);
3112
+ } catch {
3113
+ throw new Error("Could not parse `simctl list devices --json` output.");
3114
+ }
3115
+ const byRuntime = parsed?.devices;
3116
+ if (!byRuntime || typeof byRuntime !== "object") {
3117
+ return [];
3118
+ }
3119
+ const devices = [];
3120
+ for (const [runtime, entries] of Object.entries(byRuntime)) {
3121
+ if (!Array.isArray(entries)) {
3122
+ continue;
3123
+ }
3124
+ for (const entry of entries) {
3125
+ if (!entry || typeof entry !== "object") {
3126
+ continue;
3127
+ }
3128
+ const record = entry;
3129
+ const udid = typeof record.udid === "string" ? record.udid : void 0;
3130
+ const name = typeof record.name === "string" ? record.name : void 0;
3131
+ const state = typeof record.state === "string" ? record.state : "Unknown";
3132
+ if (!udid || !name) {
3133
+ continue;
3134
+ }
3135
+ devices.push({
3136
+ udid,
3137
+ name,
3138
+ state,
3139
+ runtime,
3140
+ isAvailable: record.isAvailable !== false
3141
+ });
3142
+ }
3143
+ }
3144
+ return devices;
3145
+ }
3146
+ function bootedSimulators(devices) {
3147
+ return devices.filter((device) => device.state === "Booted");
3148
+ }
3149
+ function describeSimulator(device) {
3150
+ const runtime = device.runtime.replace(/^com\.apple\.CoreSimulator\.SimRuntime\./, "");
3151
+ return `${device.name} [${runtime}] (${device.udid})`;
3152
+ }
3153
+ function selectSimulatorUdid(devices, requested) {
3154
+ const booted = bootedSimulators(devices);
3155
+ if (requested) {
3156
+ const match = devices.find((device) => device.udid === requested);
3157
+ if (!match) {
3158
+ const known = devices.length > 0 ? devices.map((d) => d.udid).join(", ") : "none";
3159
+ throw new Error(
3160
+ `iOS simulator "${requested}" was not found. Known simulators: ${known}. Check \`xcrun simctl list devices\`.`
3161
+ );
3162
+ }
3163
+ if (match.state !== "Booted") {
3164
+ throw new Error(
3165
+ `iOS simulator "${requested}" is not booted (state: ${match.state}). Boot it with \`xcrun simctl boot ${requested}\`, then retry.`
3166
+ );
3167
+ }
3168
+ return requested;
3169
+ }
3170
+ if (booted.length === 0) {
3171
+ throw new Error(
3172
+ "No booted iOS simulator found. Boot one from Xcode or with `xcrun simctl boot <udid>` (see `xcrun simctl list devices available`), then retry."
3173
+ );
3174
+ }
3175
+ if (booted.length > 1) {
3176
+ throw new Error(
3177
+ `Multiple iOS simulators are booted (${booted.map(describeSimulator).join("; ")}). Set target.udid to pick one.`
3178
+ );
3179
+ }
3180
+ return booted[0].udid;
3181
+ }
3182
+ async function listSimulators(runner) {
3183
+ const result = await runner(["simctl", "list", "devices", "--json"], { timeoutMs: 3e4 });
3184
+ if (result.code !== 0) {
3185
+ throw new Error(
3186
+ `\`xcrun simctl list devices\` failed. Is Xcode installed and are the command-line tools selected (\`xcode-select -p\`)? ${(result.stderr.trim() || "").slice(0, 400)}`
3187
+ );
3188
+ }
3189
+ return parseSimctlDevices(result.stdout);
3190
+ }
3191
+ async function installApp(runner, udid, appPath) {
3192
+ const result = await runner(["simctl", "install", udid, appPath], { timeoutMs: 12e4 });
3193
+ if (result.code !== 0) {
3194
+ throw new Error(
3195
+ `Failed to install "${appPath}" onto simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
3196
+ );
3197
+ }
3198
+ }
3199
+ async function uninstallApp(runner, udid, bundleId) {
3200
+ await runner(["simctl", "uninstall", udid, bundleId], { timeoutMs: 6e4 }).catch(() => void 0);
3201
+ }
3202
+ async function launchApp(runner, udid, bundleId, childEnv = {}) {
3203
+ const env = {};
3204
+ for (const [key, value] of Object.entries(childEnv)) {
3205
+ env[`SIMCTL_CHILD_${key}`] = value;
3206
+ }
3207
+ const result = await runner(["simctl", "launch", udid, bundleId], {
3208
+ timeoutMs: 6e4,
3209
+ env: Object.keys(env).length > 0 ? env : void 0
3210
+ });
3211
+ if (result.code !== 0) {
3212
+ throw new Error(
3213
+ `Failed to launch "${bundleId}" on simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}. Is it installed?`
3214
+ );
3215
+ }
3216
+ }
3217
+ async function terminateApp(runner, udid, bundleId) {
3218
+ await runner(["simctl", "terminate", udid, bundleId], { timeoutMs: 3e4 }).catch(() => void 0);
3219
+ }
3220
+ async function captureScreenshot(runner, udid, outPath) {
3221
+ const result = await runner(["simctl", "io", udid, "screenshot", outPath], { timeoutMs: 3e4 });
3222
+ if (result.code !== 0) {
3223
+ throw new Error(
3224
+ `Failed to capture a simulator screenshot (${udid}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 300)}`
3225
+ );
3226
+ }
3227
+ }
3228
+ function parseXcodeVersion(stdout) {
3229
+ const match = /Xcode\s+([\d.]+)/i.exec(stdout);
3230
+ return match?.[1] ?? null;
3231
+ }
3232
+ async function xcodeVersion(runner) {
3233
+ const result = await runner(["xcodebuild", "-version"], { timeoutMs: 3e4 });
3234
+ if (result.code !== 0) {
3235
+ return null;
3236
+ }
3237
+ return parseXcodeVersion(result.stdout);
3238
+ }
3239
+
3240
+ // src/browser/ios-agent.ts
3241
+ var DEFAULT_WDA_REQUEST_TIMEOUT_MS = 3e4;
3242
+ var W3C_ELEMENT_KEY2 = "element-6066-11e4-a52e-4f735466cecf";
3243
+ var WdaHttpError = class extends Error {
3244
+ constructor(message, status, webdriverError) {
3245
+ super(message);
3246
+ this.status = status;
3247
+ this.webdriverError = webdriverError;
3248
+ this.name = "WdaHttpError";
3249
+ }
3250
+ };
3251
+ var WdaTransport = class {
3252
+ baseUrl;
3253
+ requestTimeoutMs;
3254
+ fetchImpl;
3255
+ constructor(options) {
3256
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
3257
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_WDA_REQUEST_TIMEOUT_MS;
3258
+ const injected = options.fetchImpl;
3259
+ if (injected) {
3260
+ this.fetchImpl = injected;
3261
+ } else if (typeof fetch === "function") {
3262
+ this.fetchImpl = (url, init) => fetch(url, init);
3263
+ } else {
3264
+ throw new Error("global fetch is unavailable; Node 20+ is required for the iOS target");
3265
+ }
3266
+ }
3267
+ /**
3268
+ * Send one request and return the full parsed JSON body. Rejects with a
3269
+ * {@link WdaHttpError} on a non-2xx response, or a timeout error when the
3270
+ * per-request deadline elapses.
3271
+ */
3272
+ async requestFull(method, path25, body, timeoutMs) {
3273
+ const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
3274
+ const controller = new AbortController();
3275
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
3276
+ timer.unref?.();
3277
+ const url = `${this.baseUrl}${path25}`;
3278
+ const timeoutError = () => {
3279
+ const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
3280
+ return new Error(`WebDriverAgent request ${method} ${path25} timed out after ${shown}`);
3281
+ };
3282
+ let response;
3283
+ try {
3284
+ response = await this.fetchImpl(url, {
3285
+ method,
3286
+ signal: controller.signal,
3287
+ headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
3288
+ body: body !== void 0 ? JSON.stringify(body) : void 0
3289
+ });
3290
+ } catch (error) {
3291
+ clearTimeout(timer);
3292
+ if (controller.signal.aborted) {
3293
+ throw timeoutError();
3294
+ }
3295
+ throw error instanceof Error ? error : new Error(String(error));
3296
+ }
3297
+ let text;
3298
+ try {
3299
+ text = await response.text();
3300
+ } catch (error) {
3301
+ if (controller.signal.aborted) {
3302
+ throw timeoutError();
3303
+ }
3304
+ throw error instanceof Error ? error : new Error(String(error));
3305
+ } finally {
3306
+ clearTimeout(timer);
3307
+ }
3308
+ const parsed = parseJson2(text);
3309
+ if (!response.ok) {
3310
+ const wdError = extractWebdriverError2(parsed);
3311
+ throw new WdaHttpError(
3312
+ `WebDriverAgent ${method} ${path25} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
3313
+ response.status,
3314
+ wdError
3315
+ );
3316
+ }
3317
+ return parsed;
3318
+ }
3319
+ /** Like {@link requestFull} but returns just the `value` field. */
3320
+ async request(method, path25, body, timeoutMs) {
3321
+ const parsed = await this.requestFull(method, path25, body, timeoutMs);
3322
+ return parsed?.value;
3323
+ }
3324
+ };
3325
+ function parseJson2(text) {
3326
+ if (!text) {
3327
+ return void 0;
3328
+ }
3329
+ try {
3330
+ return JSON.parse(text);
3331
+ } catch {
3332
+ return void 0;
3333
+ }
3334
+ }
3335
+ function extractWebdriverError2(parsed) {
3336
+ const value = parsed?.value;
3337
+ if (value && typeof value === "object") {
3338
+ const record = value;
3339
+ const error = typeof record.error === "string" ? record.error : void 0;
3340
+ const message = typeof record.message === "string" ? record.message : void 0;
3341
+ return error ?? message;
3342
+ }
3343
+ return void 0;
3344
+ }
3345
+ function extractElementId2(value) {
3346
+ if (!value || typeof value !== "object") {
3347
+ return null;
3348
+ }
3349
+ const record = value;
3350
+ const id = record[W3C_ELEMENT_KEY2] ?? record.ELEMENT;
3351
+ return typeof id === "string" ? id : null;
3352
+ }
3353
+ function isNoSuchElement2(error) {
3354
+ if (error instanceof WdaHttpError) {
3355
+ return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
3356
+ }
3357
+ return false;
3358
+ }
3359
+ async function createWdaSession(transport, bundleId) {
3360
+ const body = await transport.requestFull("POST", "/session", {
3361
+ capabilities: { alwaysMatch: { bundleId }, firstMatch: [{}] }
3362
+ });
3363
+ const envelope = body ?? {};
3364
+ const value = envelope.value ?? {};
3365
+ const sessionId = typeof value.sessionId === "string" && value.sessionId || typeof envelope.sessionId === "string" && envelope.sessionId || "";
3366
+ if (sessionId.length > 0) {
3367
+ return sessionId;
3368
+ }
3369
+ throw new Error("WebDriverAgent did not return a session id");
3370
+ }
3371
+ async function waitForWdaReady(transport, options = { deadlineMs: 6e4 }) {
3372
+ const interval = options.intervalMs ?? 300;
3373
+ const deadline = Date.now() + options.deadlineMs;
3374
+ let lastError;
3375
+ for (; ; ) {
3376
+ try {
3377
+ const remainingMs = Math.max(1, deadline - Date.now());
3378
+ await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
3379
+ return;
3380
+ } catch (error) {
3381
+ lastError = error;
3382
+ }
3383
+ if (Date.now() >= deadline) {
3384
+ const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
3385
+ throw new Error(`WebDriverAgent did not become ready within ${options.deadlineMs}ms${detail}`);
3386
+ }
3387
+ await sleep2(Math.min(interval, Math.max(0, deadline - Date.now())));
3388
+ }
3389
+ }
3390
+ function sleep2(ms) {
3391
+ return new Promise((resolve) => {
3392
+ setTimeout(resolve, ms);
3393
+ });
3394
+ }
3395
+ function createWdaAgentClient(transport, sessionId) {
3396
+ const base = `/session/${sessionId}`;
3397
+ async function locate(query, path25) {
3398
+ return transport.request("POST", `${base}${path25}`, iosQueryToLocator(query));
3399
+ }
3400
+ return {
3401
+ async findElement(query) {
3402
+ try {
3403
+ return extractElementId2(await locate(query, "/element"));
3404
+ } catch (error) {
3405
+ if (isNoSuchElement2(error)) {
3406
+ return null;
3407
+ }
3408
+ throw error;
3409
+ }
3410
+ },
3411
+ async findElements(query) {
3412
+ const value = await locate(query, "/elements");
3413
+ if (!Array.isArray(value)) {
3414
+ return [];
3415
+ }
3416
+ return value.map((entry) => extractElementId2(entry)).filter((id) => id !== null);
3417
+ },
3418
+ async click(elementId) {
3419
+ await transport.request("POST", `${base}/element/${elementId}/click`, {});
3420
+ },
3421
+ async setValue(elementId, text) {
3422
+ await transport.request("POST", `${base}/element/${elementId}/value`, { text });
3423
+ },
3424
+ async getText(elementId) {
3425
+ const value = await transport.request("GET", `${base}/element/${elementId}/text`);
3426
+ return typeof value === "string" ? value : value == null ? null : String(value);
3427
+ },
3428
+ async sendKeys(keys) {
3429
+ await transport.request("POST", `${base}/wda/keys`, { value: keys });
3430
+ },
3431
+ async homescreen() {
3432
+ await transport.request("POST", "/wda/homescreen", {});
3433
+ },
3434
+ async close() {
3435
+ await transport.request("DELETE", base).catch(() => void 0);
3436
+ }
3437
+ };
3438
+ }
3439
+
3440
+ // src/browser/ios-helper.ts
3441
+ var import_meta3 = {};
3442
+ var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
3443
+ var WDA_RUNNER_APP_NAME = "WebDriverAgentRunner-Runner.app";
3444
+ var WDA_USE_PORT_ENV = "USE_PORT";
3445
+ var WDA_STARTUP_ATTEMPTS = 2;
3446
+ var defaultIosAgentConnector = async ({
3447
+ host,
3448
+ port,
3449
+ bundleId,
3450
+ requestTimeoutMs,
3451
+ readyDeadlineMs
3452
+ }) => {
3453
+ const transport = new WdaTransport({ baseUrl: `http://${host}:${port}`, requestTimeoutMs });
3454
+ await waitForWdaReady(transport, { deadlineMs: readyDeadlineMs });
3455
+ const sessionId = await createWdaSession(transport, bundleId);
3456
+ return createWdaAgentClient(transport, sessionId);
3457
+ };
3458
+ function resolveWdaProject(requireFn = (0, import_node_module2.createRequire)(import_meta3.url)) {
3459
+ let pkgJsonPath;
3460
+ try {
3461
+ pkgJsonPath = requireFn.resolve("appium-webdriveragent/package.json");
3462
+ } catch {
3463
+ throw new Error(
3464
+ "The iOS target requires the `appium-webdriveragent` package (its WDA Xcode project). It is an optional dependency of prowl-tools; it may have been skipped (--omit=optional) or failed to install. Restore it for a global Prowl install with: npm install -g appium-webdriveragent@16.4.0. If Prowl is installed locally in a project, run: npm install appium-webdriveragent@16.4.0"
3465
+ );
3466
+ }
3467
+ const pkgDir = import_node_path7.default.dirname(pkgJsonPath);
3468
+ const version = requireFn(pkgJsonPath).version;
3469
+ const projectPath = import_node_path7.default.join(pkgDir, "WebDriverAgent.xcodeproj");
3470
+ if (!import_node_fs7.default.existsSync(projectPath)) {
3471
+ throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
3472
+ }
3473
+ return { projectPath, version };
3474
+ }
3475
+ function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os2.default.homedir()) {
3476
+ return import_node_path7.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
3477
+ }
3478
+ function runnerAppPath(derivedDataPath) {
3479
+ return import_node_path7.default.join(derivedDataPath, "Build", "Products", "Debug-iphonesimulator", WDA_RUNNER_APP_NAME);
3480
+ }
3481
+ async function resolveWdaRunner(options = {}) {
3482
+ const runner = options.runner ?? execFileXcrunRunner;
3483
+ const env = options.env ?? process.env;
3484
+ const homeDir = options.homeDir ?? import_node_os2.default.homedir();
3485
+ const log = options.logger ?? ((message) => process.stderr.write(`${message}
3486
+ `));
3487
+ const override = env.PROWL_WDA_RUNNER;
3488
+ if (override) {
3489
+ if (!import_node_fs7.default.existsSync(override)) {
3490
+ throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
3491
+ }
3492
+ return override;
3493
+ }
3494
+ const { projectPath, version } = resolveWdaProject(options.requireFn);
3495
+ const xcode = await xcodeVersion(runner);
3496
+ if (!xcode) {
3497
+ throw new Error(
3498
+ "Could not determine the Xcode version (`xcrun xcodebuild -version`). The iOS target requires Xcode on macOS. Install it and run `xcode-select --switch`, then retry."
3499
+ );
3500
+ }
3501
+ const cacheDir = wdaCacheDir(version, xcode, homeDir);
3502
+ const cachedRunner = runnerAppPath(cacheDir);
3503
+ if (import_node_fs7.default.existsSync(cachedRunner)) {
3504
+ return cachedRunner;
3505
+ }
3506
+ log(
3507
+ `Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
3508
+ );
3509
+ import_node_fs7.default.mkdirSync(cacheDir, { recursive: true });
3510
+ const result = await runner(
3511
+ [
3512
+ "xcodebuild",
3513
+ "build-for-testing",
3514
+ "-project",
3515
+ projectPath,
3516
+ "-scheme",
3517
+ "WebDriverAgentRunner",
3518
+ "-destination",
3519
+ "generic/platform=iOS Simulator",
3520
+ "-derivedDataPath",
3521
+ cacheDir,
3522
+ "CODE_SIGNING_ALLOWED=NO"
3523
+ ],
3524
+ { timeoutMs: 12e5 }
3525
+ );
3526
+ if (result.code !== 0) {
3527
+ throw new Error(
3528
+ `Failed to build WebDriverAgent with \`xcodebuild build-for-testing\`. Ensure a full Xcode (not just the command-line tools) is installed and selected (\`xcode-select -p\`). Details: ${(result.stderr.trim() || result.stdout.trim()).slice(-800)}`
3529
+ );
3530
+ }
3531
+ if (!import_node_fs7.default.existsSync(cachedRunner)) {
3532
+ throw new Error(
3533
+ `WebDriverAgent build succeeded but the runner app was not found at ${cachedRunner}. This may indicate an Xcode layout change; set PROWL_WDA_RUNNER to a prebuilt runner.`
3534
+ );
3535
+ }
3536
+ return cachedRunner;
3537
+ }
3538
+ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
3539
+ if (!looksLikeIosAppPath(app)) {
3540
+ assertIosAppAllowed(allowedApps, app);
3541
+ if (coldStart) {
3542
+ throw new Error(
3543
+ `coldStart requires target.app to be a built .app bundle path (a bare bundle id like "${app}" cannot be reinstalled). Point target.app at the .app, or drop coldStart.`
3544
+ );
3545
+ }
3546
+ return app;
3547
+ }
3548
+ const appPath = import_node_path7.default.resolve(app);
3549
+ if (!import_node_fs7.default.existsSync(appPath)) {
3550
+ throw new Error(`.app bundle not found: ${appPath}`);
3551
+ }
3552
+ const bundleId = readIosBundleIdentifier(appPath);
3553
+ if (!bundleId) {
3554
+ throw new Error(
3555
+ `Could not read CFBundleIdentifier from "${appPath}" (root Info.plist). Ensure target.app points at a built iOS .app bundle.`
3556
+ );
3557
+ }
3558
+ assertIosAppAllowed(allowedApps, appPath);
3559
+ if (coldStart) {
3560
+ await uninstallApp(runner, udid, bundleId);
3561
+ }
3562
+ await installApp(runner, udid, appPath);
3563
+ return bundleId;
3564
+ }
3565
+ async function launchIosSession(options) {
3566
+ const runner = options.runner ?? execFileXcrunRunner;
3567
+ const portAllocator = options.portAllocator ?? findFreePort;
3568
+ const connector = options.agentConnector ?? defaultIosAgentConnector;
3569
+ const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
3570
+ const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, 6e4);
3571
+ const devices = await listSimulators(runner);
3572
+ const udid = selectSimulatorUdid(devices, options.udid);
3573
+ const reservation = await reserveSimulatorUdid(udid, { lockRoot: options.simulatorLockRoot });
3574
+ let reservationReleased = false;
3575
+ const releaseReservation = async () => {
3576
+ if (reservationReleased) {
1817
3577
  return;
1818
3578
  }
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;
3579
+ reservationReleased = true;
3580
+ await reservation.release();
3581
+ };
1846
3582
  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
- );
3583
+ const bundleId = await resolveBundleId(
3584
+ options.app,
3585
+ runner,
3586
+ udid,
3587
+ options.coldStart ?? false,
3588
+ options.allowedApps ?? []
3589
+ );
3590
+ const wdaRunnerApp = options.wdaRunnerApp ?? await resolveWdaRunner({ runner, logger: options.logger });
3591
+ await installApp(runner, udid, wdaRunnerApp);
3592
+ for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
3593
+ let client;
3594
+ let tornDown = false;
3595
+ let terminateWda = false;
3596
+ let terminateTarget = false;
3597
+ let stage = "port";
3598
+ const teardownAttempt = async () => {
3599
+ if (tornDown) {
3600
+ return;
3601
+ }
3602
+ tornDown = true;
3603
+ if (client) {
3604
+ await client.close().catch(() => void 0);
3605
+ }
3606
+ if (terminateWda) {
3607
+ await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
3608
+ }
3609
+ if (terminateTarget) {
3610
+ await terminateApp(runner, udid, bundleId);
3611
+ }
3612
+ };
3613
+ try {
3614
+ const port = await portAllocator();
3615
+ stage = "wda-launch";
3616
+ terminateWda = true;
3617
+ await launchApp(runner, udid, WDA_RUNNER_BUNDLE_ID, { [WDA_USE_PORT_ENV]: String(port) });
3618
+ stage = "target-launch";
3619
+ terminateTarget = true;
3620
+ await launchApp(runner, udid, bundleId);
3621
+ stage = "connect";
3622
+ client = await connector({ host: "127.0.0.1", port, bundleId, requestTimeoutMs, readyDeadlineMs });
3623
+ const driver = createIosDriver(client, {
3624
+ appLabel: bundleId,
3625
+ captureScreenshot: (outPath) => captureScreenshot(runner, udid, outPath)
3626
+ });
3627
+ const teardown = async () => {
3628
+ await teardownAttempt();
3629
+ await releaseReservation();
3630
+ };
3631
+ return { client, driver, bundleId, udid, teardown };
3632
+ } catch (error) {
3633
+ await teardownAttempt();
3634
+ if (stage === "target-launch" || attempt >= WDA_STARTUP_ATTEMPTS) {
3635
+ throw error instanceof Error ? error : new Error(String(error));
3636
+ }
3637
+ }
1852
3638
  }
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 };
3639
+ throw new Error("WebDriverAgent startup failed without an error.");
1857
3640
  } catch (error) {
1858
- await client.close().catch(() => void 0);
3641
+ await releaseReservation().catch(() => void 0);
1859
3642
  throw error;
1860
3643
  }
1861
3644
  }
1862
- async function closeMacSession(session) {
1863
- try {
1864
- await session.client.request("quit");
1865
- } catch {
1866
- } finally {
1867
- await session.client.close();
1868
- }
3645
+ async function closeIosSession(session) {
3646
+ await session.teardown();
1869
3647
  }
1870
3648
 
1871
3649
  // src/runner/steps.ts
1872
- var import_node_fs6 = __toESM(require("fs"), 1);
1873
- var import_node_path6 = __toESM(require("path"), 1);
3650
+ var import_node_fs9 = __toESM(require("fs"), 1);
3651
+ var import_node_path9 = __toESM(require("path"), 1);
1874
3652
  init_loader();
1875
3653
 
1876
3654
  // src/runner/healing.ts
@@ -1915,8 +3693,8 @@ async function healSelector(probe, selector, options) {
1915
3693
  for (const candidate of buildHealCandidates(selector)) {
1916
3694
  let count;
1917
3695
  try {
1918
- const locator = probe.locator(candidate.selector);
1919
- count = await locator.count();
3696
+ const locator2 = probe.locator(candidate.selector);
3697
+ count = await locator2.count();
1920
3698
  } catch {
1921
3699
  continue;
1922
3700
  }
@@ -2361,7 +4139,7 @@ async function runInlineAssert(driver, policy, assertion) {
2361
4139
  throw new Error("assert step is missing an assertion type");
2362
4140
  }
2363
4141
  function screenshotPath(screenshotsDir, fileName) {
2364
- return import_node_path6.default.join(screenshotsDir, fileName);
4142
+ return import_node_path9.default.join(screenshotsDir, fileName);
2365
4143
  }
2366
4144
  function stepPath(prefix, index) {
2367
4145
  return prefix ? `${prefix}.${index}` : `${index}`;
@@ -2378,12 +4156,12 @@ function validateDownloadFilename(suggestedFilename) {
2378
4156
  const safeFilename = suggestedFilename.trim();
2379
4157
  const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
2380
4158
  const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
2381
- if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path6.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
4159
+ if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path9.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
2382
4160
  throw new Error(`Invalid download filename: "${suggestedFilename}"`);
2383
4161
  }
2384
4162
  return safeFilename;
2385
4163
  }
2386
- async function captureScreenshot(taker, filePath) {
4164
+ async function captureScreenshot2(taker, filePath) {
2387
4165
  try {
2388
4166
  await taker.screenshot({ path: filePath, fullPage: true });
2389
4167
  } catch (error) {
@@ -2558,7 +4336,7 @@ var STEP_HANDLERS = {
2558
4336
  if (!("setInputFiles" in h.step)) unknownStep();
2559
4337
  const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
2560
4338
  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);
4339
+ const resolveFile = (f) => import_node_path9.default.isAbsolute(f) ? f : import_node_path9.default.join(h.context.configDir, f);
2562
4340
  const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
2563
4341
  await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
2564
4342
  h.policy.ensureLocationAllowed(h.driver);
@@ -2595,7 +4373,7 @@ var STEP_HANDLERS = {
2595
4373
  redactedFillSteps: subRedacted,
2596
4374
  randomVars
2597
4375
  } = interpolateHunt(subHunt, process.env, h.context.randomVars);
2598
- const subTargetType = h.driver.capabilities.has("navigate") ? "web" : "macos";
4376
+ const subTargetType = h.context.targetType ?? (h.driver.capabilities.has("navigate") ? "web" : "macos");
2599
4377
  assertStepsSupportedByTarget(interpolatedSubHunt.steps, subTargetType);
2600
4378
  assertHuntAssertionsSupportedByTarget(interpolatedSubHunt.assertions, subTargetType);
2601
4379
  h.policy.assertWithinMaxSteps(interpolatedSubHunt.steps.length, huntName);
@@ -2920,15 +4698,15 @@ var STEP_HANDLERS = {
2920
4698
  if (!responseFile) {
2921
4699
  throw new Error("mock.response must include either body or file");
2922
4700
  }
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);
4701
+ const candidateFilePath = import_node_path9.default.isAbsolute(responseFile) ? responseFile : import_node_path9.default.join(h.context.configDir, responseFile);
4702
+ const resolvedConfigDir = import_node_path9.default.resolve(h.context.configDir);
4703
+ const resolvedFilePath = import_node_path9.default.resolve(candidateFilePath);
4704
+ const relativePath = import_node_path9.default.relative(resolvedConfigDir, resolvedFilePath);
4705
+ const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path9.default.sep}`) && !import_node_path9.default.isAbsolute(relativePath);
2928
4706
  if (!isWithinConfigDir) {
2929
4707
  throw new Error("mock.response.file must resolve within config directory");
2930
4708
  }
2931
- responseBody = await import_node_fs6.default.promises.readFile(resolvedFilePath, "utf-8");
4709
+ responseBody = await import_node_fs9.default.promises.readFile(resolvedFilePath, "utf-8");
2932
4710
  }
2933
4711
  const contentType = mock.response.contentType ?? "application/json";
2934
4712
  const status = mock.response.status;
@@ -2991,8 +4769,8 @@ var STEP_HANDLERS = {
2991
4769
  capabilities: ["evaluate"],
2992
4770
  run: async (h) => {
2993
4771
  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");
4772
+ const filePath = import_node_path9.default.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : import_node_path9.default.join(h.context.configDir, h.step.runScript.file);
4773
+ const fileContents = import_node_fs9.default.readFileSync(filePath, "utf-8");
2996
4774
  await h.driver.evaluate(fileContents);
2997
4775
  return {
2998
4776
  kind: "result",
@@ -3013,19 +4791,19 @@ var STEP_HANDLERS = {
3013
4791
  const name = h.step.assertScreenshot.name;
3014
4792
  const threshold = h.step.assertScreenshot.threshold ?? 0.1;
3015
4793
  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 });
4794
+ const baselinePath = import_node_path9.default.join(baselineDir, `${name}.png`);
4795
+ const currentScreenshotPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
4796
+ import_node_fs9.default.mkdirSync(import_node_path9.default.dirname(currentScreenshotPath), { recursive: true });
3019
4797
  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);
4798
+ h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-current.png`));
4799
+ if (!import_node_fs9.default.existsSync(baselinePath)) {
4800
+ import_node_fs9.default.copyFileSync(currentScreenshotPath, baselinePath);
3023
4801
  return {
3024
4802
  kind: "result",
3025
4803
  result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
3026
4804
  };
3027
4805
  }
3028
- const diffPath = import_node_path6.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
4806
+ const diffPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
3029
4807
  const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
3030
4808
  if (comparison.match) {
3031
4809
  return {
@@ -3038,7 +4816,7 @@ var STEP_HANDLERS = {
3038
4816
  }
3039
4817
  };
3040
4818
  }
3041
- h.screenshots.push(import_node_path6.default.join("screenshots", `${name}-diff.png`));
4819
+ h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-diff.png`));
3042
4820
  throw new Error(
3043
4821
  `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
3044
4822
  );
@@ -3080,7 +4858,7 @@ var STEP_HANDLERS = {
3080
4858
  `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
3081
4859
  );
3082
4860
  }
3083
- const savePath = import_node_path6.default.join(h.context.runDir, suggestedFilename);
4861
+ const savePath = import_node_path9.default.join(h.context.runDir, suggestedFilename);
3084
4862
  await download.saveAs(savePath);
3085
4863
  return {
3086
4864
  kind: "result",
@@ -3109,8 +4887,8 @@ async function executeSteps(context) {
3109
4887
  maxSteps: context.maxSteps,
3110
4888
  selfHealing: context.selfHealing
3111
4889
  });
3112
- const screenshotsDir = import_node_path6.default.join(context.runDir, "screenshots");
3113
- import_node_fs6.default.mkdirSync(screenshotsDir, { recursive: true });
4890
+ const screenshotsDir = import_node_path9.default.join(context.runDir, "screenshots");
4891
+ import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
3114
4892
  const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
3115
4893
  policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
3116
4894
  const results = [];
@@ -3119,8 +4897,8 @@ async function executeSteps(context) {
3119
4897
  context.runStartedAtMs = runStartedAtMs;
3120
4898
  const addScreenshot = async (fileName) => {
3121
4899
  const fullPath = screenshotPath(screenshotsDir, fileName);
3122
- await captureScreenshot(driver, fullPath);
3123
- const relative = import_node_path6.default.join("screenshots", fileName);
4900
+ await captureScreenshot2(driver, fullPath);
4901
+ const relative = import_node_path9.default.join("screenshots", fileName);
3124
4902
  screenshots.push(relative);
3125
4903
  return relative;
3126
4904
  };
@@ -3208,12 +4986,12 @@ async function executeSteps(context) {
3208
4986
  return { results, screenshots, failed: false };
3209
4987
  }
3210
4988
  async function captureFinalScreenshot(page, runDir) {
3211
- const screenshotsDir = import_node_path6.default.join(runDir, "screenshots");
3212
- import_node_fs6.default.mkdirSync(screenshotsDir, { recursive: true });
4989
+ const screenshotsDir = import_node_path9.default.join(runDir, "screenshots");
4990
+ import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
3213
4991
  const fileName = "final.png";
3214
4992
  const filePath = screenshotPath(screenshotsDir, fileName);
3215
- await captureScreenshot(page, filePath);
3216
- return import_node_path6.default.join("screenshots", fileName);
4993
+ await captureScreenshot2(page, filePath);
4994
+ return import_node_path9.default.join("screenshots", fileName);
3217
4995
  }
3218
4996
 
3219
4997
  // src/runner/assertions.ts
@@ -3373,18 +5151,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
3373
5151
  }
3374
5152
 
3375
5153
  // src/reporter/result.ts
3376
- var import_node_fs7 = __toESM(require("fs"), 1);
3377
- var import_node_path7 = __toESM(require("path"), 1);
5154
+ var import_node_fs10 = __toESM(require("fs"), 1);
5155
+ var import_node_path10 = __toESM(require("path"), 1);
3378
5156
  function writeResult(runDir, result) {
3379
5157
  const fileName = "result.json";
3380
- const fullPath = import_node_path7.default.join(runDir, fileName);
3381
- import_node_fs7.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
5158
+ const fullPath = import_node_path10.default.join(runDir, fileName);
5159
+ import_node_fs10.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
3382
5160
  return fileName;
3383
5161
  }
3384
5162
 
3385
5163
  // src/reporter/summary.ts
3386
- var import_node_fs8 = __toESM(require("fs"), 1);
3387
- var import_node_path8 = __toESM(require("path"), 1);
5164
+ var import_node_fs11 = __toESM(require("fs"), 1);
5165
+ var import_node_path11 = __toESM(require("path"), 1);
3388
5166
  function escapeMd(text) {
3389
5167
  return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
3390
5168
  }
@@ -3462,15 +5240,15 @@ function writeSummary(runDir, result) {
3462
5240
  }
3463
5241
  }
3464
5242
  const fileName = "summary.md";
3465
- const fullPath = import_node_path8.default.join(runDir, fileName);
3466
- import_node_fs8.default.writeFileSync(fullPath, `${lines.join("\n")}
5243
+ const fullPath = import_node_path11.default.join(runDir, fileName);
5244
+ import_node_fs11.default.writeFileSync(fullPath, `${lines.join("\n")}
3467
5245
  `);
3468
5246
  return fileName;
3469
5247
  }
3470
5248
 
3471
5249
  // src/reporter/junit.ts
3472
- var import_node_fs9 = __toESM(require("fs"), 1);
3473
- var import_node_path9 = __toESM(require("path"), 1);
5250
+ var import_node_fs12 = __toESM(require("fs"), 1);
5251
+ var import_node_path12 = __toESM(require("path"), 1);
3474
5252
  function escapeXml(text) {
3475
5253
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3476
5254
  }
@@ -3514,8 +5292,8 @@ function writeJunit(runDir, result) {
3514
5292
  lines.push(" </testsuite>");
3515
5293
  lines.push("</testsuites>");
3516
5294
  const fileName = "junit.xml";
3517
- const fullPath = import_node_path9.default.join(runDir, fileName);
3518
- import_node_fs9.default.writeFileSync(fullPath, `${lines.join("\n")}
5295
+ const fullPath = import_node_path12.default.join(runDir, fileName);
5296
+ import_node_fs12.default.writeFileSync(fullPath, `${lines.join("\n")}
3519
5297
  `);
3520
5298
  return fileName;
3521
5299
  }
@@ -3549,15 +5327,15 @@ function timestamp(prefix) {
3549
5327
  }
3550
5328
 
3551
5329
  // src/runner/history.ts
3552
- var import_node_fs10 = __toESM(require("fs"), 1);
3553
- var import_node_path10 = __toESM(require("path"), 1);
5330
+ var import_node_fs13 = __toESM(require("fs"), 1);
5331
+ var import_node_path13 = __toESM(require("path"), 1);
3554
5332
  var HISTORY_FILE = "history.json";
3555
5333
  var LOCK_FILE_SUFFIX = ".lock";
3556
5334
  var LOCK_RETRY_MS = 10;
3557
5335
  var LOCK_TIMEOUT_MS = 5e3;
3558
5336
  var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
3559
5337
  function historyPath(configDir) {
3560
- return import_node_path10.default.join(configDir, HISTORY_FILE);
5338
+ return import_node_path13.default.join(configDir, HISTORY_FILE);
3561
5339
  }
3562
5340
  function isHistoryEntry(value) {
3563
5341
  if (!value || typeof value !== "object") {
@@ -3568,11 +5346,11 @@ function isHistoryEntry(value) {
3568
5346
  }
3569
5347
  function readHistory(configDir) {
3570
5348
  const filePath = historyPath(configDir);
3571
- if (!import_node_fs10.default.existsSync(filePath)) {
5349
+ if (!import_node_fs13.default.existsSync(filePath)) {
3572
5350
  return { entries: [] };
3573
5351
  }
3574
5352
  try {
3575
- const raw = import_node_fs10.default.readFileSync(filePath, "utf-8");
5353
+ const raw = import_node_fs13.default.readFileSync(filePath, "utf-8");
3576
5354
  const parsed = JSON.parse(raw);
3577
5355
  if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
3578
5356
  const validatedEntries = parsed.entries.filter(isHistoryEntry);
@@ -3611,12 +5389,12 @@ function sleepSync(ms) {
3611
5389
  function withHistoryLock(configDir, fn) {
3612
5390
  const filePath = historyPath(configDir);
3613
5391
  const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
3614
- import_node_fs10.default.mkdirSync(import_node_path10.default.dirname(filePath), { recursive: true });
5392
+ import_node_fs13.default.mkdirSync(import_node_path13.default.dirname(filePath), { recursive: true });
3615
5393
  const startedAt = Date.now();
3616
5394
  while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
3617
5395
  let fd;
3618
5396
  try {
3619
- fd = import_node_fs10.default.openSync(lockPath, "wx");
5397
+ fd = import_node_fs13.default.openSync(lockPath, "wx");
3620
5398
  } catch (error) {
3621
5399
  if (error.code === "EEXIST") {
3622
5400
  sleepSync(LOCK_RETRY_MS);
@@ -3628,10 +5406,10 @@ function withHistoryLock(configDir, fn) {
3628
5406
  return fn();
3629
5407
  } finally {
3630
5408
  try {
3631
- import_node_fs10.default.closeSync(fd);
5409
+ import_node_fs13.default.closeSync(fd);
3632
5410
  } catch {
3633
5411
  }
3634
- import_node_fs10.default.rmSync(lockPath, { force: true });
5412
+ import_node_fs13.default.rmSync(lockPath, { force: true });
3635
5413
  }
3636
5414
  }
3637
5415
  throw new Error(
@@ -3644,9 +5422,9 @@ function appendEntry(configDir, entry, maxRuns) {
3644
5422
  const current = readHistory(configDir);
3645
5423
  const next = pruneEntries([...current.entries, entry], maxRuns);
3646
5424
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
3647
- import_node_fs10.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
5425
+ import_node_fs13.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
3648
5426
  `);
3649
- import_node_fs10.default.renameSync(tempPath, filePath);
5427
+ import_node_fs13.default.renameSync(tempPath, filePath);
3650
5428
  });
3651
5429
  }
3652
5430
 
@@ -3659,11 +5437,11 @@ function parseViewportFlag(value) {
3659
5437
  return value;
3660
5438
  }
3661
5439
  function resolvePath(configDir, inputPath) {
3662
- if (import_node_path11.default.isAbsolute(inputPath)) {
5440
+ if (import_node_path14.default.isAbsolute(inputPath)) {
3663
5441
  return inputPath;
3664
5442
  }
3665
- const projectRoot = import_node_path11.default.dirname(configDir);
3666
- return import_node_path11.default.join(projectRoot, inputPath);
5443
+ const projectRoot = import_node_path14.default.dirname(configDir);
5444
+ return import_node_path14.default.join(projectRoot, inputPath);
3667
5445
  }
3668
5446
  function buildRunResult(options) {
3669
5447
  return {
@@ -3682,12 +5460,12 @@ function buildRunResult(options) {
3682
5460
  }
3683
5461
  function writeConsoleLog(runDir, entries) {
3684
5462
  const fileName = "console.log";
3685
- const filePath = import_node_path11.default.join(runDir, fileName);
5463
+ const filePath = import_node_path14.default.join(runDir, fileName);
3686
5464
  const lines = entries.map((entry) => {
3687
5465
  const location = entry.location ? ` (${entry.location})` : "";
3688
5466
  return `[${entry.type}] ${entry.text}${location}`;
3689
5467
  });
3690
- import_node_fs11.default.writeFileSync(filePath, `${lines.join("\n")}
5468
+ import_node_fs14.default.writeFileSync(filePath, `${lines.join("\n")}
3691
5469
  `);
3692
5470
  return fileName;
3693
5471
  }
@@ -3695,8 +5473,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
3695
5473
  const headless = options.headed ? false : config.browser.headless;
3696
5474
  const slowMo = options.slowMo ?? config.browser.slowMo;
3697
5475
  const maxSteps = config.guardrails.maxSteps;
3698
- const runDir = import_node_path11.default.join(configDir, "runs", timestamp());
3699
- import_node_fs11.default.mkdirSync(runDir, { recursive: true });
5476
+ const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
5477
+ import_node_fs14.default.mkdirSync(runDir, { recursive: true });
3700
5478
  const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
3701
5479
  const engine = options.browser ?? config.browser.engine;
3702
5480
  const channel = options.channel ?? config.browser.channel;
@@ -3813,7 +5591,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
3813
5591
  }
3814
5592
  return { result, runDir, steps: interpolatedHunt.steps };
3815
5593
  }
3816
- function delay(ms) {
5594
+ function delay3(ms) {
3817
5595
  return new Promise((resolve) => setTimeout(resolve, ms));
3818
5596
  }
3819
5597
  async function runHunt(options) {
@@ -3821,6 +5599,12 @@ async function runHunt(options) {
3821
5599
  if (config.target.type === "macos") {
3822
5600
  return runMacHunt(options, config, configDir, config.target);
3823
5601
  }
5602
+ if (config.target.type === "android") {
5603
+ return runAndroidHunt(options, config, configDir, config.target);
5604
+ }
5605
+ if (config.target.type === "ios") {
5606
+ return runIosHunt(options, config, configDir, config.target);
5607
+ }
3824
5608
  const hunt = loadHunt(options.huntName, configDir);
3825
5609
  const {
3826
5610
  hunt: interpolatedHunt,
@@ -3842,7 +5626,7 @@ async function runHunt(options) {
3842
5626
  let lastResult;
3843
5627
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
3844
5628
  if (attempt > 0 && retryDelay > 0) {
3845
- await delay(retryDelay);
5629
+ await delay3(retryDelay);
3846
5630
  }
3847
5631
  lastResult = await executeHuntAttempt(
3848
5632
  options,
@@ -3871,19 +5655,17 @@ async function runHunt(options) {
3871
5655
  }
3872
5656
  return lastResult;
3873
5657
  }
3874
- async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
5658
+ async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
3875
5659
  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
- });
5660
+ const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
5661
+ import_node_fs14.default.mkdirSync(runDir, { recursive: true });
5662
+ const session = await native.launchSession();
3883
5663
  let result;
3884
5664
  try {
3885
- const targetLabel = `macos:${session.bundleId}`;
3886
- const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, target.app, session.bundleId])];
5665
+ const driver = native.sessionDriver(session);
5666
+ const appIdentity = native.sessionAppIdentity(session);
5667
+ const targetLabel = `${native.targetType}:${appIdentity}`;
5668
+ const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
3887
5669
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3888
5670
  const startTime = Date.now();
3889
5671
  let stepResults = [];
@@ -3891,7 +5673,8 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3891
5673
  let stepFailed = false;
3892
5674
  try {
3893
5675
  const stepExecution = await executeSteps({
3894
- driver: session.driver,
5676
+ driver,
5677
+ targetType: native.targetType,
3895
5678
  steps: interpolatedHunt.steps,
3896
5679
  targetUrl: targetLabel,
3897
5680
  runDir,
@@ -3918,7 +5701,7 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3918
5701
  }
3919
5702
  let finalScreenshot;
3920
5703
  try {
3921
- finalScreenshot = await captureFinalScreenshot(session.driver, runDir);
5704
+ finalScreenshot = await captureFinalScreenshot(driver, runDir);
3922
5705
  } catch {
3923
5706
  finalScreenshot = void 0;
3924
5707
  }
@@ -3939,16 +5722,116 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3939
5722
  });
3940
5723
  result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
3941
5724
  } finally {
3942
- await closeMacSession(session);
5725
+ await native.closeSession(session);
3943
5726
  }
3944
5727
  return { result, runDir, steps: interpolatedHunt.steps };
3945
5728
  }
5729
+ async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
5730
+ return executeNativeHuntAttempt(
5731
+ options,
5732
+ config,
5733
+ configDir,
5734
+ interpolatedHunt,
5735
+ redactedFillSteps,
5736
+ randomVars,
5737
+ allowedApps,
5738
+ {
5739
+ targetType: "macos",
5740
+ targetApp: target.app,
5741
+ launchSession: () => launchMacSession({
5742
+ app: target.app,
5743
+ timeoutMs: config.browser.timeout,
5744
+ clientFactory: options.macClientFactory
5745
+ }),
5746
+ closeSession: closeMacSession,
5747
+ sessionDriver: (session) => session.driver,
5748
+ sessionAppIdentity: (session) => session.bundleId
5749
+ }
5750
+ );
5751
+ }
3946
5752
  async function runMacHunt(options, config, configDir, target) {
5753
+ return runNativeHunt(options, config, configDir, target, {
5754
+ targetType: "macos",
5755
+ assertAppAllowed: (allowedApps, nativeTarget) => assertTargetAppAllowed(allowedApps, nativeTarget.app),
5756
+ attempt: executeMacHuntAttempt
5757
+ });
5758
+ }
5759
+ async function executeAndroidHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
5760
+ const launch = options.androidSessionFactory ?? launchAndroidSession;
5761
+ return executeNativeHuntAttempt(
5762
+ options,
5763
+ config,
5764
+ configDir,
5765
+ interpolatedHunt,
5766
+ redactedFillSteps,
5767
+ randomVars,
5768
+ allowedApps,
5769
+ {
5770
+ targetType: "android",
5771
+ targetApp: target.app,
5772
+ launchSession: () => launch({
5773
+ app: target.app,
5774
+ deviceSerial: target.deviceSerial,
5775
+ coldStart: target.coldStart,
5776
+ timeoutMs: config.browser.timeout,
5777
+ allowedApps
5778
+ }),
5779
+ closeSession: closeAndroidSession,
5780
+ sessionDriver: (session) => session.driver,
5781
+ sessionAppIdentity: (session) => session.package
5782
+ }
5783
+ );
5784
+ }
5785
+ async function runAndroidHunt(options, config, configDir, target) {
5786
+ return runNativeHunt(options, config, configDir, target, {
5787
+ targetType: "android",
5788
+ assertAppAllowed: (allowedApps, nativeTarget) => {
5789
+ if (!nativeTarget.app.toLowerCase().endsWith(".apk")) {
5790
+ assertAndroidAppAllowed(allowedApps, nativeTarget.app);
5791
+ }
5792
+ },
5793
+ attempt: executeAndroidHuntAttempt
5794
+ });
5795
+ }
5796
+ async function executeIosHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
5797
+ const launch = options.iosSessionFactory ?? launchIosSession;
5798
+ return executeNativeHuntAttempt(
5799
+ options,
5800
+ config,
5801
+ configDir,
5802
+ interpolatedHunt,
5803
+ redactedFillSteps,
5804
+ randomVars,
5805
+ allowedApps,
5806
+ {
5807
+ targetType: "ios",
5808
+ targetApp: target.app,
5809
+ launchSession: () => launch({
5810
+ app: target.app,
5811
+ udid: target.udid,
5812
+ coldStart: target.coldStart,
5813
+ timeoutMs: config.browser.timeout,
5814
+ allowedApps
5815
+ }),
5816
+ closeSession: closeIosSession,
5817
+ sessionDriver: (session) => session.driver,
5818
+ sessionAppIdentity: (session) => session.bundleId
5819
+ }
5820
+ );
5821
+ }
5822
+ async function runIosHunt(options, config, configDir, target) {
5823
+ return runNativeHunt(options, config, configDir, target, {
5824
+ targetType: "ios",
5825
+ assertAppAllowed: (allowedApps, nativeTarget) => assertIosAppAllowed(allowedApps, nativeTarget.app),
5826
+ attempt: executeIosHuntAttempt
5827
+ });
5828
+ }
5829
+ async function runNativeHunt(options, config, configDir, target, native) {
3947
5830
  const hunt = loadHunt(options.huntName, configDir);
3948
5831
  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);
5832
+ assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
5833
+ assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
5834
+ native.assertAppAllowed(config.guardrails.allowedApps, target);
3952
5835
  const maxSteps = config.guardrails.maxSteps;
3953
5836
  if (interpolatedHunt.steps.length > maxSteps) {
3954
5837
  throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
@@ -3958,9 +5841,9 @@ async function runMacHunt(options, config, configDir, target) {
3958
5841
  let lastResult;
3959
5842
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
3960
5843
  if (attempt > 0 && retryDelay > 0) {
3961
- await delay(retryDelay);
5844
+ await delay3(retryDelay);
3962
5845
  }
3963
- lastResult = await executeMacHuntAttempt(
5846
+ lastResult = await native.attempt(
3964
5847
  options,
3965
5848
  config,
3966
5849
  configDir,
@@ -3988,7 +5871,7 @@ async function runMacHunt(options, config, configDir, target) {
3988
5871
  }
3989
5872
  function recordHistory(configDir, outcome, maxRuns) {
3990
5873
  try {
3991
- const relativeRunDir = import_node_path11.default.relative(configDir, outcome.runDir);
5874
+ const relativeRunDir = import_node_path14.default.relative(configDir, outcome.runDir);
3992
5875
  appendEntry(
3993
5876
  configDir,
3994
5877
  {
@@ -4213,37 +6096,37 @@ function buildRunCommand() {
4213
6096
  }
4214
6097
 
4215
6098
  // src/cli/commands/init.ts
4216
- var import_node_fs12 = __toESM(require("fs"), 1);
4217
- var import_node_path12 = __toESM(require("path"), 1);
6099
+ var import_node_fs15 = __toESM(require("fs"), 1);
6100
+ var import_node_path15 = __toESM(require("path"), 1);
4218
6101
  var import_node_url2 = require("url");
4219
6102
  var import_commander2 = require("commander");
4220
6103
  var import_chalk4 = __toESM(require("chalk"), 1);
4221
6104
  init_loader();
4222
- var import_meta2 = {};
6105
+ var import_meta4 = {};
4223
6106
  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;
6107
+ const currentFile = (0, import_node_url2.fileURLToPath)(import_meta4.url);
6108
+ let dir = import_node_path15.default.dirname(currentFile);
6109
+ const root = import_node_path15.default.parse(dir).root;
4227
6110
  while (dir !== root) {
4228
- if (import_node_fs12.default.existsSync(import_node_path12.default.join(dir, "package.json"))) {
6111
+ if (import_node_fs15.default.existsSync(import_node_path15.default.join(dir, "package.json"))) {
4229
6112
  return dir;
4230
6113
  }
4231
- dir = import_node_path12.default.dirname(dir);
6114
+ dir = import_node_path15.default.dirname(dir);
4232
6115
  }
4233
- if (import_node_fs12.default.existsSync(import_node_path12.default.join(root, "package.json"))) {
6116
+ if (import_node_fs15.default.existsSync(import_node_path15.default.join(root, "package.json"))) {
4234
6117
  return root;
4235
6118
  }
4236
6119
  throw new Error("Cannot find package root. Reinstall prowl-tools.");
4237
6120
  }
4238
6121
  function copyFile(source, destination) {
4239
- import_node_fs12.default.mkdirSync(import_node_path12.default.dirname(destination), { recursive: true });
4240
- import_node_fs12.default.copyFileSync(source, destination);
6122
+ import_node_fs15.default.mkdirSync(import_node_path15.default.dirname(destination), { recursive: true });
6123
+ import_node_fs15.default.copyFileSync(source, destination);
4241
6124
  }
4242
6125
  function buildInitCommand() {
4243
6126
  const command = new import_commander2.Command("init").option("--force", `Overwrite existing ${CONFIG_DIR} directory`).action((options) => {
4244
6127
  const root = process.cwd();
4245
- const prowlDir = import_node_path12.default.join(root, CONFIG_DIR);
4246
- if (import_node_fs12.default.existsSync(prowlDir) && !options.force) {
6128
+ const prowlDir = import_node_path15.default.join(root, CONFIG_DIR);
6129
+ if (import_node_fs15.default.existsSync(prowlDir) && !options.force) {
4247
6130
  console.error(
4248
6131
  import_chalk4.default.red(
4249
6132
  `${CONFIG_DIR} already exists. Run with --force to reinitialize prowl configuration without deleting existing files.`
@@ -4253,20 +6136,20 @@ function buildInitCommand() {
4253
6136
  return;
4254
6137
  }
4255
6138
  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)) {
6139
+ const examplesDir = import_node_path15.default.join(packageRoot, "examples");
6140
+ const exampleConfig = import_node_path15.default.join(examplesDir, "config.yml");
6141
+ const exampleHuntsDir = import_node_path15.default.join(examplesDir, "hunts");
6142
+ if (!import_node_fs15.default.existsSync(exampleConfig) || !import_node_fs15.default.existsSync(exampleHuntsDir)) {
4260
6143
  console.error(import_chalk4.default.red("Examples not found in package. Reinstall prowl-tools."));
4261
6144
  process.exitCode = 1;
4262
6145
  return;
4263
6146
  }
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"));
6147
+ copyFile(exampleConfig, import_node_path15.default.join(prowlDir, "config.yml"));
6148
+ const huntFiles = import_node_fs15.default.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
4266
6149
  for (const huntFile of huntFiles) {
4267
6150
  copyFile(
4268
- import_node_path12.default.join(exampleHuntsDir, huntFile),
4269
- import_node_path12.default.join(prowlDir, "hunts", huntFile)
6151
+ import_node_path15.default.join(exampleHuntsDir, huntFile),
6152
+ import_node_path15.default.join(prowlDir, "hunts", huntFile)
4270
6153
  );
4271
6154
  }
4272
6155
  const gitignore = [
@@ -4280,7 +6163,7 @@ function buildInitCommand() {
4280
6163
  ".env",
4281
6164
  ""
4282
6165
  ].join("\n");
4283
- import_node_fs12.default.writeFileSync(import_node_path12.default.join(prowlDir, ".gitignore"), gitignore);
6166
+ import_node_fs15.default.writeFileSync(import_node_path15.default.join(prowlDir, ".gitignore"), gitignore);
4284
6167
  console.log(welcomeBanner());
4285
6168
  console.log(import_chalk4.default.green(` Initialized ${CONFIG_DIR} directory.`));
4286
6169
  console.log(import_chalk4.default.gray(" Run ") + import_chalk4.default.bold("prowl run hello") + import_chalk4.default.gray(" to get started."));
@@ -4290,17 +6173,17 @@ function buildInitCommand() {
4290
6173
  }
4291
6174
 
4292
6175
  // src/cli/commands/login.ts
4293
- var import_node_path13 = __toESM(require("path"), 1);
6176
+ var import_node_path16 = __toESM(require("path"), 1);
4294
6177
  var import_node_readline = __toESM(require("readline"), 1);
4295
6178
  var import_chalk5 = __toESM(require("chalk"), 1);
4296
6179
  var import_commander3 = require("commander");
4297
6180
  init_loader();
4298
6181
  function resolvePath2(configDir, inputPath) {
4299
- if (import_node_path13.default.isAbsolute(inputPath)) {
6182
+ if (import_node_path16.default.isAbsolute(inputPath)) {
4300
6183
  return inputPath;
4301
6184
  }
4302
- const projectRoot = import_node_path13.default.dirname(configDir);
4303
- return import_node_path13.default.join(projectRoot, inputPath);
6185
+ const projectRoot = import_node_path16.default.dirname(configDir);
6186
+ return import_node_path16.default.join(projectRoot, inputPath);
4304
6187
  }
4305
6188
  function waitForEnter(prompt) {
4306
6189
  return new Promise((resolve) => {
@@ -4316,7 +6199,7 @@ function buildLoginCommand() {
4316
6199
  let session = null;
4317
6200
  try {
4318
6201
  const { config, configDir } = loadConfig(options.config);
4319
- if (config.target.type === "macos") {
6202
+ if (config.target.type !== "web") {
4320
6203
  throw new Error("`prowl login` captures browser auth state and only applies to web targets.");
4321
6204
  }
4322
6205
  const targetUrl = options.url ?? config.target.url;
@@ -4387,18 +6270,18 @@ function buildListCommand() {
4387
6270
  }
4388
6271
 
4389
6272
  // src/cli/commands/watch.ts
4390
- var import_node_fs13 = __toESM(require("fs"), 1);
6273
+ var import_node_fs16 = __toESM(require("fs"), 1);
4391
6274
  var import_chalk7 = __toESM(require("chalk"), 1);
4392
6275
  var import_commander5 = require("commander");
4393
6276
  init_loader();
4394
6277
 
4395
6278
  // src/cli/watch-utils.ts
4396
- var import_node_path14 = __toESM(require("path"), 1);
6279
+ var import_node_path17 = __toESM(require("path"), 1);
4397
6280
  function getWatchTargets(configDir, huntName) {
4398
6281
  return [
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")
6282
+ import_node_path17.default.join(configDir, "hunts", `${huntName}.yml`),
6283
+ import_node_path17.default.join(configDir, "config.yml"),
6284
+ import_node_path17.default.join(configDir, ".env")
4402
6285
  ];
4403
6286
  }
4404
6287
  function createDebouncer(delayMs, fn) {
@@ -4467,14 +6350,14 @@ function buildWatchCommand() {
4467
6350
  });
4468
6351
  const unwatch = [];
4469
6352
  for (const target of watchTargets) {
4470
- import_node_fs13.default.watchFile(target, { interval: 150 }, (curr, prev) => {
6353
+ import_node_fs16.default.watchFile(target, { interval: 150 }, (curr, prev) => {
4471
6354
  if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) {
4472
6355
  return;
4473
6356
  }
4474
6357
  console.log(import_chalk7.default.gray(`Change detected: ${target}`));
4475
6358
  debounced.trigger();
4476
6359
  });
4477
- unwatch.push(() => import_node_fs13.default.unwatchFile(target));
6360
+ unwatch.push(() => import_node_fs16.default.unwatchFile(target));
4478
6361
  }
4479
6362
  const stop = () => {
4480
6363
  if (stopped) {
@@ -4500,12 +6383,12 @@ var import_commander6 = require("commander");
4500
6383
  var import_chalk9 = __toESM(require("chalk"), 1);
4501
6384
 
4502
6385
  // src/runner/suite.ts
4503
- var import_node_path17 = __toESM(require("path"), 1);
6386
+ var import_node_path20 = __toESM(require("path"), 1);
4504
6387
  init_loader();
4505
6388
 
4506
6389
  // src/reporter/ci-summary.ts
4507
- var import_node_fs14 = __toESM(require("fs"), 1);
4508
- var import_node_path15 = __toESM(require("path"), 1);
6390
+ var import_node_fs17 = __toESM(require("fs"), 1);
6391
+ var import_node_path18 = __toESM(require("path"), 1);
4509
6392
  var import_chalk8 = __toESM(require("chalk"), 1);
4510
6393
  function countCiResults(results) {
4511
6394
  return {
@@ -4569,9 +6452,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
4569
6452
  ...flaky.length > 0 ? { flaky } : {},
4570
6453
  ...clusters.length > 0 ? { clusters } : {}
4571
6454
  };
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");
6455
+ import_node_fs17.default.mkdirSync(ciRunDir, { recursive: true });
6456
+ const filePath = import_node_path18.default.join(ciRunDir, "ci-result.json");
6457
+ import_node_fs17.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
4575
6458
  return filePath;
4576
6459
  }
4577
6460
 
@@ -4705,8 +6588,8 @@ function clusterFailures(failures) {
4705
6588
  }
4706
6589
 
4707
6590
  // src/backlog/index.ts
4708
- var import_node_fs15 = __toESM(require("fs"), 1);
4709
- var import_node_path16 = __toESM(require("path"), 1);
6591
+ var import_node_fs18 = __toESM(require("fs"), 1);
6592
+ var import_node_path19 = __toESM(require("path"), 1);
4710
6593
 
4711
6594
  // src/backlog/parse.ts
4712
6595
  var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
@@ -4796,7 +6679,7 @@ ${after}`;
4796
6679
  // src/backlog/index.ts
4797
6680
  function readFileOrEmpty(filePath) {
4798
6681
  try {
4799
- return import_node_fs15.default.readFileSync(filePath, "utf-8");
6682
+ return import_node_fs18.default.readFileSync(filePath, "utf-8");
4800
6683
  } catch (error) {
4801
6684
  const err = error;
4802
6685
  if (err.code === "ENOENT") return "";
@@ -4812,7 +6695,7 @@ function buildFailure(hunt) {
4812
6695
  if (!hunt.runDir) return failure;
4813
6696
  let run;
4814
6697
  try {
4815
- const resultJson = readFileOrEmpty(import_node_path16.default.join(hunt.runDir, "result.json"));
6698
+ const resultJson = readFileOrEmpty(import_node_path19.default.join(hunt.runDir, "result.json"));
4816
6699
  if (!resultJson) return failure;
4817
6700
  run = JSON.parse(resultJson);
4818
6701
  } catch (error) {
@@ -4841,8 +6724,8 @@ function extractFailures(suiteResult) {
4841
6724
  }
4842
6725
  function updateBacklogFromSuite(suiteResult, options = {}) {
4843
6726
  const projectRoot = options.projectRoot ?? process.cwd();
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");
6727
+ const backlogPath = options.backlogPath ?? import_node_path19.default.join(projectRoot, "docs", "backlog.md");
6728
+ const resolvedPath = options.resolvedPath ?? import_node_path19.default.join(projectRoot, "docs", "resolved.md");
4846
6729
  const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
4847
6730
  const summary = { created: [], regressions: [], skipped: [], backlogPath };
4848
6731
  const failures = extractFailures(suiteResult);
@@ -4874,8 +6757,8 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
4874
6757
  }
4875
6758
  }
4876
6759
  if (ticketsToAdd.length > 0) {
4877
- import_node_fs15.default.mkdirSync(import_node_path16.default.dirname(backlogPath), { recursive: true });
4878
- import_node_fs15.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
6760
+ import_node_fs18.default.mkdirSync(import_node_path19.default.dirname(backlogPath), { recursive: true });
6761
+ import_node_fs18.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
4879
6762
  }
4880
6763
  return summary;
4881
6764
  }
@@ -5036,7 +6919,7 @@ async function runSuite(options = {}) {
5036
6919
  const clusters = clusterFailures(
5037
6920
  extractFailures({ result: { hunts: results }, resultPath: null })
5038
6921
  ).filter((cluster) => cluster.count > 1);
5039
- const ciRunDir = import_node_path17.default.join(configDir, "runs", timestamp("ci"));
6922
+ const ciRunDir = import_node_path20.default.join(configDir, "runs", timestamp("ci"));
5040
6923
  const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
5041
6924
  const { passed, failed, skipped } = countCiResults(results);
5042
6925
  return {
@@ -5154,8 +7037,8 @@ function buildCiCommand() {
5154
7037
  }
5155
7038
 
5156
7039
  // src/cli/commands/update-baselines.ts
5157
- var import_node_fs16 = __toESM(require("fs"), 1);
5158
- var import_node_path18 = __toESM(require("path"), 1);
7040
+ var import_node_fs19 = __toESM(require("fs"), 1);
7041
+ var import_node_path21 = __toESM(require("path"), 1);
5159
7042
  var import_commander7 = require("commander");
5160
7043
  var import_chalk10 = __toESM(require("chalk"), 1);
5161
7044
  init_loader();
@@ -5163,33 +7046,33 @@ function buildUpdateBaselinesCommand() {
5163
7046
  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) => {
5164
7047
  try {
5165
7048
  const { configDir } = loadConfig(options.config);
5166
- const baselinesDir = import_node_path18.default.join(configDir, "baselines");
5167
- import_node_fs16.default.mkdirSync(baselinesDir, { recursive: true });
7049
+ const baselinesDir = import_node_path21.default.join(configDir, "baselines");
7050
+ import_node_fs19.default.mkdirSync(baselinesDir, { recursive: true });
5168
7051
  let runDir;
5169
7052
  if (options.run) {
5170
- runDir = import_node_path18.default.isAbsolute(options.run) ? options.run : import_node_path18.default.resolve(options.run);
7053
+ runDir = import_node_path21.default.isAbsolute(options.run) ? options.run : import_node_path21.default.resolve(options.run);
5171
7054
  } else {
5172
- const runsDir = import_node_path18.default.join(configDir, "runs");
5173
- if (!import_node_fs16.default.existsSync(runsDir)) {
7055
+ const runsDir = import_node_path21.default.join(configDir, "runs");
7056
+ if (!import_node_fs19.default.existsSync(runsDir)) {
5174
7057
  console.error(import_chalk10.default.red(" No runs directory found. Run a hunt first."));
5175
7058
  process.exitCode = 1;
5176
7059
  return;
5177
7060
  }
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();
7061
+ const entries = import_node_fs19.default.readdirSync(runsDir).filter((e) => import_node_fs19.default.statSync(import_node_path21.default.join(runsDir, e)).isDirectory()).sort().reverse();
5179
7062
  if (entries.length === 0) {
5180
7063
  console.error(import_chalk10.default.red(" No run directories found. Run a hunt first."));
5181
7064
  process.exitCode = 1;
5182
7065
  return;
5183
7066
  }
5184
- runDir = import_node_path18.default.join(runsDir, entries[0]);
7067
+ runDir = import_node_path21.default.join(runsDir, entries[0]);
5185
7068
  }
5186
- const screenshotsDir = import_node_path18.default.join(runDir, "screenshots");
5187
- if (!import_node_fs16.default.existsSync(screenshotsDir)) {
7069
+ const screenshotsDir = import_node_path21.default.join(runDir, "screenshots");
7070
+ if (!import_node_fs19.default.existsSync(screenshotsDir)) {
5188
7071
  console.error(import_chalk10.default.red(` No screenshots found in ${runDir}`));
5189
7072
  process.exitCode = 1;
5190
7073
  return;
5191
7074
  }
5192
- const screenshots = import_node_fs16.default.readdirSync(screenshotsDir).filter((f) => f.endsWith("-current.png"));
7075
+ const screenshots = import_node_fs19.default.readdirSync(screenshotsDir).filter((f) => f.endsWith("-current.png"));
5193
7076
  if (screenshots.length === 0) {
5194
7077
  console.log(import_chalk10.default.yellow(" No assertScreenshot results found in this run."));
5195
7078
  return;
@@ -5203,10 +7086,10 @@ function buildUpdateBaselinesCommand() {
5203
7086
  let updated = 0;
5204
7087
  for (const file of filtered) {
5205
7088
  const baselineName = file.replace("-current.png", ".png");
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);
7089
+ const sourcePath = import_node_path21.default.join(screenshotsDir, file);
7090
+ const destPath = import_node_path21.default.join(baselinesDir, baselineName);
7091
+ const exists = import_node_fs19.default.existsSync(destPath);
7092
+ import_node_fs19.default.copyFileSync(sourcePath, destPath);
5210
7093
  updated++;
5211
7094
  const status = exists ? import_chalk10.default.yellow("updated") : import_chalk10.default.green("created");
5212
7095
  console.log(` ${status} ${baselineName}`);
@@ -5359,6 +7242,134 @@ async function analyzePage(page) {
5359
7242
  };
5360
7243
  }
5361
7244
 
7245
+ // src/analyzer/mac.ts
7246
+ var INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
7247
+ "AXButton",
7248
+ "AXTextField",
7249
+ "AXSecureTextField",
7250
+ "AXTextArea",
7251
+ "AXCheckBox",
7252
+ "AXRadioButton",
7253
+ "AXPopUpButton",
7254
+ "AXMenuButton",
7255
+ "AXLink",
7256
+ "AXMenuItem",
7257
+ "AXComboBox",
7258
+ "AXSlider",
7259
+ "AXDisclosureTriangle"
7260
+ ]);
7261
+ var DEFAULT_ANALYZE_TREE_DEPTH = 20;
7262
+ function str(value) {
7263
+ if (typeof value !== "string") {
7264
+ return void 0;
7265
+ }
7266
+ const trimmed = value.trim();
7267
+ return trimmed.length > 0 ? trimmed : void 0;
7268
+ }
7269
+ function toNode(raw) {
7270
+ const node = raw ?? {};
7271
+ const children = Array.isArray(node.children) ? node.children.map(toNode) : void 0;
7272
+ return {
7273
+ role: str(node.role),
7274
+ title: str(node.title),
7275
+ description: str(node.description),
7276
+ value: str(node.value),
7277
+ identifier: str(node.identifier),
7278
+ enabled: typeof node.enabled === "boolean" ? node.enabled : void 0,
7279
+ ...children ? { children } : {}
7280
+ };
7281
+ }
7282
+ function quote(value) {
7283
+ return `"${value}"`;
7284
+ }
7285
+ function rankMacSelectors(node) {
7286
+ const selectors = [];
7287
+ const identifier = node.identifier;
7288
+ const exactLabel = node.title ?? node.description;
7289
+ const name = node.title ?? node.description ?? node.value;
7290
+ if (identifier) {
7291
+ selectors.push(`id=${identifier}`);
7292
+ }
7293
+ if (exactLabel) {
7294
+ selectors.push(`label=${quote(exactLabel)}`);
7295
+ }
7296
+ if (node.role && name) {
7297
+ selectors.push(`role=${node.role}[name=${quote(name)}]`);
7298
+ }
7299
+ if (name) {
7300
+ selectors.push(`text=${quote(name)}`);
7301
+ }
7302
+ if (selectors.length === 0 && node.role) {
7303
+ selectors.push(`role=${node.role}`);
7304
+ }
7305
+ return selectors;
7306
+ }
7307
+ function toElement(node, source) {
7308
+ return {
7309
+ role: node.role ?? "?",
7310
+ ...node.title ? { title: node.title } : {},
7311
+ ...node.description ? { description: node.description } : {},
7312
+ ...node.value ? { value: node.value } : {},
7313
+ ...node.identifier ? { identifier: node.identifier } : {},
7314
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
7315
+ source,
7316
+ selectors: rankMacSelectors(node)
7317
+ };
7318
+ }
7319
+ function collectInteractive(root) {
7320
+ const out = [];
7321
+ const visit = (node) => {
7322
+ if (node.role && INTERACTIVE_ROLES.has(node.role)) {
7323
+ out.push(toElement(node, "window"));
7324
+ }
7325
+ for (const child of node.children ?? []) {
7326
+ visit(child);
7327
+ }
7328
+ };
7329
+ visit(root);
7330
+ return out;
7331
+ }
7332
+ function toWindow(node) {
7333
+ const [best] = rankMacSelectors(node);
7334
+ return {
7335
+ ...node.title ? { title: node.title } : {},
7336
+ ...node.identifier ? { identifier: node.identifier } : {},
7337
+ selector: best ?? "role=AXWindow"
7338
+ };
7339
+ }
7340
+ async function analyzeMacApp(client, options) {
7341
+ const depth = options.treeDepth ?? DEFAULT_ANALYZE_TREE_DEPTH;
7342
+ const treeResult = await client.request("tree", { depth });
7343
+ const elements = collectInteractive(toNode(treeResult.tree));
7344
+ const windowsResult = await client.request("windows");
7345
+ const rawWindows = Array.isArray(windowsResult.windows) ? windowsResult.windows : [];
7346
+ const windows = rawWindows.map((raw) => toWindow(toNode(raw)));
7347
+ const menuItems = await readStatusMenu(client, options.menuTimeoutSeconds);
7348
+ return { app: options.app, elements, windows, menuItems };
7349
+ }
7350
+ async function readStatusMenu(client, menuTimeoutSeconds) {
7351
+ let statusItems;
7352
+ try {
7353
+ const status = await client.request("statusItems");
7354
+ statusItems = Array.isArray(status.items) ? status.items : [];
7355
+ } catch {
7356
+ return [];
7357
+ }
7358
+ if (statusItems.length === 0) {
7359
+ return [];
7360
+ }
7361
+ const params = menuTimeoutSeconds !== void 0 ? { timeout: menuTimeoutSeconds } : {};
7362
+ try {
7363
+ const menu = await client.request("openMenu", params);
7364
+ const rawItems = Array.isArray(menu.items) ? menu.items : [];
7365
+ return rawItems.map((raw) => toNode(raw)).filter((node) => node.role !== "AXMenuItem" || node.title || node.identifier || node.description).map((node) => toElement(node, "menu"));
7366
+ } catch {
7367
+ return [];
7368
+ } finally {
7369
+ await client.request("closeMenu").catch(() => void 0);
7370
+ }
7371
+ }
7372
+
5362
7373
  // src/cli/commands/analyze.ts
5363
7374
  init_loader();
5364
7375
  function parseViewportFlag2(value) {
@@ -5368,73 +7379,160 @@ function parseViewportFlag2(value) {
5368
7379
  }
5369
7380
  return value;
5370
7381
  }
5371
- function buildAnalyzeCommand() {
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) => {
5373
- try {
5374
- const engine = parseBrowserEngine(options.browser);
5375
- const channel = options.channel;
5376
- const viewport = options.viewport ? resolveViewport(parseViewportFlag2(options.viewport)) : { width: 1280, height: 720 };
5377
- const session = await launchBrowser({
5378
- headless: !options.headed,
5379
- slowMo: 0,
5380
- timeout: 3e4,
5381
- trace: false,
5382
- recordHar: false,
5383
- runDir: process.cwd(),
5384
- engine,
5385
- channel,
5386
- viewport
5387
- });
5388
- const driver = createPlaywrightDriver(session.page);
5389
- try {
5390
- await driver.goto(url, { waitUntil: "networkidle" });
5391
- const result = await analyzePage(driver);
5392
- if (options.json) {
5393
- console.log(JSON.stringify(result, null, 2));
5394
- } else {
5395
- console.log(`
7382
+ function tryLoadConfig(configPath) {
7383
+ const configLocation = configPath !== void 0 ? configPath : findConfigPath(process.cwd());
7384
+ if (configLocation === null) {
7385
+ return null;
7386
+ }
7387
+ try {
7388
+ return loadConfig(configLocation).config;
7389
+ } catch (error) {
7390
+ const message = error instanceof Error ? error.message : "Unknown configuration error";
7391
+ throw new Error(`Failed to load config at ${configLocation}: ${message}`);
7392
+ }
7393
+ }
7394
+ async function runWebAnalyze(url, options) {
7395
+ const engine = parseBrowserEngine(options.browser);
7396
+ const channel = options.channel;
7397
+ const viewport = options.viewport ? resolveViewport(parseViewportFlag2(options.viewport)) : { width: 1280, height: 720 };
7398
+ const session = await launchBrowser({
7399
+ headless: !options.headed,
7400
+ slowMo: 0,
7401
+ timeout: 3e4,
7402
+ trace: false,
7403
+ recordHar: false,
7404
+ runDir: process.cwd(),
7405
+ engine,
7406
+ channel,
7407
+ viewport
7408
+ });
7409
+ const driver = createPlaywrightDriver(session.page);
7410
+ try {
7411
+ await driver.goto(url, { waitUntil: "networkidle" });
7412
+ const result = await analyzePage(driver);
7413
+ if (options.json) {
7414
+ console.log(JSON.stringify(result, null, 2));
7415
+ } else {
7416
+ console.log(`
5396
7417
  ${import_chalk11.default.bold("Page Analysis:")} ${result.title}`);
5397
- console.log(` ${import_chalk11.default.gray("URL:")} ${result.url}
7418
+ console.log(` ${import_chalk11.default.gray("URL:")} ${result.url}
5398
7419
  `);
5399
- if (result.forms.length > 0) {
5400
- console.log(import_chalk11.default.bold(" Forms:"));
5401
- for (const form of result.forms) {
5402
- const method = form.method ? import_chalk11.default.cyan(form.method) : "";
5403
- const action = form.action ? import_chalk11.default.gray(form.action) : "";
5404
- console.log(` [${form.index}] ${method} ${action} (${form.fieldCount} fields)`);
5405
- }
5406
- console.log();
5407
- }
5408
- if (result.elements.length > 0) {
5409
- console.log(import_chalk11.default.bold(" Interactive Elements:"));
5410
- for (const el of result.elements) {
5411
- const tag = import_chalk11.default.cyan(el.tag);
5412
- const type = el.type ? import_chalk11.default.gray(`[${el.type}]`) : "";
5413
- const bestSelector = el.selectors.testId ?? el.selectors.label ?? el.selectors.ariaLabel ?? el.selectors.css ?? el.selectors.name ?? "";
5414
- const selectorStr = bestSelector ? import_chalk11.default.yellow(bestSelector) : import_chalk11.default.gray("(no selector)");
5415
- const req = el.required ? import_chalk11.default.red(" *") : "";
5416
- const form = el.formGroup !== void 0 ? import_chalk11.default.gray(` form[${el.formGroup}]`) : "";
5417
- console.log(` ${tag}${type} ${selectorStr}${req}${form}`);
5418
- }
5419
- console.log();
5420
- }
5421
- if (result.links.length > 0) {
5422
- console.log(import_chalk11.default.bold(" Links:"));
5423
- for (const link of result.links.slice(0, 20)) {
5424
- const text = link.text || import_chalk11.default.gray("(no text)");
5425
- console.log(` ${text} ${import_chalk11.default.gray("\u2192")} ${import_chalk11.default.blue(link.href)}`);
5426
- }
5427
- if (result.links.length > 20) {
5428
- console.log(import_chalk11.default.gray(` ... and ${result.links.length - 20} more`));
5429
- }
5430
- console.log();
5431
- }
5432
- console.log(import_chalk11.default.gray(` ${result.elements.length} elements, ${result.forms.length} forms, ${result.links.length} links
7420
+ if (result.forms.length > 0) {
7421
+ console.log(import_chalk11.default.bold(" Forms:"));
7422
+ for (const form of result.forms) {
7423
+ const method = form.method ? import_chalk11.default.cyan(form.method) : "";
7424
+ const action = form.action ? import_chalk11.default.gray(form.action) : "";
7425
+ console.log(` [${form.index}] ${method} ${action} (${form.fieldCount} fields)`);
7426
+ }
7427
+ console.log();
7428
+ }
7429
+ if (result.elements.length > 0) {
7430
+ console.log(import_chalk11.default.bold(" Interactive Elements:"));
7431
+ for (const el of result.elements) {
7432
+ const tag = import_chalk11.default.cyan(el.tag);
7433
+ const type = el.type ? import_chalk11.default.gray(`[${el.type}]`) : "";
7434
+ const bestSelector = el.selectors.testId ?? el.selectors.label ?? el.selectors.ariaLabel ?? el.selectors.css ?? el.selectors.name ?? "";
7435
+ const selectorStr = bestSelector ? import_chalk11.default.yellow(bestSelector) : import_chalk11.default.gray("(no selector)");
7436
+ const req = el.required ? import_chalk11.default.red(" *") : "";
7437
+ const form = el.formGroup !== void 0 ? import_chalk11.default.gray(` form[${el.formGroup}]`) : "";
7438
+ console.log(` ${tag}${type} ${selectorStr}${req}${form}`);
7439
+ }
7440
+ console.log();
7441
+ }
7442
+ if (result.links.length > 0) {
7443
+ console.log(import_chalk11.default.bold(" Links:"));
7444
+ for (const link of result.links.slice(0, 20)) {
7445
+ const text = link.text || import_chalk11.default.gray("(no text)");
7446
+ console.log(` ${text} ${import_chalk11.default.gray("\u2192")} ${import_chalk11.default.blue(link.href)}`);
7447
+ }
7448
+ if (result.links.length > 20) {
7449
+ console.log(import_chalk11.default.gray(` ... and ${result.links.length - 20} more`));
7450
+ }
7451
+ console.log();
7452
+ }
7453
+ console.log(import_chalk11.default.gray(` ${result.elements.length} elements, ${result.forms.length} forms, ${result.links.length} links
5433
7454
  `));
7455
+ }
7456
+ } finally {
7457
+ await closeBrowser(session);
7458
+ }
7459
+ }
7460
+ function printMacElement(el) {
7461
+ const role = import_chalk11.default.cyan(el.role);
7462
+ const [best] = el.selectors;
7463
+ const selectorStr = best ? import_chalk11.default.yellow(best) : import_chalk11.default.gray("(no selector)");
7464
+ const label = el.title ?? el.description ?? el.value;
7465
+ const labelStr = label ? ` ${import_chalk11.default.gray(`"${label}"`)}` : "";
7466
+ const disabled = el.enabled === false ? import_chalk11.default.gray(" (disabled)") : "";
7467
+ console.log(` ${role} ${selectorStr}${labelStr}${disabled}`);
7468
+ }
7469
+ async function runMacAnalyze(app, config, options) {
7470
+ const allowedApps = config?.guardrails.allowedApps ?? [];
7471
+ assertTargetAppAllowed(allowedApps, app);
7472
+ const session = await launchMacSession({ app, timeoutMs: config?.browser.timeout });
7473
+ try {
7474
+ const result = await analyzeMacApp(session.client, { app: session.bundleId });
7475
+ if (options.json) {
7476
+ console.log(JSON.stringify(result, null, 2));
7477
+ } else {
7478
+ console.log(`
7479
+ ${import_chalk11.default.bold("App Analysis:")} ${result.app}
7480
+ `);
7481
+ if (result.windows.length > 0) {
7482
+ console.log(import_chalk11.default.bold(" Windows:"));
7483
+ for (const win of result.windows) {
7484
+ const title = win.title ? import_chalk11.default.gray(`"${win.title}"`) : import_chalk11.default.gray("(untitled)");
7485
+ console.log(` ${title} ${import_chalk11.default.yellow(win.selector)}`);
5434
7486
  }
5435
- } finally {
5436
- await closeBrowser(session);
7487
+ console.log();
7488
+ }
7489
+ if (result.elements.length > 0) {
7490
+ console.log(import_chalk11.default.bold(" Interactive Elements:"));
7491
+ for (const el of result.elements) {
7492
+ printMacElement(el);
7493
+ }
7494
+ console.log();
7495
+ }
7496
+ if (result.menuItems.length > 0) {
7497
+ console.log(import_chalk11.default.bold(" Menu Bar:"));
7498
+ for (const el of result.menuItems) {
7499
+ printMacElement(el);
7500
+ }
7501
+ console.log();
7502
+ }
7503
+ console.log(
7504
+ import_chalk11.default.gray(
7505
+ ` ${result.elements.length} elements, ${result.windows.length} windows, ${result.menuItems.length} menu items
7506
+ `
7507
+ )
7508
+ );
7509
+ }
7510
+ } finally {
7511
+ await session.client.close();
7512
+ }
7513
+ }
7514
+ function buildAnalyzeCommand() {
7515
+ const command = new import_commander8.Command("analyze").argument("[url]", "URL to analyze (web target)").description("Analyze a page or macOS app to discover interactive elements and selectors").option("--json", "Output as JSON").option("--app <app>", "macOS app to analyze: bundle id or /path/to/App.app (macOS target)").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) => {
7516
+ try {
7517
+ if (options.app && url) {
7518
+ throw new Error("Pass either a URL argument (web) or --app (macOS), not both.");
7519
+ }
7520
+ if (options.app) {
7521
+ await runMacAnalyze(options.app, tryLoadConfig(options.config), options);
7522
+ return;
7523
+ }
7524
+ if (url) {
7525
+ await runWebAnalyze(url, options);
7526
+ return;
7527
+ }
7528
+ const config = tryLoadConfig(options.config);
7529
+ if (config?.target.type === "macos") {
7530
+ await runMacAnalyze(config.target.app, config, options);
7531
+ return;
5437
7532
  }
7533
+ throw new Error(
7534
+ "analyze needs a target: pass a URL for the web target, or use --app <bundle-id|.app-path> (or set target.type: macos in .prowl/config.yml) for the macOS target."
7535
+ );
5438
7536
  } catch (error) {
5439
7537
  const message = error instanceof Error ? error.message : "Analysis failed";
5440
7538
  if (options.json) {
@@ -5451,8 +7549,8 @@ function buildAnalyzeCommand() {
5451
7549
  }
5452
7550
 
5453
7551
  // src/cli/commands/generate.ts
5454
- var import_node_fs17 = __toESM(require("fs"), 1);
5455
- var import_node_path19 = __toESM(require("path"), 1);
7552
+ var import_node_fs20 = __toESM(require("fs"), 1);
7553
+ var import_node_path22 = __toESM(require("path"), 1);
5456
7554
  var import_commander9 = require("commander");
5457
7555
  var import_chalk12 = __toESM(require("chalk"), 1);
5458
7556
  var import_ora = __toESM(require("ora"), 1);
@@ -5721,13 +7819,13 @@ function buildGenerateCommand() {
5721
7819
  const result = loadConfig2(options.config);
5722
7820
  configDir = result.configDir;
5723
7821
  } catch {
5724
- configDir = import_node_path19.default.join(process.cwd(), ".prowl");
7822
+ configDir = import_node_path22.default.join(process.cwd(), ".prowl");
5725
7823
  }
5726
- const huntsDir = import_node_path19.default.join(configDir, "hunts");
5727
- import_node_fs17.default.mkdirSync(huntsDir, { recursive: true });
7824
+ const huntsDir = import_node_path22.default.join(configDir, "hunts");
7825
+ import_node_fs20.default.mkdirSync(huntsDir, { recursive: true });
5728
7826
  const fileName = options.output.endsWith(".yml") ? options.output : `${options.output}.yml`;
5729
- const filePath = import_node_path19.default.join(huntsDir, fileName);
5730
- import_node_fs17.default.writeFileSync(filePath, yamlStr + "\n", "utf-8");
7827
+ const filePath = import_node_path22.default.join(huntsDir, fileName);
7828
+ import_node_fs20.default.writeFileSync(filePath, yamlStr + "\n", "utf-8");
5731
7829
  console.log(import_chalk12.default.green(` Saved to ${filePath}`));
5732
7830
  } else {
5733
7831
  console.log(yamlStr);
@@ -5924,7 +8022,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
5924
8022
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
5925
8023
 
5926
8024
  // src/mcp/tools.ts
5927
- var import_node_path20 = __toESM(require("path"), 1);
8025
+ var import_node_path23 = __toESM(require("path"), 1);
5928
8026
  init_loader();
5929
8027
  function listHuntsTool(configPath) {
5930
8028
  const { configDir } = loadConfig(configPath);
@@ -5932,7 +8030,7 @@ function listHuntsTool(configPath) {
5932
8030
  }
5933
8031
  async function runSuiteTool(args = {}, options = {}) {
5934
8032
  const { configPath: resolvedConfigPath, configDir, config } = loadConfig(options.configPath);
5935
- const projectRoot = options.projectRoot ?? import_node_path20.default.dirname(configDir);
8033
+ const projectRoot = options.projectRoot ?? import_node_path23.default.dirname(configDir);
5936
8034
  const suite = await runSuite({
5937
8035
  configPath: resolvedConfigPath,
5938
8036
  includeTags: args.includeTags,
@@ -5941,8 +8039,8 @@ async function runSuiteTool(args = {}, options = {}) {
5941
8039
  });
5942
8040
  const bugLogCfg = config.bugLog ?? {};
5943
8041
  const logBugs = args.logBugs ?? bugLogCfg.enabled ?? true;
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;
8042
+ const backlogPath = bugLogCfg.backlogPath ? import_node_path23.default.resolve(projectRoot, bugLogCfg.backlogPath) : void 0;
8043
+ const resolvedPath = bugLogCfg.resolvedPath ? import_node_path23.default.resolve(projectRoot, bugLogCfg.resolvedPath) : backlogPath ? import_node_path23.default.join(import_node_path23.default.dirname(backlogPath), "resolved.md") : void 0;
5946
8044
  const bugs = logBugs ? updateBacklogFromSuite(suite, { projectRoot, backlogPath, resolvedPath }) : { created: [], regressions: [], skipped: [], backlogPath: null };
5947
8045
  const { status, totalHunts, passed, failed, skipped } = suite.result;
5948
8046
  return {
@@ -5969,9 +8067,9 @@ async function runHuntTool(args, configPath) {
5969
8067
  }
5970
8068
 
5971
8069
  // src/mcp/projects.ts
5972
- var import_node_fs18 = __toESM(require("fs"), 1);
5973
- var import_node_os = __toESM(require("os"), 1);
5974
- var import_node_path21 = __toESM(require("path"), 1);
8070
+ var import_node_fs21 = __toESM(require("fs"), 1);
8071
+ var import_node_os3 = __toESM(require("os"), 1);
8072
+ var import_node_path24 = __toESM(require("path"), 1);
5975
8073
  var import_yaml3 = __toESM(require("yaml"), 1);
5976
8074
  var import_zod2 = require("zod");
5977
8075
  var projectEntrySchema = import_zod2.z.object({
@@ -5983,37 +8081,37 @@ var projectRegistrySchema = import_zod2.z.object({
5983
8081
  projects: import_zod2.z.record(import_zod2.z.string().min(1), projectEntrySchema)
5984
8082
  }).strict();
5985
8083
  function defaultRegistryPath() {
5986
- return import_node_path21.default.join(import_node_os.default.homedir(), ".prowl", "projects.yml");
8084
+ return import_node_path24.default.join(import_node_os3.default.homedir(), ".prowl", "projects.yml");
5987
8085
  }
5988
8086
  function legacyRegistryPath() {
5989
- return import_node_path21.default.join(import_node_os.default.homedir(), ".prowlqa", "projects.yml");
8087
+ return import_node_path24.default.join(import_node_os3.default.homedir(), ".prowlqa", "projects.yml");
5990
8088
  }
5991
8089
  function resolveProjectConfigPath(root) {
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;
8090
+ const preferred = import_node_path24.default.join(root, ".prowl", "config.yml");
8091
+ if (import_node_fs21.default.existsSync(preferred)) return preferred;
8092
+ const legacy = import_node_path24.default.join(root, ".prowlqa", "config.yml");
8093
+ if (import_node_fs21.default.existsSync(legacy)) return legacy;
5996
8094
  return preferred;
5997
8095
  }
5998
8096
  function resolveRegistryRelativePath(registry, inputPath) {
5999
- return import_node_path21.default.isAbsolute(inputPath) ? inputPath : import_node_path21.default.resolve(import_node_path21.default.dirname(registry.registryPath), inputPath);
8097
+ return import_node_path24.default.isAbsolute(inputPath) ? inputPath : import_node_path24.default.resolve(import_node_path24.default.dirname(registry.registryPath), inputPath);
6000
8098
  }
6001
8099
  function resolveRegistryPath(explicitPath) {
6002
- if (explicitPath) return import_node_path21.default.resolve(explicitPath);
8100
+ if (explicitPath) return import_node_path24.default.resolve(explicitPath);
6003
8101
  const envPath = process.env.PROWL_PROJECTS ?? process.env.PROWLQA_PROJECTS;
6004
- if (envPath) return import_node_path21.default.resolve(envPath);
8102
+ if (envPath) return import_node_path24.default.resolve(envPath);
6005
8103
  const fallback = defaultRegistryPath();
6006
- if (import_node_fs18.default.existsSync(fallback)) return fallback;
8104
+ if (import_node_fs21.default.existsSync(fallback)) return fallback;
6007
8105
  const legacy = legacyRegistryPath();
6008
- return import_node_fs18.default.existsSync(legacy) ? legacy : null;
8106
+ return import_node_fs21.default.existsSync(legacy) ? legacy : null;
6009
8107
  }
6010
8108
  function loadProjectRegistry(explicitPath) {
6011
8109
  const registryPath = resolveRegistryPath(explicitPath);
6012
8110
  if (!registryPath) return null;
6013
- if (!import_node_fs18.default.existsSync(registryPath)) {
8111
+ if (!import_node_fs21.default.existsSync(registryPath)) {
6014
8112
  throw new Error(`Project registry not found at ${registryPath}`);
6015
8113
  }
6016
- const raw = import_node_fs18.default.readFileSync(registryPath, "utf-8");
8114
+ const raw = import_node_fs21.default.readFileSync(registryPath, "utf-8");
6017
8115
  const parsed = import_yaml3.default.parse(raw) ?? {};
6018
8116
  const validated = projectRegistrySchema.parse(parsed);
6019
8117
  return { projects: validated.projects, registryPath };