prowl-tools 0.1.4 → 0.1.6

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, assertWithAiStepSchema, 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({
@@ -294,6 +311,9 @@ var init_schema = __esm({
294
311
  threshold: import_zod.z.number().min(0).max(1).optional()
295
312
  }).strict()
296
313
  }).strict();
314
+ assertWithAiStepSchema = import_zod.z.object({
315
+ assertWithAI: import_zod.z.string().min(1)
316
+ }).strict();
297
317
  copyTextStepSchema = import_zod.z.object({
298
318
  copyText: import_zod.z.object({
299
319
  selector: import_zod.z.string().min(1),
@@ -336,6 +356,7 @@ var init_schema = __esm({
336
356
  evalScriptStepSchema,
337
357
  runScriptStepSchema,
338
358
  assertScreenshotStepSchema,
359
+ assertWithAiStepSchema,
339
360
  copyTextStepSchema,
340
361
  waitForDownloadStepSchema
341
362
  ]);
@@ -417,9 +438,28 @@ function resolveViewport(value) {
417
438
  return value;
418
439
  }
419
440
  function resolveTarget(target) {
420
- if (target && target.type === "macos") {
441
+ const type = target?.type;
442
+ if (type === "macos") {
421
443
  return { type: "macos", app: target.app };
422
444
  }
445
+ if (type === "android") {
446
+ const androidTarget = target;
447
+ return {
448
+ type: "android",
449
+ app: androidTarget.app,
450
+ ...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
451
+ ...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
452
+ };
453
+ }
454
+ if (type === "ios") {
455
+ const iosTarget = target;
456
+ return {
457
+ type: "ios",
458
+ app: iosTarget.app,
459
+ ...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
460
+ ...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
461
+ };
462
+ }
423
463
  return {
424
464
  type: "web",
425
465
  url: target?.url ?? DEFAULT_WEB_URL
@@ -628,12 +668,12 @@ __export(visual_exports, {
628
668
  ensureBaselineDir: () => ensureBaselineDir
629
669
  });
630
670
  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));
671
+ const baselineData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(baselinePath));
672
+ const currentData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(currentPath));
633
673
  const { width, height } = baselineData;
634
674
  if (currentData.width !== width || currentData.height !== height) {
635
675
  const diff2 = new import_pngjs.PNG({ width, height });
636
- import_node_fs5.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
676
+ import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
637
677
  return {
638
678
  match: false,
639
679
  diffPercentage: 1,
@@ -651,7 +691,7 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
651
691
  );
652
692
  const totalPixels = width * height;
653
693
  const diffPercentage = diffPixels / totalPixels;
654
- import_node_fs5.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
694
+ import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
655
695
  return {
656
696
  match: diffPercentage <= threshold,
657
697
  diffPercentage,
@@ -659,16 +699,16 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
659
699
  };
660
700
  }
661
701
  function ensureBaselineDir(configDir) {
662
- const baselineDir = import_node_path5.default.join(configDir, "baselines");
663
- import_node_fs5.default.mkdirSync(baselineDir, { recursive: true });
702
+ const baselineDir = import_node_path8.default.join(configDir, "baselines");
703
+ import_node_fs8.default.mkdirSync(baselineDir, { recursive: true });
664
704
  return baselineDir;
665
705
  }
666
- var import_node_fs5, import_node_path5, import_pngjs, import_pixelmatch;
706
+ var import_node_fs8, import_node_path8, import_pngjs, import_pixelmatch;
667
707
  var init_visual = __esm({
668
708
  "src/runner/visual.ts"() {
669
709
  "use strict";
670
- import_node_fs5 = __toESM(require("fs"), 1);
671
- import_node_path5 = __toESM(require("path"), 1);
710
+ import_node_fs8 = __toESM(require("fs"), 1);
711
+ import_node_path8 = __toESM(require("path"), 1);
672
712
  import_pngjs = require("pngjs");
673
713
  import_pixelmatch = __toESM(require("pixelmatch"), 1);
674
714
  }
@@ -680,7 +720,7 @@ var import_commander13 = require("commander");
680
720
  // package.json
681
721
  var package_default = {
682
722
  name: "prowl-tools",
683
- version: "0.1.4",
723
+ version: "0.1.6",
684
724
  description: "CLI-first QA testing tool for deterministic Playwright flows.",
685
725
  type: "module",
686
726
  license: "Apache-2.0",
@@ -750,6 +790,10 @@ var package_default = {
750
790
  yaml: "^2.6.1",
751
791
  zod: "^3.23.8"
752
792
  },
793
+ optionalDependencies: {
794
+ "appium-uiautomator2-server": "10.6.2",
795
+ "appium-webdriveragent": "16.4.0"
796
+ },
753
797
  devDependencies: {
754
798
  "@types/node": "^22.13.1",
755
799
  "@types/pngjs": "^6.0.5",
@@ -767,8 +811,8 @@ var import_commander = require("commander");
767
811
  var import_chalk3 = __toESM(require("chalk"), 1);
768
812
 
769
813
  // src/runner/index.ts
770
- var import_node_fs11 = __toESM(require("fs"), 1);
771
- var import_node_path11 = __toESM(require("path"), 1);
814
+ var import_node_fs14 = __toESM(require("fs"), 1);
815
+ var import_node_path14 = __toESM(require("path"), 1);
772
816
  init_loader();
773
817
 
774
818
  // src/config/interpolate.ts
@@ -1065,6 +1109,11 @@ function interpolateStep(step, vars, stepPath2, redacted) {
1065
1109
  }
1066
1110
  };
1067
1111
  }
1112
+ if ("assertWithAI" in step) {
1113
+ return {
1114
+ assertWithAI: interpolateString(step.assertWithAI, vars).value
1115
+ };
1116
+ }
1068
1117
  if ("copyText" in step) {
1069
1118
  return {
1070
1119
  copyText: {
@@ -1171,15 +1220,25 @@ function webOnlyReason(step) {
1171
1220
  }
1172
1221
  return null;
1173
1222
  }
1223
+ function nativeTargetLabel(target) {
1224
+ if (target === "android") {
1225
+ return "Android";
1226
+ }
1227
+ if (target === "ios") {
1228
+ return "iOS";
1229
+ }
1230
+ return "macOS";
1231
+ }
1174
1232
  function assertStepsSupportedByTarget(steps, target) {
1175
- if (target !== "macos") {
1233
+ if (target === "web") {
1176
1234
  return;
1177
1235
  }
1236
+ const label = nativeTargetLabel(target);
1178
1237
  for (const step of steps) {
1179
1238
  const reason = webOnlyReason(step);
1180
1239
  if (reason) {
1181
1240
  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.).`
1241
+ `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
1242
  );
1184
1243
  }
1185
1244
  if ("if" in step) {
@@ -1194,20 +1253,34 @@ function assertStepsSupportedByTarget(steps, target) {
1194
1253
  }
1195
1254
  }
1196
1255
  function assertHuntAssertionsSupportedByTarget(assertions, target) {
1197
- if (target !== "macos" || !assertions || assertions.length === 0) {
1256
+ if (target === "web" || !assertions || assertions.length === 0) {
1198
1257
  return;
1199
1258
  }
1200
1259
  throw new Error(
1201
- "Hunt-level assertions are not supported by the macOS target. Use inline assert visible/notVisible steps instead."
1260
+ `Hunt-level assertions are not supported by the ${nativeTargetLabel(target)} target. Use inline assert visible/notVisible steps instead.`
1202
1261
  );
1203
1262
  }
1204
1263
  function trimTrailingPathSeparators(value) {
1205
1264
  return value.replace(/[\\/]+$/g, "");
1206
1265
  }
1207
- function looksLikeMacosAppPath(app) {
1266
+ function looksLikeAppBundlePath(app) {
1208
1267
  const trimmed = trimTrailingPathSeparators(app);
1209
1268
  return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
1210
1269
  }
1270
+ function looksLikeIosAppPath(app) {
1271
+ const trimmed = trimTrailingPathSeparators(app);
1272
+ if (trimmed.includes("/") || trimmed.includes("\\")) {
1273
+ return true;
1274
+ }
1275
+ if (trimmed.toLowerCase().endsWith(".app")) {
1276
+ try {
1277
+ return import_node_fs2.default.statSync(normalizeAppPath(app)).isDirectory();
1278
+ } catch {
1279
+ return false;
1280
+ }
1281
+ }
1282
+ return false;
1283
+ }
1211
1284
  function normalizeAppPath(app) {
1212
1285
  return import_node_path2.default.resolve(trimTrailingPathSeparators(app));
1213
1286
  }
@@ -1215,8 +1288,8 @@ function parseBundleIdentifier(plist) {
1215
1288
  const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
1216
1289
  return match?.[1]?.trim() || null;
1217
1290
  }
1218
- function readBundleIdentifier(appPath) {
1219
- const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), "Contents", "Info.plist");
1291
+ function readBundleIdentifier(appPath, ...plistSubPath) {
1292
+ const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), ...plistSubPath);
1220
1293
  if (!import_node_fs2.default.existsSync(infoPlistPath)) {
1221
1294
  return null;
1222
1295
  }
@@ -1241,9 +1314,15 @@ function readBundleIdentifier(appPath) {
1241
1314
  return null;
1242
1315
  }
1243
1316
  }
1244
- function macosAppAllowedIdentities(app) {
1317
+ function readMacosBundleIdentifier(appPath) {
1318
+ return readBundleIdentifier(appPath, "Contents", "Info.plist");
1319
+ }
1320
+ function readIosBundleIdentifier(appPath) {
1321
+ return readBundleIdentifier(appPath, "Info.plist");
1322
+ }
1323
+ function appBundleAllowedIdentities(app, readBundleId, isAppPath = looksLikeAppBundlePath) {
1245
1324
  const identities = /* @__PURE__ */ new Set([app]);
1246
- if (looksLikeMacosAppPath(app)) {
1325
+ if (isAppPath(app)) {
1247
1326
  const normalizedPath = normalizeAppPath(app);
1248
1327
  identities.add(trimTrailingPathSeparators(app));
1249
1328
  identities.add(normalizedPath);
@@ -1251,19 +1330,56 @@ function macosAppAllowedIdentities(app) {
1251
1330
  if (bundleName) {
1252
1331
  identities.add(bundleName);
1253
1332
  }
1254
- const bundleId = readBundleIdentifier(app);
1333
+ const bundleId = readBundleId(app);
1255
1334
  if (bundleId) {
1256
1335
  identities.add(bundleId);
1257
1336
  }
1258
1337
  }
1259
1338
  return [...identities];
1260
1339
  }
1340
+ function macosAppAllowedIdentities(app) {
1341
+ return appBundleAllowedIdentities(app, readMacosBundleIdentifier);
1342
+ }
1343
+ function iosAppAllowedIdentities(app) {
1344
+ return appBundleAllowedIdentities(app, readIosBundleIdentifier, looksLikeIosAppPath);
1345
+ }
1261
1346
  function assertTargetAppAllowed(allowedApps, app) {
1347
+ assertNativeAppAllowed(allowedApps, app, macosAppAllowedIdentities);
1348
+ }
1349
+ function assertIosAppAllowed(allowedApps, app) {
1350
+ assertNativeAppAllowed(allowedApps, app, iosAppAllowedIdentities);
1351
+ }
1352
+ function looksLikeApkPath(app) {
1353
+ const trimmed = trimTrailingPathSeparators(app);
1354
+ return trimmed.includes("/") || trimmed.includes("\\") || trimmed.toLowerCase().endsWith(".apk");
1355
+ }
1356
+ function normalizeAndroidApkPath(app) {
1357
+ const resolved = import_node_path2.default.resolve(trimTrailingPathSeparators(app));
1358
+ try {
1359
+ return import_node_fs2.default.realpathSync.native(resolved);
1360
+ } catch {
1361
+ return resolved;
1362
+ }
1363
+ }
1364
+ function androidAppAllowedIdentities(app, resolvedPackage) {
1365
+ if (looksLikeApkPath(app)) {
1366
+ return resolvedPackage ? [normalizeAndroidApkPath(app), resolvedPackage] : [normalizeAndroidApkPath(app)];
1367
+ }
1368
+ return [app];
1369
+ }
1370
+ function assertAndroidAppAllowed(allowedApps, app, resolvedPackage) {
1371
+ assertNativeAppAllowed(
1372
+ allowedApps,
1373
+ app,
1374
+ (value) => androidAppAllowedIdentities(value, value === app ? resolvedPackage : void 0)
1375
+ );
1376
+ }
1377
+ function assertNativeAppAllowed(allowedApps, app, resolveIdentities) {
1262
1378
  if (allowedApps.length === 0) {
1263
1379
  return;
1264
1380
  }
1265
- const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => macosAppAllowedIdentities(allowedApp)));
1266
- if (macosAppAllowedIdentities(app).some((identity) => allowedIdentities.has(identity))) {
1381
+ const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => resolveIdentities(allowedApp)));
1382
+ if (resolveIdentities(app).some((identity) => allowedIdentities.has(identity))) {
1267
1383
  return;
1268
1384
  }
1269
1385
  throw new Error(
@@ -1641,7 +1757,11 @@ function createMacDriver(client, options = {}) {
1641
1757
  return rejectUnsupported("evalScript");
1642
1758
  },
1643
1759
  async screenshot(screenshotOptions) {
1644
- await client.request("screenshot", { path: screenshotOptions.path });
1760
+ const result = await client.request("screenshot", { path: screenshotOptions.path });
1761
+ const warning = result.warning;
1762
+ if (typeof warning === "string" && warning.length > 0) {
1763
+ console.warn(`macOS screenshot fell back to full screen: ${warning}`);
1764
+ }
1645
1765
  },
1646
1766
  // network / dialogs / downloads (all web-only) -------------------------
1647
1767
  onResponse(_handler) {
@@ -1868,154 +1988,2177 @@ async function closeMacSession(session) {
1868
1988
  }
1869
1989
  }
1870
1990
 
1871
- // src/runner/steps.ts
1991
+ // src/browser/android-helper.ts
1992
+ var import_node_module = require("module");
1993
+ var import_node_child_process4 = require("child_process");
1872
1994
  var import_node_fs6 = __toESM(require("fs"), 1);
1873
- var import_node_path6 = __toESM(require("path"), 1);
1874
- init_loader();
1995
+ var import_node_path5 = __toESM(require("path"), 1);
1875
1996
 
1876
- // src/runner/healing.ts
1877
- var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
1878
- function extractSelectorIntent(selector) {
1879
- const raw = [];
1880
- for (const match of selector.matchAll(/[#.]([A-Za-z_][\w-]*)/g)) {
1881
- raw.push(match[1]);
1882
- }
1883
- for (const match of selector.matchAll(/\[[A-Za-z_:-]+\s*[~|^$*]?=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\]/g)) {
1884
- const value = match[1] ?? match[2] ?? match[3];
1885
- if (value) raw.push(value);
1997
+ // src/browser/android-driver.ts
1998
+ var import_node_fs5 = __toESM(require("fs"), 1);
1999
+
2000
+ // src/analyzer/xml.ts
2001
+ var ENTITIES = {
2002
+ amp: "&",
2003
+ lt: "<",
2004
+ gt: ">",
2005
+ quot: '"',
2006
+ apos: "'"
2007
+ };
2008
+ function decodeXmlEntities(value) {
2009
+ if (!value.includes("&")) {
2010
+ return value;
1886
2011
  }
1887
- const words = [];
1888
- for (const token of raw) {
1889
- for (const part of splitToken(token)) {
1890
- const lower = part.toLowerCase();
1891
- if (lower.length > 0 && !words.includes(lower)) {
1892
- words.push(lower);
1893
- }
2012
+ return value.replace(/&(#(?:[xX][0-9a-fA-F]+|[0-9]+)|[a-zA-Z]+);/g, (match, code) => {
2013
+ if (code[0] === "#") {
2014
+ const hex = code[1] === "x" || code[1] === "X";
2015
+ const num2 = Number.parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10);
2016
+ return Number.isSafeInteger(num2) && num2 <= 1114111 ? String.fromCodePoint(num2) : match;
1894
2017
  }
1895
- }
1896
- return { words, label: words.join(" ") };
1897
- }
1898
- function splitToken(token) {
1899
- return token.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[\s\-_.:]+/).filter((part) => part.length > 0);
1900
- }
1901
- function buildHealCandidates(selector) {
1902
- const { words, label } = extractSelectorIntent(selector);
1903
- if (words.length === 0) return [];
1904
- const escaped = label.replace(/"/g, '\\"');
1905
- const candidates = [];
1906
- candidates.push({ selector: `text=${label}`, strategy: "text" });
1907
- candidates.push({ selector: `[aria-label*="${escaped}" i]`, strategy: "aria" });
1908
- for (const tag of INTERACTIVE_TAGS) {
1909
- candidates.push({ selector: `${tag}:has-text("${escaped}")`, strategy: "structural" });
1910
- }
1911
- return candidates;
2018
+ const named = ENTITIES[code];
2019
+ return named ?? match;
2020
+ });
1912
2021
  }
1913
- async function healSelector(probe, selector, options) {
1914
- if (!options.enabled) return null;
1915
- for (const candidate of buildHealCandidates(selector)) {
1916
- let count;
1917
- try {
1918
- const locator = probe.locator(candidate.selector);
1919
- count = await locator.count();
1920
- } catch {
2022
+ var ATTR_RE = /([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
2023
+ function parseTagBody(body) {
2024
+ const trimmed = body.trim();
2025
+ const nameMatch = /^([^\s/>]+)/.exec(trimmed);
2026
+ const tag = nameMatch ? nameMatch[1] : "";
2027
+ const attrs = {};
2028
+ const rest = trimmed.slice(tag.length);
2029
+ ATTR_RE.lastIndex = 0;
2030
+ let m;
2031
+ while ((m = ATTR_RE.exec(rest)) !== null) {
2032
+ const rawValue = m[3] !== void 0 ? m[3] : m[4] ?? "";
2033
+ attrs[m[1]] = decodeXmlEntities(rawValue);
2034
+ }
2035
+ return { tag, attrs };
2036
+ }
2037
+ function parseXml(input) {
2038
+ const n = input.length;
2039
+ const stack = [];
2040
+ let root = null;
2041
+ let i = 0;
2042
+ while (i < n) {
2043
+ const lt = input.indexOf("<", i);
2044
+ if (lt < 0) {
2045
+ break;
2046
+ }
2047
+ i = lt + 1;
2048
+ const ch = input[i];
2049
+ if (ch === "?") {
2050
+ const end = input.indexOf("?>", i);
2051
+ i = end < 0 ? n : end + 2;
1921
2052
  continue;
1922
2053
  }
1923
- if (count === 1) {
1924
- return { selector: candidate.selector, healedFrom: selector, strategy: candidate.strategy };
2054
+ if (ch === "!") {
2055
+ if (input.startsWith("!--", i)) {
2056
+ const end = input.indexOf("-->", i);
2057
+ i = end < 0 ? n : end + 3;
2058
+ } else {
2059
+ const end = input.indexOf(">", i);
2060
+ i = end < 0 ? n : end + 1;
2061
+ }
2062
+ continue;
2063
+ }
2064
+ if (ch === "/") {
2065
+ const gt = input.indexOf(">", i);
2066
+ i = gt < 0 ? n : gt + 1;
2067
+ stack.pop();
2068
+ continue;
2069
+ }
2070
+ let j = i;
2071
+ let quote2 = null;
2072
+ while (j < n) {
2073
+ const c = input[j];
2074
+ if (quote2 !== null) {
2075
+ if (c === quote2) {
2076
+ quote2 = null;
2077
+ }
2078
+ } else if (c === '"' || c === "'") {
2079
+ quote2 = c;
2080
+ } else if (c === ">") {
2081
+ break;
2082
+ }
2083
+ j += 1;
2084
+ }
2085
+ const inner = input.slice(i, j);
2086
+ i = j + 1;
2087
+ const selfClose = inner.endsWith("/");
2088
+ const body = selfClose ? inner.slice(0, -1) : inner;
2089
+ const { tag, attrs } = parseTagBody(body);
2090
+ if (tag.length === 0) {
2091
+ continue;
2092
+ }
2093
+ const element = { tag, attrs, children: [] };
2094
+ const parent = stack[stack.length - 1];
2095
+ if (parent) {
2096
+ parent.children.push(element);
2097
+ }
2098
+ if (root === null) {
2099
+ root = element;
2100
+ }
2101
+ if (!selfClose) {
2102
+ stack.push(element);
1925
2103
  }
1926
2104
  }
1927
- return null;
2105
+ return root;
1928
2106
  }
1929
2107
 
1930
- // src/runner/policy.ts
1931
- var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
1932
- function createRunPolicy(driver, options) {
1933
- const { forbiddenSelectors, allowedDomains, maxSteps, selfHealing } = options;
1934
- const allowedApps = options.allowedApps ?? [];
1935
- function matchesForbiddenPattern(selector, forbidden) {
1936
- const selectorText = driver.parseTextSelector(selector);
1937
- if (selectorText === null) {
1938
- return false;
1939
- }
1940
- const forbiddenText = driver.parseTextSelector(forbidden);
1941
- if (forbiddenText !== null) {
1942
- return selectorText.includes(forbiddenText);
1943
- }
1944
- return selectorText.includes(forbidden);
2108
+ // src/selector/native.ts
2109
+ function unquoteSelectorValue(value) {
2110
+ const trimmed = value.trim();
2111
+ const first = trimmed[0];
2112
+ if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
2113
+ return trimmed.slice(1, -1);
1945
2114
  }
1946
- function isForbiddenSelector(selector) {
1947
- return forbiddenSelectors.some(
1948
- (forbidden) => selector.includes(forbidden) || matchesForbiddenPattern(selector, forbidden)
2115
+ return trimmed;
2116
+ }
2117
+ function quoteSelectorValue(value) {
2118
+ return `"${value}"`;
2119
+ }
2120
+ var ROLE_SELECTOR_RE = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s;
2121
+ var NATIVE_SELECTOR_PREFIX_RE = /^(id|label|text|role)=/;
2122
+ function invalidSelectorMessage(selector, reason) {
2123
+ return `Invalid native selector ${JSON.stringify(selector)}: ${reason}.`;
2124
+ }
2125
+ function parseNativeSelector(selector) {
2126
+ const trimmed = selector.trim();
2127
+ if (trimmed.length === 0) {
2128
+ throw new Error(
2129
+ invalidSelectorMessage(
2130
+ selector,
2131
+ "selector is empty; use id=, label=, text=, role=, :focus, or a bare text value"
2132
+ )
1949
2133
  );
1950
2134
  }
1951
- function assertAllowedSelector(selector) {
1952
- if (isForbiddenSelector(selector)) {
1953
- throw new Error(`Forbidden selector: ${selector}`);
1954
- }
2135
+ if (trimmed === ":focus") {
2136
+ return { kind: "focused" };
1955
2137
  }
1956
- function assertWithinMaxSteps(stepCount, huntName) {
1957
- if (stepCount > maxSteps) {
1958
- if (huntName) {
1959
- throw new Error(`Hunt "${huntName}" has ${stepCount} steps. Max allowed is ${maxSteps}.`);
1960
- }
1961
- throw new Error(`Hunt has ${stepCount} steps. Max allowed is ${maxSteps}.`);
1962
- }
2138
+ const idMatch = /^id=(.+)$/s.exec(trimmed);
2139
+ if (idMatch) {
2140
+ return { kind: "id", value: unquoteSelectorValue(idMatch[1]) };
1963
2141
  }
1964
- function ensureUrlAllowed(urlValue) {
1965
- for (const protocol of ALWAYS_ALLOWED_PROTOCOLS) {
1966
- if (urlValue.startsWith(protocol)) {
1967
- return;
2142
+ const roleMatch = ROLE_SELECTOR_RE.exec(trimmed);
2143
+ if (roleMatch) {
2144
+ const name = roleMatch[2] !== void 0 ? unquoteSelectorValue(roleMatch[2]) : void 0;
2145
+ return name !== void 0 && name.length > 0 ? { kind: "role", role: roleMatch[1], name } : { kind: "role", role: roleMatch[1] };
2146
+ }
2147
+ const labelMatch = /^label=(.+)$/s.exec(trimmed);
2148
+ if (labelMatch) {
2149
+ return { kind: "label", value: unquoteSelectorValue(labelMatch[1]) };
2150
+ }
2151
+ const textMatch = /^text=(.+)$/s.exec(trimmed);
2152
+ if (textMatch) {
2153
+ return { kind: "text", value: unquoteSelectorValue(textMatch[1]) };
2154
+ }
2155
+ const prefix = NATIVE_SELECTOR_PREFIX_RE.exec(trimmed);
2156
+ if (prefix) {
2157
+ throw new Error(
2158
+ invalidSelectorMessage(
2159
+ selector,
2160
+ `malformed ${prefix[1]}= selector; expected id=<value>, label=<value>, text=<value>, or role=<Type>[name=<value>]`
2161
+ )
2162
+ );
2163
+ }
2164
+ return { kind: "text", value: trimmed };
2165
+ }
2166
+ function unwrapNativeTextSelector(selector) {
2167
+ const trimmed = selector.trim();
2168
+ if (!trimmed.startsWith("text=")) {
2169
+ return null;
2170
+ }
2171
+ return unquoteSelectorValue(trimmed.slice("text=".length));
2172
+ }
2173
+ function qualifyResourceId(value, appPackage) {
2174
+ if (value.includes(":") || !appPackage) {
2175
+ return value;
2176
+ }
2177
+ return `${appPackage}:id/${value}`;
2178
+ }
2179
+ function normalizeXcuiClassName(role) {
2180
+ return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
2181
+ }
2182
+ function shortIosType(type) {
2183
+ return type.startsWith("XCUIElementType") ? type.slice("XCUIElementType".length) : type;
2184
+ }
2185
+ function rankNativeSelectors(fields) {
2186
+ const selectors = [];
2187
+ if (fields.id) {
2188
+ selectors.push(`id=${fields.id}`);
2189
+ }
2190
+ if (fields.label) {
2191
+ selectors.push(`label=${quoteSelectorValue(fields.label)}`);
2192
+ }
2193
+ if (fields.role && fields.name) {
2194
+ selectors.push(`role=${fields.role}[name=${quoteSelectorValue(fields.name)}]`);
2195
+ }
2196
+ if (fields.name) {
2197
+ selectors.push(`text=${quoteSelectorValue(fields.name)}`);
2198
+ }
2199
+ if (selectors.length === 0 && fields.role) {
2200
+ selectors.push(`role=${fields.role}`);
2201
+ }
2202
+ return selectors;
2203
+ }
2204
+
2205
+ // src/browser/android-driver.ts
2206
+ var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
2207
+ "query",
2208
+ "interact",
2209
+ "wait",
2210
+ "screenshot"
2211
+ ]);
2212
+ var WAIT_POLL_INTERVAL_MS = 250;
2213
+ var DEFAULT_WAIT_TIMEOUT_MS = 5e3;
2214
+ var ANDROID_KEYCODES = {
2215
+ enter: 66,
2216
+ return: 66,
2217
+ tab: 61,
2218
+ space: 62,
2219
+ backspace: 67,
2220
+ delete: 67,
2221
+ del: 67,
2222
+ escape: 111,
2223
+ esc: 111,
2224
+ back: 4,
2225
+ home: 3,
2226
+ menu: 82,
2227
+ search: 84,
2228
+ up: 19,
2229
+ arrowup: 19,
2230
+ down: 20,
2231
+ arrowdown: 20,
2232
+ left: 21,
2233
+ arrowleft: 21,
2234
+ right: 22,
2235
+ arrowright: 22,
2236
+ pageup: 92,
2237
+ pagedown: 93
2238
+ };
2239
+ function escapeUiSelectorArg(value) {
2240
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2241
+ }
2242
+ function toAndroidQuery(selector) {
2243
+ switch (selector.kind) {
2244
+ case "focused":
2245
+ return { by: "focused" };
2246
+ case "id":
2247
+ return { by: "id", value: selector.value };
2248
+ case "role":
2249
+ return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
2250
+ case "label":
2251
+ return { by: "accessibilityId", value: selector.value };
2252
+ case "text":
2253
+ return { by: "text", value: selector.value };
2254
+ }
2255
+ }
2256
+ function parseAndroidSelector(selector) {
2257
+ return toAndroidQuery(parseNativeSelector(selector));
2258
+ }
2259
+ function locator(strategy, selector) {
2260
+ return { strategy, selector, context: "" };
2261
+ }
2262
+ function androidQueryToLocator(query, options = {}) {
2263
+ switch (query.by) {
2264
+ case "id":
2265
+ return locator("id", qualifyResourceId(query.value, options.appPackage));
2266
+ case "accessibilityId":
2267
+ return locator("accessibility id", query.value);
2268
+ case "text":
2269
+ return locator(
2270
+ "-android uiautomator",
2271
+ `new UiSelector().textContains("${escapeUiSelectorArg(query.value)}")`
2272
+ );
2273
+ case "focused":
2274
+ return locator("-android uiautomator", "new UiSelector().focused(true)");
2275
+ case "role": {
2276
+ const className = escapeUiSelectorArg(query.role);
2277
+ if (query.name === void 0 || query.name.length === 0) {
2278
+ return locator("class name", query.role);
1968
2279
  }
1969
- }
1970
- let url;
1971
- try {
1972
- url = new URL(urlValue);
1973
- } catch {
1974
- throw new Error(`Navigation target is not a valid absolute URL: ${urlValue}`);
1975
- }
1976
- if (!allowedDomains.includes(url.hostname)) {
1977
- throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
2280
+ return locator(
2281
+ "-android uiautomator",
2282
+ `new UiSelector().className("${className}").textContains("${escapeUiSelectorArg(query.name)}")`
2283
+ );
1978
2284
  }
1979
2285
  }
1980
- function ensureAppAllowed(app) {
1981
- if (!allowedApps.includes(app)) {
1982
- throw new Error(`Interaction with disallowed app: ${app}`);
1983
- }
2286
+ }
2287
+ function unwrapAndroidTextSelector(selector) {
2288
+ return unwrapNativeTextSelector(selector);
2289
+ }
2290
+ function keyCodeFor(key) {
2291
+ const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
2292
+ if (code === void 0) {
2293
+ throw new Error(
2294
+ `Unsupported key "${key}" for the Android target. Supported keys: ${Object.keys(ANDROID_KEYCODES).sort().join(", ")}.`
2295
+ );
1984
2296
  }
1985
- function ensureLocationAllowed(activeDriver) {
1986
- if (activeDriver.capabilities.has("navigate")) {
1987
- ensureUrlAllowed(activeDriver.currentUrl());
2297
+ return code;
2298
+ }
2299
+ function delay(ms) {
2300
+ return new Promise((resolve) => {
2301
+ setTimeout(resolve, ms);
2302
+ });
2303
+ }
2304
+ function createAndroidDriver(client, options = {}) {
2305
+ const unsupported = (verb) => new Error(`${verb} is not supported by the Android target`);
2306
+ const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
2307
+ async function resolveOne(selector) {
2308
+ const id = await client.findElement(parseAndroidSelector(selector));
2309
+ if (id === null) {
2310
+ throw new Error(`No element matched selector: ${selector}`);
1988
2311
  }
2312
+ return id;
1989
2313
  }
1990
- const healProbe = {
1991
- locator: (selector) => ({ count: () => driver.count(selector) })
1992
- };
1993
- async function resolveActionSelector(selector) {
1994
- assertAllowedSelector(selector);
1995
- if (!selfHealing) {
1996
- return { selector };
1997
- }
1998
- let matched = false;
1999
- try {
2000
- matched = await driver.count(selector) > 0;
2001
- } catch {
2002
- return { selector };
2003
- }
2004
- if (matched) {
2005
- return { selector };
2006
- }
2007
- const healed = await healSelector(healProbe, selector, { enabled: true });
2008
- if (!healed) {
2009
- return { selector };
2010
- }
2011
- assertAllowedSelector(healed.selector);
2012
- console.warn(
2013
- `Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
2014
- );
2015
- return { selector: healed.selector, healedFrom: healed.healedFrom };
2314
+ async function clickSelector(selector) {
2315
+ await client.click(await resolveOne(selector));
2316
+ }
2317
+ async function fillSelector(selector, value) {
2318
+ await client.setValue(await resolveOne(selector), value);
2016
2319
  }
2017
2320
  return {
2018
- assertWithinMaxSteps,
2321
+ capabilities: ANDROID_CAPABILITIES,
2322
+ // navigation -----------------------------------------------------------
2323
+ goto(_url, _options) {
2324
+ return rejectUnsupported("navigate");
2325
+ },
2326
+ currentUrl() {
2327
+ return `android:${options.appLabel ?? ""}`;
2328
+ },
2329
+ // queries --------------------------------------------------------------
2330
+ async count(selector) {
2331
+ return (await client.findElements(parseAndroidSelector(selector))).length;
2332
+ },
2333
+ async textContent(selector) {
2334
+ const id = await client.findElement(parseAndroidSelector(selector));
2335
+ if (id === null) {
2336
+ return null;
2337
+ }
2338
+ return client.getText(id);
2339
+ },
2340
+ // interactions ---------------------------------------------------------
2341
+ click: clickSelector,
2342
+ clickFirst: clickSelector,
2343
+ fill: fillSelector,
2344
+ fillFirst: fillSelector,
2345
+ async press(_selector, key) {
2346
+ await client.pressKeyCode(keyCodeFor(key));
2347
+ },
2348
+ selectOption() {
2349
+ return rejectUnsupported("select");
2350
+ },
2351
+ selectOptionFirst() {
2352
+ return rejectUnsupported("select");
2353
+ },
2354
+ hover() {
2355
+ return rejectUnsupported("hover");
2356
+ },
2357
+ scrollIntoView() {
2358
+ return rejectUnsupported("scrollTo");
2359
+ },
2360
+ setInputFiles() {
2361
+ return rejectUnsupported("setInputFiles");
2362
+ },
2363
+ // semantic locators ----------------------------------------------------
2364
+ async countByRole(role, name) {
2365
+ return (await client.findElements({ by: "role", role, name })).length;
2366
+ },
2367
+ async clickFirstByRole(role, name) {
2368
+ const id = await client.findElement({ by: "role", role, name });
2369
+ if (id === null) {
2370
+ throw new Error(`No element matched role=${role}[name="${name}"]`);
2371
+ }
2372
+ await client.click(id);
2373
+ },
2374
+ async countByLabel(label) {
2375
+ return (await client.findElements({ by: "accessibilityId", value: label })).length;
2376
+ },
2377
+ async fillFirstByLabel(label, value) {
2378
+ const id = await client.findElement({ by: "accessibilityId", value: label });
2379
+ if (id === null) {
2380
+ throw new Error(`No element matched label="${label}"`);
2381
+ }
2382
+ await client.setValue(id, value);
2383
+ },
2384
+ selectOptionFirstByLabel() {
2385
+ return rejectUnsupported("select");
2386
+ },
2387
+ // waiting --------------------------------------------------------------
2388
+ async waitForSelector(selector, waitOptions) {
2389
+ const query = parseAndroidSelector(selector);
2390
+ const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS;
2391
+ const deadline = Date.now() + timeoutMs;
2392
+ for (; ; ) {
2393
+ if ((await client.findElements(query)).length > 0) {
2394
+ return;
2395
+ }
2396
+ if (Date.now() >= deadline) {
2397
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
2398
+ }
2399
+ await delay(Math.min(WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
2400
+ }
2401
+ },
2402
+ waitForUrl() {
2403
+ return rejectUnsupported("waitForUrl");
2404
+ },
2405
+ waitForNetworkIdle() {
2406
+ return rejectUnsupported("waitForNetworkIdle");
2407
+ },
2408
+ // scripting & artifacts ------------------------------------------------
2409
+ evaluate() {
2410
+ return rejectUnsupported("evalScript");
2411
+ },
2412
+ async screenshot(screenshotOptions) {
2413
+ const png = await client.screenshotPng();
2414
+ import_node_fs5.default.writeFileSync(screenshotOptions.path, png);
2415
+ },
2416
+ // network / dialogs / downloads (all web-only) -------------------------
2417
+ onResponse(_handler) {
2418
+ throw unsupported("onResponse");
2419
+ },
2420
+ route(_url, _handler) {
2421
+ return rejectUnsupported("mockRoute");
2422
+ },
2423
+ unroute() {
2424
+ return rejectUnsupported("unmockRoute");
2425
+ },
2426
+ onDialog(_action) {
2427
+ throw unsupported("onDialog");
2428
+ },
2429
+ waitForDownloadEvent() {
2430
+ return rejectUnsupported("waitForDownload");
2431
+ },
2432
+ parseTextSelector(selector) {
2433
+ return unwrapAndroidTextSelector(selector);
2434
+ }
2435
+ };
2436
+ }
2437
+
2438
+ // src/browser/android-adb.ts
2439
+ var import_node_child_process3 = require("child_process");
2440
+ var execFileAdbRunner = (args, options) => new Promise((resolve) => {
2441
+ (0, import_node_child_process3.execFile)(
2442
+ "adb",
2443
+ args,
2444
+ { encoding: "utf-8", timeout: options?.timeoutMs, maxBuffer: 16 * 1024 * 1024 },
2445
+ (error, stdout, stderr) => {
2446
+ const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
2447
+ const capturedStderr = stderr ?? "";
2448
+ resolve({
2449
+ stdout: stdout ?? "",
2450
+ stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
2451
+ code
2452
+ });
2453
+ }
2454
+ );
2455
+ });
2456
+ var spawnAdbProcess = (args) => {
2457
+ const child = (0, import_node_child_process3.spawn)("adb", args, { stdio: "ignore" });
2458
+ child.on("error", () => {
2459
+ });
2460
+ return { kill: () => child.kill() };
2461
+ };
2462
+ function withSerial(serial, args) {
2463
+ return serial ? ["-s", serial, ...args] : args;
2464
+ }
2465
+ function parseAdbDevices(stdout) {
2466
+ const devices = [];
2467
+ for (const rawLine of stdout.split(/\r?\n/)) {
2468
+ const line = rawLine.trim();
2469
+ if (!line || /^list of devices attached/i.test(line)) {
2470
+ continue;
2471
+ }
2472
+ const [serial, state, ...rest] = line.split(/\s+/);
2473
+ if (!serial || !state) {
2474
+ continue;
2475
+ }
2476
+ const description = {};
2477
+ for (const token of rest) {
2478
+ const eq = token.indexOf(":");
2479
+ if (eq > 0) {
2480
+ description[token.slice(0, eq)] = token.slice(eq + 1);
2481
+ }
2482
+ }
2483
+ devices.push({ serial, state, description });
2484
+ }
2485
+ return devices;
2486
+ }
2487
+ function bootedDevices(devices) {
2488
+ return devices.filter((device) => device.state === "device");
2489
+ }
2490
+ function selectDeviceSerial(devices, requested) {
2491
+ const booted = bootedDevices(devices);
2492
+ if (requested) {
2493
+ const match = devices.find((device) => device.serial === requested);
2494
+ if (!match) {
2495
+ const attached = devices.length > 0 ? devices.map((d) => d.serial).join(", ") : "none";
2496
+ throw new Error(
2497
+ `Android device "${requested}" is not attached. Attached devices: ${attached}. Check \`adb devices -l\`.`
2498
+ );
2499
+ }
2500
+ if (match.state !== "device") {
2501
+ throw new Error(
2502
+ `Android device "${requested}" is present but not ready (state: ${match.state}). Boot or authorize it, then retry.`
2503
+ );
2504
+ }
2505
+ return requested;
2506
+ }
2507
+ if (booted.length === 0) {
2508
+ throw new Error(
2509
+ "No booted Android device found. Start an emulator or connect a device with USB debugging, then confirm it appears in `adb devices -l`."
2510
+ );
2511
+ }
2512
+ if (booted.length > 1) {
2513
+ throw new Error(
2514
+ `Multiple Android devices attached (${booted.map((d) => d.serial).join(", ")}). Set target.deviceSerial to pick one.`
2515
+ );
2516
+ }
2517
+ return booted[0].serial;
2518
+ }
2519
+ async function listDevices(runner) {
2520
+ const result = await runner(["devices", "-l"], { timeoutMs: 1e4 });
2521
+ if (result.code !== 0) {
2522
+ throw new Error(
2523
+ `\`adb devices\` failed (exit ${result.code}). Is adb on PATH and the server running? ` + (result.stderr.trim() || "").slice(0, 400)
2524
+ );
2525
+ }
2526
+ return parseAdbDevices(result.stdout);
2527
+ }
2528
+ function parseForwardPort(stdout) {
2529
+ const port = Number.parseInt(stdout.trim(), 10);
2530
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
2531
+ throw new Error(`Could not parse a forwarded port from adb output: "${stdout.trim()}"`);
2532
+ }
2533
+ return port;
2534
+ }
2535
+ function parseAaptPackage(stdout) {
2536
+ const match = /package:\s*name='([^']+)'/.exec(stdout);
2537
+ return match?.[1] ?? null;
2538
+ }
2539
+ async function forwardDynamicPort(runner, serial, remotePort) {
2540
+ const result = await runner(withSerial(serial, ["forward", "tcp:0", `tcp:${remotePort}`]), {
2541
+ timeoutMs: 1e4
2542
+ });
2543
+ if (result.code !== 0) {
2544
+ throw new Error(`adb forward failed (exit ${result.code}): ${result.stderr.trim()}`);
2545
+ }
2546
+ return parseForwardPort(result.stdout);
2547
+ }
2548
+ async function removeForward(runner, serial, localPort) {
2549
+ await runner(withSerial(serial, ["forward", "--remove", `tcp:${localPort}`]), { timeoutMs: 1e4 }).catch(
2550
+ () => void 0
2551
+ );
2552
+ }
2553
+ async function installApk(runner, serial, apkPath) {
2554
+ const result = await runner(withSerial(serial, ["install", "-r", "-t", "-g", apkPath]), {
2555
+ timeoutMs: 12e4
2556
+ });
2557
+ if (result.code !== 0 || /failure/i.test(result.stdout)) {
2558
+ throw new Error(
2559
+ `Failed to install APK "${apkPath}" (exit ${result.code}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
2560
+ );
2561
+ }
2562
+ }
2563
+ function formatUnknownError(error) {
2564
+ return error instanceof Error ? error.message : String(error);
2565
+ }
2566
+ function runnerPhaseError(message, error) {
2567
+ return new Error(`${message}: ${formatUnknownError(error)}`, { cause: error });
2568
+ }
2569
+ function parseResolvedComponent(stdout, pkg) {
2570
+ const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
2571
+ for (let i = lines.length - 1; i >= 0; i--) {
2572
+ if (/^[\w.]+\/[\w.$]+$/.test(lines[i]) && lines[i].startsWith(`${pkg}/`)) {
2573
+ return lines[i];
2574
+ }
2575
+ }
2576
+ return null;
2577
+ }
2578
+ async function launchPackage(runner, serial, pkg) {
2579
+ let resolved;
2580
+ try {
2581
+ resolved = await runner(
2582
+ withSerial(serial, [
2583
+ "shell",
2584
+ "cmd",
2585
+ "package",
2586
+ "resolve-activity",
2587
+ "--brief",
2588
+ "-c",
2589
+ "android.intent.category.LAUNCHER",
2590
+ pkg
2591
+ ]),
2592
+ { timeoutMs: 3e4 }
2593
+ );
2594
+ } catch (error) {
2595
+ throw runnerPhaseError(
2596
+ `Failed to resolve the launcher activity for Android package "${pkg}" via adb`,
2597
+ error
2598
+ );
2599
+ }
2600
+ const component = parseResolvedComponent(resolved.stdout, pkg);
2601
+ if (resolved.code !== 0 || !component) {
2602
+ throw new Error(
2603
+ `Could not resolve a launcher activity for Android package "${pkg}" (exit ${resolved.code})${(resolved.stdout + resolved.stderr).trim() ? `: ${(resolved.stdout + resolved.stderr).trim().slice(0, 300)}` : ""}. Is it installed?`
2604
+ );
2605
+ }
2606
+ let start;
2607
+ try {
2608
+ start = await runner(withSerial(serial, ["shell", "am", "start", "-n", component]), {
2609
+ timeoutMs: 3e4
2610
+ });
2611
+ } catch (error) {
2612
+ throw runnerPhaseError(
2613
+ `Failed to launch Android package "${pkg}" with \`am start\` (${component}) via adb`,
2614
+ error
2615
+ );
2616
+ }
2617
+ if (start.code !== 0 || /error|does not exist|cannot start/i.test(start.stdout + start.stderr)) {
2618
+ throw new Error(
2619
+ `Failed to launch Android package "${pkg}" (${component}, exit ${start.code}): ${(start.stdout + start.stderr).trim().slice(0, 400)}.`
2620
+ );
2621
+ }
2622
+ }
2623
+ async function forceStop(runner, serial, pkg) {
2624
+ await runner(withSerial(serial, ["shell", "am", "force-stop", pkg]), { timeoutMs: 3e4 });
2625
+ }
2626
+ async function clearPackage(runner, serial, pkg) {
2627
+ const result = await runner(withSerial(serial, ["shell", "pm", "clear", pkg]), { timeoutMs: 3e4 });
2628
+ if (result.code !== 0 || !/success/i.test(result.stdout)) {
2629
+ throw new Error(
2630
+ `Failed to clear Android package "${pkg}" for cold start (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 300)}`
2631
+ );
2632
+ }
2633
+ }
2634
+ function startInstrumentation(spawner, serial) {
2635
+ return spawner(
2636
+ withSerial(serial, [
2637
+ "shell",
2638
+ "am",
2639
+ "instrument",
2640
+ "-w",
2641
+ "-e",
2642
+ "disableAnalytics",
2643
+ "true",
2644
+ "io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner"
2645
+ ])
2646
+ );
2647
+ }
2648
+
2649
+ // src/browser/android-agent.ts
2650
+ var DEFAULT_AGENT_REQUEST_TIMEOUT_MS = 3e4;
2651
+ var W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
2652
+ var Uia2HttpError = class extends Error {
2653
+ constructor(message, status, webdriverError) {
2654
+ super(message);
2655
+ this.status = status;
2656
+ this.webdriverError = webdriverError;
2657
+ this.name = "Uia2HttpError";
2658
+ }
2659
+ };
2660
+ var Uia2Transport = class {
2661
+ baseUrl;
2662
+ requestTimeoutMs;
2663
+ fetchImpl;
2664
+ constructor(options) {
2665
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
2666
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_AGENT_REQUEST_TIMEOUT_MS;
2667
+ const injected = options.fetchImpl;
2668
+ if (injected) {
2669
+ this.fetchImpl = injected;
2670
+ } else if (typeof fetch === "function") {
2671
+ this.fetchImpl = (url, init) => fetch(url, init);
2672
+ } else {
2673
+ throw new Error("global fetch is unavailable; Node 20+ is required for the Android target");
2674
+ }
2675
+ }
2676
+ /**
2677
+ * Send one request and return the parsed `value` field. Rejects with a
2678
+ * {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
2679
+ * per-request deadline elapses.
2680
+ */
2681
+ async request(method, path25, body, timeoutMs) {
2682
+ const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
2683
+ const controller = new AbortController();
2684
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
2685
+ timer.unref?.();
2686
+ const url = `${this.baseUrl}${path25}`;
2687
+ let response;
2688
+ try {
2689
+ response = await this.fetchImpl(url, {
2690
+ method,
2691
+ signal: controller.signal,
2692
+ headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
2693
+ body: body !== void 0 ? JSON.stringify(body) : void 0
2694
+ });
2695
+ } catch (error) {
2696
+ if (controller.signal.aborted) {
2697
+ const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
2698
+ throw new Error(`uiautomator2 request ${method} ${path25} timed out after ${shown}`);
2699
+ }
2700
+ throw error instanceof Error ? error : new Error(String(error));
2701
+ } finally {
2702
+ clearTimeout(timer);
2703
+ }
2704
+ const text = await response.text();
2705
+ const parsed = parseJson(text);
2706
+ if (!response.ok) {
2707
+ const wdError = extractWebdriverError(parsed);
2708
+ throw new Uia2HttpError(
2709
+ `uiautomator2 ${method} ${path25} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2710
+ response.status,
2711
+ wdError
2712
+ );
2713
+ }
2714
+ return parsed?.value;
2715
+ }
2716
+ };
2717
+ function parseJson(text) {
2718
+ if (!text) {
2719
+ return void 0;
2720
+ }
2721
+ try {
2722
+ return JSON.parse(text);
2723
+ } catch {
2724
+ return void 0;
2725
+ }
2726
+ }
2727
+ function extractWebdriverError(parsed) {
2728
+ const value = parsed?.value;
2729
+ if (value && typeof value === "object") {
2730
+ const record = value;
2731
+ const error = typeof record.error === "string" ? record.error : void 0;
2732
+ const message = typeof record.message === "string" ? record.message : void 0;
2733
+ return error ?? message;
2734
+ }
2735
+ return void 0;
2736
+ }
2737
+ function extractElementId(value) {
2738
+ if (!value || typeof value !== "object") {
2739
+ return null;
2740
+ }
2741
+ const record = value;
2742
+ const id = record[W3C_ELEMENT_KEY] ?? record.ELEMENT;
2743
+ return typeof id === "string" ? id : null;
2744
+ }
2745
+ function isNoSuchElement(error) {
2746
+ if (error instanceof Uia2HttpError) {
2747
+ return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
2748
+ }
2749
+ return false;
2750
+ }
2751
+ async function createUia2Session(transport) {
2752
+ const value = await transport.request("POST", "/session", {
2753
+ capabilities: { alwaysMatch: {}, firstMatch: [{}] }
2754
+ });
2755
+ const record = value ?? {};
2756
+ const sessionId = record.sessionId;
2757
+ if (typeof sessionId === "string" && sessionId.length > 0) {
2758
+ return sessionId;
2759
+ }
2760
+ throw new Error("uiautomator2 did not return a session id");
2761
+ }
2762
+ async function waitForAgentReady(transport, options = { deadlineMs: 3e4 }) {
2763
+ const interval = options.intervalMs ?? 300;
2764
+ const deadline = Date.now() + options.deadlineMs;
2765
+ let lastError;
2766
+ for (; ; ) {
2767
+ try {
2768
+ const remainingMs = Math.max(1, deadline - Date.now());
2769
+ const value = await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
2770
+ const ready = value?.ready;
2771
+ if (ready === void 0 || ready === true) {
2772
+ return;
2773
+ }
2774
+ } catch (error) {
2775
+ lastError = error;
2776
+ }
2777
+ if (Date.now() >= deadline) {
2778
+ const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
2779
+ throw new Error(`uiautomator2 agent did not become ready within ${options.deadlineMs}ms${detail}`);
2780
+ }
2781
+ await sleep(Math.min(interval, Math.max(0, deadline - Date.now())));
2782
+ }
2783
+ }
2784
+ function sleep(ms) {
2785
+ return new Promise((resolve) => {
2786
+ const timer = setTimeout(resolve, ms);
2787
+ timer.unref?.();
2788
+ });
2789
+ }
2790
+ function createUia2AgentClient(transport, sessionId, options = {}) {
2791
+ const base = `/session/${sessionId}`;
2792
+ async function locate(query, path25) {
2793
+ return transport.request(
2794
+ "POST",
2795
+ `${base}${path25}`,
2796
+ androidQueryToLocator(query, { appPackage: options.appPackage })
2797
+ );
2798
+ }
2799
+ return {
2800
+ async findElement(query) {
2801
+ try {
2802
+ return extractElementId(await locate(query, "/element"));
2803
+ } catch (error) {
2804
+ if (isNoSuchElement(error)) {
2805
+ return null;
2806
+ }
2807
+ throw error;
2808
+ }
2809
+ },
2810
+ async findElements(query) {
2811
+ const value = await locate(query, "/elements");
2812
+ if (!Array.isArray(value)) {
2813
+ return [];
2814
+ }
2815
+ return value.map((entry) => extractElementId(entry)).filter((id) => id !== null);
2816
+ },
2817
+ async click(elementId) {
2818
+ await transport.request("POST", `${base}/element/${elementId}/click`, {});
2819
+ },
2820
+ async setValue(elementId, text) {
2821
+ await transport.request("POST", `${base}/element/${elementId}/value`, { text });
2822
+ },
2823
+ async getText(elementId) {
2824
+ const value = await transport.request("GET", `${base}/element/${elementId}/text`);
2825
+ return typeof value === "string" ? value : value == null ? null : String(value);
2826
+ },
2827
+ async pressKeyCode(keyCode) {
2828
+ await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
2829
+ },
2830
+ async screenshotPng() {
2831
+ const value = await transport.request("GET", `${base}/screenshot`);
2832
+ if (typeof value !== "string") {
2833
+ throw new Error("uiautomator2 screenshot did not return base64 data");
2834
+ }
2835
+ return Buffer.from(value, "base64");
2836
+ },
2837
+ async source() {
2838
+ const value = await transport.request("GET", `${base}/source`);
2839
+ if (typeof value !== "string") {
2840
+ throw new Error("uiautomator2 /source did not return XML text; cannot analyze Android UI hierarchy");
2841
+ }
2842
+ return value;
2843
+ },
2844
+ async close() {
2845
+ await transport.request("DELETE", base).catch(() => void 0);
2846
+ }
2847
+ };
2848
+ }
2849
+
2850
+ // src/browser/android-helper.ts
2851
+ var import_meta2 = {};
2852
+ var UIA2_REMOTE_PORT = 6790;
2853
+ function resolveAgentApks(requireFn = (0, import_node_module.createRequire)(import_meta2.url)) {
2854
+ let pkgJsonPath;
2855
+ try {
2856
+ pkgJsonPath = requireFn.resolve("appium-uiautomator2-server/package.json");
2857
+ } catch {
2858
+ throw new Error(
2859
+ "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"
2860
+ );
2861
+ }
2862
+ const pkgDir = import_node_path5.default.dirname(pkgJsonPath);
2863
+ const version = requireFn(pkgJsonPath).version;
2864
+ const serverApk = import_node_path5.default.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
2865
+ const testApk = import_node_path5.default.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
2866
+ for (const apk of [serverApk, testApk]) {
2867
+ if (!import_node_fs6.default.existsSync(apk)) {
2868
+ throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
2869
+ }
2870
+ }
2871
+ return { serverApk, testApk };
2872
+ }
2873
+ function looksLikeApk(app) {
2874
+ return app.toLowerCase().endsWith(".apk");
2875
+ }
2876
+ var execFileAaptResolver = async (apkPath) => {
2877
+ for (const tool of ["aapt", "aapt2"]) {
2878
+ const output = await new Promise((resolve) => {
2879
+ (0, import_node_child_process4.execFile)(
2880
+ tool,
2881
+ ["dump", "badging", apkPath],
2882
+ { encoding: "utf-8", timeout: 2e4, maxBuffer: 8 * 1024 * 1024 },
2883
+ (error, stdout) => resolve(error ? null : stdout)
2884
+ );
2885
+ });
2886
+ const pkg = output ? parseAaptPackage(output) : null;
2887
+ if (pkg) {
2888
+ return pkg;
2889
+ }
2890
+ }
2891
+ return null;
2892
+ };
2893
+ var defaultAgentConnector = async ({
2894
+ host,
2895
+ port,
2896
+ requestTimeoutMs,
2897
+ readyDeadlineMs,
2898
+ appPackage
2899
+ }) => {
2900
+ const transport = new Uia2Transport({
2901
+ baseUrl: `http://${host}:${port}/wd/hub`,
2902
+ requestTimeoutMs
2903
+ });
2904
+ await waitForAgentReady(transport, { deadlineMs: readyDeadlineMs });
2905
+ const sessionId = await createUia2Session(transport);
2906
+ return createUia2AgentClient(transport, sessionId, { appPackage });
2907
+ };
2908
+ async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
2909
+ if (!looksLikeApk(app)) {
2910
+ assertAndroidAppAllowed(allowedApps, app);
2911
+ return app;
2912
+ }
2913
+ const apkPath = import_node_path5.default.resolve(app);
2914
+ if (!import_node_fs6.default.existsSync(apkPath)) {
2915
+ throw new Error(`APK not found: ${apkPath}`);
2916
+ }
2917
+ const pkg = await aaptResolver(apkPath);
2918
+ if (!pkg) {
2919
+ throw new Error(
2920
+ `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.`
2921
+ );
2922
+ }
2923
+ assertAndroidAppAllowed(allowedApps, apkPath, pkg);
2924
+ await installApk(runner, serial, apkPath);
2925
+ return pkg;
2926
+ }
2927
+ async function launchAndroidSession(options) {
2928
+ const runner = options.runner ?? execFileAdbRunner;
2929
+ const spawner = options.spawner ?? spawnAdbProcess;
2930
+ const connector = options.agentConnector ?? defaultAgentConnector;
2931
+ const aaptResolver = options.aaptResolver ?? execFileAaptResolver;
2932
+ const apks = options.apks ?? resolveAgentApks();
2933
+ const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS) + 5e3;
2934
+ const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS);
2935
+ const devices = await listDevices(runner);
2936
+ const serial = selectDeviceSerial(devices, options.deviceSerial);
2937
+ const pkg = await resolvePackage(options.app, runner, serial, aaptResolver, options.allowedApps ?? []);
2938
+ await installApk(runner, serial, apks.serverApk);
2939
+ await installApk(runner, serial, apks.testApk);
2940
+ if (options.coldStart) {
2941
+ await clearPackage(runner, serial, pkg);
2942
+ }
2943
+ await launchPackage(runner, serial, pkg);
2944
+ let instrumentation;
2945
+ let localPort;
2946
+ let client;
2947
+ let tornDown = false;
2948
+ const teardown = async () => {
2949
+ if (tornDown) {
2950
+ return;
2951
+ }
2952
+ tornDown = true;
2953
+ if (client) {
2954
+ await client.close().catch(() => void 0);
2955
+ }
2956
+ instrumentation?.kill();
2957
+ const forwardedPort = localPort;
2958
+ localPort = void 0;
2959
+ if (forwardedPort !== void 0) {
2960
+ await removeForward(runner, serial, forwardedPort);
2961
+ }
2962
+ await forceStop(runner, serial, pkg).catch(() => void 0);
2963
+ };
2964
+ try {
2965
+ instrumentation = startInstrumentation(spawner, serial);
2966
+ localPort = await forwardDynamicPort(runner, serial, UIA2_REMOTE_PORT);
2967
+ client = await connector({
2968
+ host: "127.0.0.1",
2969
+ port: localPort,
2970
+ requestTimeoutMs,
2971
+ readyDeadlineMs,
2972
+ appPackage: pkg
2973
+ });
2974
+ const driver = createAndroidDriver(client, { appLabel: pkg });
2975
+ return { client, driver, package: pkg, serial, teardown };
2976
+ } catch (error) {
2977
+ await teardown();
2978
+ throw error;
2979
+ }
2980
+ }
2981
+ async function closeAndroidSession(session) {
2982
+ await session.teardown();
2983
+ }
2984
+
2985
+ // src/browser/ios-helper.ts
2986
+ var import_node_module2 = require("module");
2987
+ var import_node_child_process6 = require("child_process");
2988
+ var import_node_fs7 = __toESM(require("fs"), 1);
2989
+ var import_node_os2 = __toESM(require("os"), 1);
2990
+ var import_node_path7 = __toESM(require("path"), 1);
2991
+
2992
+ // src/browser/ios-driver.ts
2993
+ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
2994
+ "query",
2995
+ "interact",
2996
+ "wait",
2997
+ "screenshot"
2998
+ ]);
2999
+ var WAIT_POLL_INTERVAL_MS2 = 250;
3000
+ var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
3001
+ var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
3002
+ function escapePredicateArg(value) {
3003
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
3004
+ }
3005
+ function toIosQuery(selector) {
3006
+ switch (selector.kind) {
3007
+ case "focused":
3008
+ return { by: "focused" };
3009
+ case "id":
3010
+ return { by: "accessibilityId", value: selector.value };
3011
+ case "role":
3012
+ return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
3013
+ case "label":
3014
+ return { by: "label", value: selector.value };
3015
+ case "text":
3016
+ return { by: "text", value: selector.value };
3017
+ }
3018
+ }
3019
+ function parseIosSelector(selector) {
3020
+ return toIosQuery(parseNativeSelector(selector));
3021
+ }
3022
+ function iosQueryToLocator(query) {
3023
+ switch (query.by) {
3024
+ case "accessibilityId":
3025
+ return { using: "accessibility id", value: query.value };
3026
+ case "label":
3027
+ return { using: "predicate string", value: `label == "${escapePredicateArg(query.value)}"` };
3028
+ case "text": {
3029
+ const escaped = escapePredicateArg(query.value);
3030
+ return {
3031
+ using: "predicate string",
3032
+ value: `label CONTAINS "${escaped}" OR value CONTAINS "${escaped}"`
3033
+ };
3034
+ }
3035
+ case "focused":
3036
+ return { using: "predicate string", value: "hasKeyboardFocus == 1" };
3037
+ case "role": {
3038
+ const className = normalizeXcuiClassName(query.role);
3039
+ if (query.name === void 0 || query.name.length === 0) {
3040
+ return { using: "class name", value: className };
3041
+ }
3042
+ const escapedClass = escapePredicateArg(className);
3043
+ const escapedName = escapePredicateArg(query.name);
3044
+ return {
3045
+ using: "predicate string",
3046
+ value: `type == "${escapedClass}" AND (label CONTAINS "${escapedName}" OR value CONTAINS "${escapedName}")`
3047
+ };
3048
+ }
3049
+ }
3050
+ }
3051
+ function unwrapIosTextSelector(selector) {
3052
+ return unwrapNativeTextSelector(selector);
3053
+ }
3054
+ function delay2(ms) {
3055
+ return new Promise((resolve) => {
3056
+ setTimeout(resolve, ms);
3057
+ });
3058
+ }
3059
+ function createIosDriver(client, options) {
3060
+ const unsupported = (verb) => new Error(`${verb} is not supported by the iOS target`);
3061
+ const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
3062
+ async function resolveOne(selector) {
3063
+ const id = await client.findElement(parseIosSelector(selector));
3064
+ if (id === null) {
3065
+ throw new Error(`No element matched selector: ${selector}`);
3066
+ }
3067
+ return id;
3068
+ }
3069
+ async function clickSelector(selector) {
3070
+ await client.click(await resolveOne(selector));
3071
+ }
3072
+ async function fillSelector(selector, value) {
3073
+ await client.setValue(await resolveOne(selector), value);
3074
+ }
3075
+ async function pressKey(key) {
3076
+ const name = key.trim().toLowerCase();
3077
+ if (name === "enter" || name === "return") {
3078
+ await client.sendKeys(["\n"]);
3079
+ return;
3080
+ }
3081
+ if (name === "delete" || name === "backspace" || name === "del") {
3082
+ await client.sendKeys(["\b"]);
3083
+ return;
3084
+ }
3085
+ if (name === "home") {
3086
+ await client.homescreen();
3087
+ return;
3088
+ }
3089
+ throw new Error(
3090
+ `Unsupported key "${key}" for the iOS target. Supported keys: ${IOS_PRESS_KEYS.join(", ")}.`
3091
+ );
3092
+ }
3093
+ return {
3094
+ capabilities: IOS_CAPABILITIES,
3095
+ // navigation -----------------------------------------------------------
3096
+ goto(_url, _options) {
3097
+ return rejectUnsupported("navigate");
3098
+ },
3099
+ currentUrl() {
3100
+ return `ios:${options.appLabel ?? ""}`;
3101
+ },
3102
+ // queries --------------------------------------------------------------
3103
+ async count(selector) {
3104
+ return (await client.findElements(parseIosSelector(selector))).length;
3105
+ },
3106
+ async textContent(selector) {
3107
+ const id = await client.findElement(parseIosSelector(selector));
3108
+ if (id === null) {
3109
+ return null;
3110
+ }
3111
+ return client.getText(id);
3112
+ },
3113
+ // interactions ---------------------------------------------------------
3114
+ click: clickSelector,
3115
+ clickFirst: clickSelector,
3116
+ fill: fillSelector,
3117
+ fillFirst: fillSelector,
3118
+ async press(_selector, key) {
3119
+ await pressKey(key);
3120
+ },
3121
+ selectOption() {
3122
+ return rejectUnsupported("select");
3123
+ },
3124
+ selectOptionFirst() {
3125
+ return rejectUnsupported("select");
3126
+ },
3127
+ hover() {
3128
+ return rejectUnsupported("hover");
3129
+ },
3130
+ scrollIntoView() {
3131
+ return rejectUnsupported("scrollTo");
3132
+ },
3133
+ setInputFiles() {
3134
+ return rejectUnsupported("setInputFiles");
3135
+ },
3136
+ // semantic locators ----------------------------------------------------
3137
+ async countByRole(role, name) {
3138
+ return (await client.findElements({ by: "role", role, name })).length;
3139
+ },
3140
+ async clickFirstByRole(role, name) {
3141
+ const id = await client.findElement({ by: "role", role, name });
3142
+ if (id === null) {
3143
+ throw new Error(`No element matched role=${role}[name="${name}"]`);
3144
+ }
3145
+ await client.click(id);
3146
+ },
3147
+ async countByLabel(label) {
3148
+ return (await client.findElements({ by: "label", value: label })).length;
3149
+ },
3150
+ async fillFirstByLabel(label, value) {
3151
+ const id = await client.findElement({ by: "label", value: label });
3152
+ if (id === null) {
3153
+ throw new Error(`No element matched label="${label}"`);
3154
+ }
3155
+ await client.setValue(id, value);
3156
+ },
3157
+ selectOptionFirstByLabel() {
3158
+ return rejectUnsupported("select");
3159
+ },
3160
+ // waiting --------------------------------------------------------------
3161
+ async waitForSelector(selector, waitOptions) {
3162
+ const query = parseIosSelector(selector);
3163
+ const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
3164
+ const deadline = Date.now() + timeoutMs;
3165
+ for (; ; ) {
3166
+ if ((await client.findElements(query)).length > 0) {
3167
+ return;
3168
+ }
3169
+ if (Date.now() >= deadline) {
3170
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
3171
+ }
3172
+ await delay2(Math.min(WAIT_POLL_INTERVAL_MS2, Math.max(0, deadline - Date.now())));
3173
+ }
3174
+ },
3175
+ waitForUrl() {
3176
+ return rejectUnsupported("waitForUrl");
3177
+ },
3178
+ waitForNetworkIdle() {
3179
+ return rejectUnsupported("waitForNetworkIdle");
3180
+ },
3181
+ // scripting & artifacts ------------------------------------------------
3182
+ evaluate() {
3183
+ return rejectUnsupported("evalScript");
3184
+ },
3185
+ async screenshot(screenshotOptions) {
3186
+ await options.captureScreenshot(screenshotOptions.path);
3187
+ },
3188
+ // network / dialogs / downloads (all web-only) -------------------------
3189
+ onResponse(_handler) {
3190
+ throw unsupported("onResponse");
3191
+ },
3192
+ route(_url, _handler) {
3193
+ return rejectUnsupported("mockRoute");
3194
+ },
3195
+ unroute() {
3196
+ return rejectUnsupported("unmockRoute");
3197
+ },
3198
+ onDialog(_action) {
3199
+ throw unsupported("onDialog");
3200
+ },
3201
+ waitForDownloadEvent() {
3202
+ return rejectUnsupported("waitForDownload");
3203
+ },
3204
+ parseTextSelector(selector) {
3205
+ return unwrapIosTextSelector(selector);
3206
+ }
3207
+ };
3208
+ }
3209
+
3210
+ // src/browser/ios-simctl.ts
3211
+ var import_node_child_process5 = require("child_process");
3212
+ var import_promises = require("fs/promises");
3213
+ var import_node_net = __toESM(require("net"), 1);
3214
+ var import_node_os = __toESM(require("os"), 1);
3215
+ var import_node_path6 = __toESM(require("path"), 1);
3216
+ var spawnXcrunProcess = (args, options) => {
3217
+ const child = (0, import_node_child_process5.spawn)("xcrun", args, {
3218
+ stdio: "ignore",
3219
+ env: options?.env ? { ...process.env, ...options.env } : process.env
3220
+ });
3221
+ child.on("error", () => {
3222
+ });
3223
+ return {
3224
+ kill: () => {
3225
+ child.kill();
3226
+ }
3227
+ };
3228
+ };
3229
+ var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path6.default.join(import_node_os.default.tmpdir(), "prowl-ios-simulator-locks");
3230
+ var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
3231
+ var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
3232
+ (0, import_node_child_process5.execFile)(
3233
+ "xcrun",
3234
+ args,
3235
+ {
3236
+ encoding: "utf-8",
3237
+ timeout: options?.timeoutMs,
3238
+ maxBuffer: 32 * 1024 * 1024,
3239
+ env: options?.env ? { ...process.env, ...options.env } : process.env
3240
+ },
3241
+ (error, stdout, stderr) => {
3242
+ const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
3243
+ const capturedStderr = stderr ?? "";
3244
+ resolve({
3245
+ stdout: stdout ?? "",
3246
+ stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
3247
+ code
3248
+ });
3249
+ }
3250
+ );
3251
+ });
3252
+ function simulatorLockName(udid) {
3253
+ const safe = udid.replace(/[^A-Za-z0-9_.-]/g, "_");
3254
+ return `${safe || "simulator"}.lock`;
3255
+ }
3256
+ function isErrno(error, code) {
3257
+ return error?.code === code;
3258
+ }
3259
+ function isProcessAlive(pid) {
3260
+ try {
3261
+ process.kill(pid, 0);
3262
+ return true;
3263
+ } catch (error) {
3264
+ return isErrno(error, "EPERM");
3265
+ }
3266
+ }
3267
+ async function removeStaleSimulatorLock(lockPath) {
3268
+ try {
3269
+ const ownerText = await (0, import_promises.readFile)(import_node_path6.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
3270
+ const owner = JSON.parse(ownerText);
3271
+ if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
3272
+ await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
3273
+ return true;
3274
+ }
3275
+ } catch {
3276
+ return false;
3277
+ }
3278
+ return false;
3279
+ }
3280
+ function simulatorReservedError(udid) {
3281
+ return new Error(
3282
+ `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.`
3283
+ );
3284
+ }
3285
+ async function reserveSimulatorUdid(udid, options = {}) {
3286
+ const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
3287
+ const lockPath = import_node_path6.default.join(lockRoot, simulatorLockName(udid));
3288
+ await (0, import_promises.mkdir)(lockRoot, { recursive: true });
3289
+ for (let attempt = 0; attempt < 2; attempt += 1) {
3290
+ try {
3291
+ await (0, import_promises.mkdir)(lockPath);
3292
+ } catch (error) {
3293
+ if (!isErrno(error, "EEXIST")) {
3294
+ throw error instanceof Error ? error : new Error(String(error));
3295
+ }
3296
+ if (attempt === 0 && await removeStaleSimulatorLock(lockPath)) {
3297
+ continue;
3298
+ }
3299
+ throw simulatorReservedError(udid);
3300
+ }
3301
+ let released = false;
3302
+ const release = async () => {
3303
+ if (released) {
3304
+ return;
3305
+ }
3306
+ released = true;
3307
+ await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
3308
+ };
3309
+ try {
3310
+ await (0, import_promises.writeFile)(
3311
+ import_node_path6.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
3312
+ `${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
3313
+ `,
3314
+ { flag: "wx" }
3315
+ );
3316
+ } catch (error) {
3317
+ await release().catch(() => void 0);
3318
+ throw error instanceof Error ? error : new Error(String(error));
3319
+ }
3320
+ return { udid, release };
3321
+ }
3322
+ throw simulatorReservedError(udid);
3323
+ }
3324
+ function findFreePort() {
3325
+ return new Promise((resolve, reject) => {
3326
+ const server = import_node_net.default.createServer();
3327
+ server.on("error", reject);
3328
+ server.listen(0, "127.0.0.1", () => {
3329
+ const address = server.address();
3330
+ if (address && typeof address === "object") {
3331
+ const { port } = address;
3332
+ server.close(() => resolve(port));
3333
+ } else {
3334
+ server.close(() => reject(new Error("Could not allocate a local port")));
3335
+ }
3336
+ });
3337
+ });
3338
+ }
3339
+ function parseSimctlDevices(json) {
3340
+ let parsed;
3341
+ try {
3342
+ parsed = JSON.parse(json);
3343
+ } catch {
3344
+ throw new Error("Could not parse `simctl list devices --json` output.");
3345
+ }
3346
+ const byRuntime = parsed?.devices;
3347
+ if (!byRuntime || typeof byRuntime !== "object") {
3348
+ return [];
3349
+ }
3350
+ const devices = [];
3351
+ for (const [runtime, entries] of Object.entries(byRuntime)) {
3352
+ if (!Array.isArray(entries)) {
3353
+ continue;
3354
+ }
3355
+ for (const entry of entries) {
3356
+ if (!entry || typeof entry !== "object") {
3357
+ continue;
3358
+ }
3359
+ const record = entry;
3360
+ const udid = typeof record.udid === "string" ? record.udid : void 0;
3361
+ const name = typeof record.name === "string" ? record.name : void 0;
3362
+ const state = typeof record.state === "string" ? record.state : "Unknown";
3363
+ if (!udid || !name) {
3364
+ continue;
3365
+ }
3366
+ devices.push({
3367
+ udid,
3368
+ name,
3369
+ state,
3370
+ runtime,
3371
+ isAvailable: record.isAvailable !== false
3372
+ });
3373
+ }
3374
+ }
3375
+ return devices;
3376
+ }
3377
+ function bootedSimulators(devices) {
3378
+ return devices.filter((device) => device.state === "Booted");
3379
+ }
3380
+ function describeSimulator(device) {
3381
+ const runtime = device.runtime.replace(/^com\.apple\.CoreSimulator\.SimRuntime\./, "");
3382
+ return `${device.name} [${runtime}] (${device.udid})`;
3383
+ }
3384
+ function selectSimulatorUdid(devices, requested) {
3385
+ const booted = bootedSimulators(devices);
3386
+ if (requested) {
3387
+ const match = devices.find((device) => device.udid === requested);
3388
+ if (!match) {
3389
+ const known = devices.length > 0 ? devices.map((d) => d.udid).join(", ") : "none";
3390
+ throw new Error(
3391
+ `iOS simulator "${requested}" was not found. Known simulators: ${known}. Check \`xcrun simctl list devices\`.`
3392
+ );
3393
+ }
3394
+ if (match.state !== "Booted") {
3395
+ throw new Error(
3396
+ `iOS simulator "${requested}" is not booted (state: ${match.state}). Boot it with \`xcrun simctl boot ${requested}\`, then retry.`
3397
+ );
3398
+ }
3399
+ return requested;
3400
+ }
3401
+ if (booted.length === 0) {
3402
+ throw new Error(
3403
+ "No booted iOS simulator found. Boot one from Xcode or with `xcrun simctl boot <udid>` (see `xcrun simctl list devices available`), then retry."
3404
+ );
3405
+ }
3406
+ if (booted.length > 1) {
3407
+ throw new Error(
3408
+ `Multiple iOS simulators are booted (${booted.map(describeSimulator).join("; ")}). Set target.udid to pick one.`
3409
+ );
3410
+ }
3411
+ return booted[0].udid;
3412
+ }
3413
+ async function listSimulators(runner) {
3414
+ const result = await runner(["simctl", "list", "devices", "--json"], { timeoutMs: 3e4 });
3415
+ if (result.code !== 0) {
3416
+ throw new Error(
3417
+ `\`xcrun simctl list devices\` failed. Is Xcode installed and are the command-line tools selected (\`xcode-select -p\`)? ${(result.stderr.trim() || "").slice(0, 400)}`
3418
+ );
3419
+ }
3420
+ return parseSimctlDevices(result.stdout);
3421
+ }
3422
+ async function installApp(runner, udid, appPath) {
3423
+ const result = await runner(["simctl", "install", udid, appPath], { timeoutMs: 12e4 });
3424
+ if (result.code !== 0) {
3425
+ throw new Error(
3426
+ `Failed to install "${appPath}" onto simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
3427
+ );
3428
+ }
3429
+ }
3430
+ async function uninstallApp(runner, udid, bundleId) {
3431
+ await runner(["simctl", "uninstall", udid, bundleId], { timeoutMs: 6e4 }).catch(() => void 0);
3432
+ }
3433
+ async function launchApp(runner, udid, bundleId, childEnv = {}) {
3434
+ const env = {};
3435
+ for (const [key, value] of Object.entries(childEnv)) {
3436
+ env[`SIMCTL_CHILD_${key}`] = value;
3437
+ }
3438
+ const result = await runner(["simctl", "launch", udid, bundleId], {
3439
+ timeoutMs: 6e4,
3440
+ env: Object.keys(env).length > 0 ? env : void 0
3441
+ });
3442
+ if (result.code !== 0) {
3443
+ throw new Error(
3444
+ `Failed to launch "${bundleId}" on simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}. Is it installed?`
3445
+ );
3446
+ }
3447
+ }
3448
+ async function terminateApp(runner, udid, bundleId) {
3449
+ await runner(["simctl", "terminate", udid, bundleId], { timeoutMs: 3e4 }).catch(() => void 0);
3450
+ }
3451
+ async function captureScreenshot(runner, udid, outPath) {
3452
+ const result = await runner(["simctl", "io", udid, "screenshot", outPath], { timeoutMs: 3e4 });
3453
+ if (result.code !== 0) {
3454
+ throw new Error(
3455
+ `Failed to capture a simulator screenshot (${udid}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 300)}`
3456
+ );
3457
+ }
3458
+ }
3459
+ function parseXcodeVersion(stdout) {
3460
+ const match = /Xcode\s+([\d.]+)/i.exec(stdout);
3461
+ return match?.[1] ?? null;
3462
+ }
3463
+ async function xcodeVersion(runner) {
3464
+ const result = await runner(["xcodebuild", "-version"], { timeoutMs: 3e4 });
3465
+ if (result.code !== 0) {
3466
+ return null;
3467
+ }
3468
+ return parseXcodeVersion(result.stdout);
3469
+ }
3470
+
3471
+ // src/browser/ios-agent.ts
3472
+ var DEFAULT_WDA_REQUEST_TIMEOUT_MS = 3e4;
3473
+ var W3C_ELEMENT_KEY2 = "element-6066-11e4-a52e-4f735466cecf";
3474
+ var WdaHttpError = class extends Error {
3475
+ constructor(message, status, webdriverError) {
3476
+ super(message);
3477
+ this.status = status;
3478
+ this.webdriverError = webdriverError;
3479
+ this.name = "WdaHttpError";
3480
+ }
3481
+ };
3482
+ var WdaTransport = class {
3483
+ baseUrl;
3484
+ requestTimeoutMs;
3485
+ fetchImpl;
3486
+ constructor(options) {
3487
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
3488
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_WDA_REQUEST_TIMEOUT_MS;
3489
+ const injected = options.fetchImpl;
3490
+ if (injected) {
3491
+ this.fetchImpl = injected;
3492
+ } else if (typeof fetch === "function") {
3493
+ this.fetchImpl = (url, init) => fetch(url, init);
3494
+ } else {
3495
+ throw new Error("global fetch is unavailable; Node 20+ is required for the iOS target");
3496
+ }
3497
+ }
3498
+ /**
3499
+ * Send one request and return the full parsed JSON body. Rejects with a
3500
+ * {@link WdaHttpError} on a non-2xx response, or a timeout error when the
3501
+ * per-request deadline elapses.
3502
+ */
3503
+ async requestFull(method, path25, body, timeoutMs) {
3504
+ const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
3505
+ const controller = new AbortController();
3506
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
3507
+ timer.unref?.();
3508
+ const url = `${this.baseUrl}${path25}`;
3509
+ const timeoutError = () => {
3510
+ const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
3511
+ return new Error(`WebDriverAgent request ${method} ${path25} timed out after ${shown}`);
3512
+ };
3513
+ let response;
3514
+ try {
3515
+ response = await this.fetchImpl(url, {
3516
+ method,
3517
+ signal: controller.signal,
3518
+ headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
3519
+ body: body !== void 0 ? JSON.stringify(body) : void 0
3520
+ });
3521
+ } catch (error) {
3522
+ clearTimeout(timer);
3523
+ if (controller.signal.aborted) {
3524
+ throw timeoutError();
3525
+ }
3526
+ throw error instanceof Error ? error : new Error(String(error));
3527
+ }
3528
+ let text;
3529
+ try {
3530
+ text = await response.text();
3531
+ } catch (error) {
3532
+ if (controller.signal.aborted) {
3533
+ throw timeoutError();
3534
+ }
3535
+ throw error instanceof Error ? error : new Error(String(error));
3536
+ } finally {
3537
+ clearTimeout(timer);
3538
+ }
3539
+ const parsed = parseJson2(text);
3540
+ if (!response.ok) {
3541
+ const wdError = extractWebdriverError2(parsed);
3542
+ throw new WdaHttpError(
3543
+ `WebDriverAgent ${method} ${path25} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
3544
+ response.status,
3545
+ wdError
3546
+ );
3547
+ }
3548
+ return parsed;
3549
+ }
3550
+ /** Like {@link requestFull} but returns just the `value` field. */
3551
+ async request(method, path25, body, timeoutMs) {
3552
+ const parsed = await this.requestFull(method, path25, body, timeoutMs);
3553
+ return parsed?.value;
3554
+ }
3555
+ };
3556
+ function parseJson2(text) {
3557
+ if (!text) {
3558
+ return void 0;
3559
+ }
3560
+ try {
3561
+ return JSON.parse(text);
3562
+ } catch {
3563
+ return void 0;
3564
+ }
3565
+ }
3566
+ function extractWebdriverError2(parsed) {
3567
+ const value = parsed?.value;
3568
+ if (value && typeof value === "object") {
3569
+ const record = value;
3570
+ const error = typeof record.error === "string" ? record.error : void 0;
3571
+ const message = typeof record.message === "string" ? record.message : void 0;
3572
+ return error ?? message;
3573
+ }
3574
+ return void 0;
3575
+ }
3576
+ function extractElementId2(value) {
3577
+ if (!value || typeof value !== "object") {
3578
+ return null;
3579
+ }
3580
+ const record = value;
3581
+ const id = record[W3C_ELEMENT_KEY2] ?? record.ELEMENT;
3582
+ return typeof id === "string" ? id : null;
3583
+ }
3584
+ function isNoSuchElement2(error) {
3585
+ if (error instanceof WdaHttpError) {
3586
+ return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
3587
+ }
3588
+ return false;
3589
+ }
3590
+ async function createWdaSession(transport, bundleId) {
3591
+ const body = await transport.requestFull("POST", "/session", {
3592
+ capabilities: { alwaysMatch: { bundleId }, firstMatch: [{}] }
3593
+ });
3594
+ const envelope = body ?? {};
3595
+ const value = envelope.value ?? {};
3596
+ const sessionId = typeof value.sessionId === "string" && value.sessionId || typeof envelope.sessionId === "string" && envelope.sessionId || "";
3597
+ if (sessionId.length > 0) {
3598
+ return sessionId;
3599
+ }
3600
+ throw new Error("WebDriverAgent did not return a session id");
3601
+ }
3602
+ async function waitForWdaReady(transport, options = { deadlineMs: 6e4 }) {
3603
+ const interval = options.intervalMs ?? 300;
3604
+ const deadline = Date.now() + options.deadlineMs;
3605
+ let lastError;
3606
+ for (; ; ) {
3607
+ try {
3608
+ const remainingMs = Math.max(1, deadline - Date.now());
3609
+ await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
3610
+ return;
3611
+ } catch (error) {
3612
+ lastError = error;
3613
+ }
3614
+ if (Date.now() >= deadline) {
3615
+ const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
3616
+ throw new Error(`WebDriverAgent did not become ready within ${options.deadlineMs}ms${detail}`);
3617
+ }
3618
+ await sleep2(Math.min(interval, Math.max(0, deadline - Date.now())));
3619
+ }
3620
+ }
3621
+ function sleep2(ms) {
3622
+ return new Promise((resolve) => {
3623
+ setTimeout(resolve, ms);
3624
+ });
3625
+ }
3626
+ function createWdaAgentClient(transport, sessionId) {
3627
+ const base = `/session/${sessionId}`;
3628
+ async function locate(query, path25) {
3629
+ return transport.request("POST", `${base}${path25}`, iosQueryToLocator(query));
3630
+ }
3631
+ return {
3632
+ async findElement(query) {
3633
+ try {
3634
+ return extractElementId2(await locate(query, "/element"));
3635
+ } catch (error) {
3636
+ if (isNoSuchElement2(error)) {
3637
+ return null;
3638
+ }
3639
+ throw error;
3640
+ }
3641
+ },
3642
+ async findElements(query) {
3643
+ const value = await locate(query, "/elements");
3644
+ if (!Array.isArray(value)) {
3645
+ return [];
3646
+ }
3647
+ return value.map((entry) => extractElementId2(entry)).filter((id) => id !== null);
3648
+ },
3649
+ async click(elementId) {
3650
+ await transport.request("POST", `${base}/element/${elementId}/click`, {});
3651
+ },
3652
+ async setValue(elementId, text) {
3653
+ await transport.request("POST", `${base}/element/${elementId}/value`, { text });
3654
+ },
3655
+ async getText(elementId) {
3656
+ const value = await transport.request("GET", `${base}/element/${elementId}/text`);
3657
+ return typeof value === "string" ? value : value == null ? null : String(value);
3658
+ },
3659
+ async sendKeys(keys) {
3660
+ await transport.request("POST", `${base}/wda/keys`, { value: keys });
3661
+ },
3662
+ async homescreen() {
3663
+ await transport.request("POST", "/wda/homescreen", {});
3664
+ },
3665
+ async source() {
3666
+ const value = await transport.request("GET", "/source");
3667
+ if (typeof value !== "string") {
3668
+ throw new Error("WebDriverAgent /source did not return XML text; cannot analyze iOS UI hierarchy");
3669
+ }
3670
+ return value;
3671
+ },
3672
+ async close() {
3673
+ await transport.request("DELETE", base).catch(() => void 0);
3674
+ }
3675
+ };
3676
+ }
3677
+
3678
+ // src/browser/ios-helper.ts
3679
+ var import_meta3 = {};
3680
+ var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
3681
+ var WDA_USE_PORT_ENV = "USE_PORT";
3682
+ var PREPARED_XCTESTRUN_PREFIX = "prowl-wda-xctestrun-";
3683
+ var WDA_STARTUP_ATTEMPTS = 2;
3684
+ var defaultIosAgentConnector = async ({
3685
+ host,
3686
+ port,
3687
+ bundleId,
3688
+ requestTimeoutMs,
3689
+ readyDeadlineMs
3690
+ }) => {
3691
+ const transport = new WdaTransport({ baseUrl: `http://${host}:${port}`, requestTimeoutMs });
3692
+ await waitForWdaReady(transport, { deadlineMs: readyDeadlineMs });
3693
+ const sessionId = await createWdaSession(transport, bundleId);
3694
+ return createWdaAgentClient(transport, sessionId);
3695
+ };
3696
+ function resolveWdaProject(requireFn = (0, import_node_module2.createRequire)(import_meta3.url)) {
3697
+ let pkgJsonPath;
3698
+ try {
3699
+ pkgJsonPath = requireFn.resolve("appium-webdriveragent/package.json");
3700
+ } catch {
3701
+ throw new Error(
3702
+ "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"
3703
+ );
3704
+ }
3705
+ const pkgDir = import_node_path7.default.dirname(pkgJsonPath);
3706
+ const version = requireFn(pkgJsonPath).version;
3707
+ const projectPath = import_node_path7.default.join(pkgDir, "WebDriverAgent.xcodeproj");
3708
+ if (!import_node_fs7.default.existsSync(projectPath)) {
3709
+ throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
3710
+ }
3711
+ return { projectPath, version };
3712
+ }
3713
+ function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os2.default.homedir()) {
3714
+ return import_node_path7.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
3715
+ }
3716
+ function productsDir(derivedDataPath) {
3717
+ return import_node_path7.default.join(derivedDataPath, "Build", "Products");
3718
+ }
3719
+ function findXctestrunIn(dir) {
3720
+ let entries;
3721
+ try {
3722
+ entries = import_node_fs7.default.readdirSync(dir);
3723
+ } catch {
3724
+ return null;
3725
+ }
3726
+ const match = entries.filter((name) => name.endsWith(".xctestrun") && !name.startsWith(PREPARED_XCTESTRUN_PREFIX)).sort()[0];
3727
+ return match ? import_node_path7.default.join(dir, match) : null;
3728
+ }
3729
+ function resolveOverrideXctestrun(override) {
3730
+ if (!import_node_fs7.default.existsSync(override)) {
3731
+ throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
3732
+ }
3733
+ if (override.endsWith(".xctestrun")) {
3734
+ return override;
3735
+ }
3736
+ const candidates = [];
3737
+ const stat = import_node_fs7.default.statSync(override);
3738
+ if (override.endsWith(".app")) {
3739
+ candidates.push(import_node_path7.default.dirname(import_node_path7.default.dirname(override)));
3740
+ } else if (stat.isDirectory()) {
3741
+ candidates.push(override, productsDir(override));
3742
+ }
3743
+ for (const dir of candidates) {
3744
+ const found = findXctestrunIn(dir);
3745
+ if (found) {
3746
+ return found;
3747
+ }
3748
+ }
3749
+ throw new Error(
3750
+ `PROWL_WDA_RUNNER (${override}) does not resolve to a WebDriverAgent .xctestrun. Point it at the generated \`*.xctestrun\`, at the \`-derivedDataPath\` directory from \`xcodebuild build-for-testing\`, or at that build's runner \`.app\`.`
3751
+ );
3752
+ }
3753
+ async function resolveWdaTestRun(options = {}) {
3754
+ const runner = options.runner ?? execFileXcrunRunner;
3755
+ const env = options.env ?? process.env;
3756
+ const homeDir = options.homeDir ?? import_node_os2.default.homedir();
3757
+ const log = options.logger ?? ((message) => process.stderr.write(`${message}
3758
+ `));
3759
+ const override = env.PROWL_WDA_RUNNER;
3760
+ if (override) {
3761
+ return resolveOverrideXctestrun(override);
3762
+ }
3763
+ const { projectPath, version } = resolveWdaProject(options.requireFn);
3764
+ const xcode = await xcodeVersion(runner);
3765
+ if (!xcode) {
3766
+ throw new Error(
3767
+ "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."
3768
+ );
3769
+ }
3770
+ const cacheDir = wdaCacheDir(version, xcode, homeDir);
3771
+ const cached = findXctestrunIn(productsDir(cacheDir));
3772
+ if (cached) {
3773
+ return cached;
3774
+ }
3775
+ log(
3776
+ `Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
3777
+ );
3778
+ import_node_fs7.default.mkdirSync(cacheDir, { recursive: true });
3779
+ const result = await runner(
3780
+ [
3781
+ "xcodebuild",
3782
+ "build-for-testing",
3783
+ "-project",
3784
+ projectPath,
3785
+ "-scheme",
3786
+ "WebDriverAgentRunner",
3787
+ "-destination",
3788
+ "generic/platform=iOS Simulator",
3789
+ "-derivedDataPath",
3790
+ cacheDir,
3791
+ "CODE_SIGNING_ALLOWED=NO"
3792
+ ],
3793
+ { timeoutMs: 12e5 }
3794
+ );
3795
+ if (result.code !== 0) {
3796
+ throw new Error(
3797
+ `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)}`
3798
+ );
3799
+ }
3800
+ const built = findXctestrunIn(productsDir(cacheDir));
3801
+ if (!built) {
3802
+ throw new Error(
3803
+ `WebDriverAgent build succeeded but no .xctestrun was found under ${productsDir(cacheDir)}. This may indicate an Xcode layout change; set PROWL_WDA_RUNNER to a prebuilt runner/xctestrun.`
3804
+ );
3805
+ }
3806
+ return built;
3807
+ }
3808
+ function injectUsePortIntoXctestrun(plist, port) {
3809
+ const clone = structuredClone(plist);
3810
+ let injected = 0;
3811
+ const visit = (node) => {
3812
+ if (Array.isArray(node)) {
3813
+ for (const entry of node) {
3814
+ visit(entry);
3815
+ }
3816
+ return;
3817
+ }
3818
+ if (!node || typeof node !== "object") {
3819
+ return;
3820
+ }
3821
+ const obj = node;
3822
+ const isTarget = typeof obj.TestBundlePath === "string" || typeof obj.TestHostPath === "string";
3823
+ const existingEnv = obj.EnvironmentVariables && typeof obj.EnvironmentVariables === "object" && !Array.isArray(obj.EnvironmentVariables) ? obj.EnvironmentVariables : void 0;
3824
+ if (isTarget || existingEnv) {
3825
+ const env = existingEnv ?? {};
3826
+ env[WDA_USE_PORT_ENV] = String(port);
3827
+ obj.EnvironmentVariables = env;
3828
+ injected += 1;
3829
+ }
3830
+ for (const value of Object.values(obj)) {
3831
+ visit(value);
3832
+ }
3833
+ };
3834
+ visit(clone);
3835
+ if (injected === 0) {
3836
+ throw new Error(
3837
+ "Could not find a test target in the WebDriverAgent .xctestrun to set USE_PORT. The xctestrun format may have changed; set PROWL_WDA_RUNNER to a compatible runner."
3838
+ );
3839
+ }
3840
+ return clone;
3841
+ }
3842
+ function runPlutil(args) {
3843
+ return new Promise((resolve, reject) => {
3844
+ (0, import_node_child_process6.execFile)(
3845
+ "plutil",
3846
+ args,
3847
+ { encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 },
3848
+ (error, stdout, stderr) => {
3849
+ if (error) {
3850
+ reject(
3851
+ new Error(
3852
+ `\`plutil ${args.join(" ")}\` failed: ${(stderr || error.message).slice(0, 400)}`
3853
+ )
3854
+ );
3855
+ return;
3856
+ }
3857
+ resolve(stdout ?? "");
3858
+ }
3859
+ );
3860
+ });
3861
+ }
3862
+ var defaultWdaTestRunPreparer = async ({ xctestrunPath, port }) => {
3863
+ const json = await runPlutil(["-convert", "json", "-o", "-", xctestrunPath]);
3864
+ let parsed;
3865
+ try {
3866
+ parsed = JSON.parse(json);
3867
+ } catch {
3868
+ throw new Error(`Could not parse the WebDriverAgent .xctestrun as JSON: ${xctestrunPath}`);
3869
+ }
3870
+ const injected = injectUsePortIntoXctestrun(parsed, port);
3871
+ const stem = `${PREPARED_XCTESTRUN_PREFIX}${port}-${process.pid}`;
3872
+ const outPath = import_node_path7.default.join(import_node_path7.default.dirname(xctestrunPath), `${stem}.xctestrun`);
3873
+ const jsonPath = import_node_path7.default.join(import_node_os2.default.tmpdir(), `${stem}.json`);
3874
+ import_node_fs7.default.writeFileSync(jsonPath, JSON.stringify(injected));
3875
+ try {
3876
+ await runPlutil(["-convert", "xml1", jsonPath, "-o", outPath]);
3877
+ } finally {
3878
+ import_node_fs7.default.rmSync(jsonPath, { force: true });
3879
+ }
3880
+ return outPath;
3881
+ };
3882
+ function cleanupPreparedTestRun(preparedPath) {
3883
+ if (!preparedPath) {
3884
+ return;
3885
+ }
3886
+ if (!import_node_path7.default.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
3887
+ return;
3888
+ }
3889
+ import_node_fs7.default.rmSync(preparedPath, { force: true });
3890
+ }
3891
+ function wdaTestRunArgs(xctestrunPath, udid) {
3892
+ return [
3893
+ "xcodebuild",
3894
+ "test-without-building",
3895
+ "-xctestrun",
3896
+ xctestrunPath,
3897
+ "-destination",
3898
+ `id=${udid}`
3899
+ ];
3900
+ }
3901
+ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
3902
+ if (!looksLikeIosAppPath(app)) {
3903
+ assertIosAppAllowed(allowedApps, app);
3904
+ if (coldStart) {
3905
+ throw new Error(
3906
+ `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.`
3907
+ );
3908
+ }
3909
+ return app;
3910
+ }
3911
+ const appPath = import_node_path7.default.resolve(app);
3912
+ if (!import_node_fs7.default.existsSync(appPath)) {
3913
+ throw new Error(`.app bundle not found: ${appPath}`);
3914
+ }
3915
+ const bundleId = readIosBundleIdentifier(appPath);
3916
+ if (!bundleId) {
3917
+ throw new Error(
3918
+ `Could not read CFBundleIdentifier from "${appPath}" (root Info.plist). Ensure target.app points at a built iOS .app bundle.`
3919
+ );
3920
+ }
3921
+ assertIosAppAllowed(allowedApps, appPath);
3922
+ if (coldStart) {
3923
+ await uninstallApp(runner, udid, bundleId);
3924
+ }
3925
+ await installApp(runner, udid, appPath);
3926
+ return bundleId;
3927
+ }
3928
+ async function launchIosSession(options) {
3929
+ const runner = options.runner ?? execFileXcrunRunner;
3930
+ const spawner = options.spawner ?? spawnXcrunProcess;
3931
+ const preparer = options.testRunPreparer ?? defaultWdaTestRunPreparer;
3932
+ const portAllocator = options.portAllocator ?? findFreePort;
3933
+ const connector = options.agentConnector ?? defaultIosAgentConnector;
3934
+ const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
3935
+ const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, 6e4);
3936
+ const devices = await listSimulators(runner);
3937
+ const udid = selectSimulatorUdid(devices, options.udid);
3938
+ const reservation = await reserveSimulatorUdid(udid, { lockRoot: options.simulatorLockRoot });
3939
+ let reservationReleased = false;
3940
+ const releaseReservation = async () => {
3941
+ if (reservationReleased) {
3942
+ return;
3943
+ }
3944
+ reservationReleased = true;
3945
+ await reservation.release();
3946
+ };
3947
+ try {
3948
+ const bundleId = await resolveBundleId(
3949
+ options.app,
3950
+ runner,
3951
+ udid,
3952
+ options.coldStart ?? false,
3953
+ options.allowedApps ?? []
3954
+ );
3955
+ const baseTestRun = options.wdaTestRun ?? await resolveWdaTestRun({ runner, logger: options.logger });
3956
+ for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
3957
+ let client;
3958
+ let wdaProcess;
3959
+ let preparedTestRun;
3960
+ let tornDown = false;
3961
+ let terminateTarget = false;
3962
+ let stage = "prepare";
3963
+ const teardownAttempt = async () => {
3964
+ if (tornDown) {
3965
+ return;
3966
+ }
3967
+ tornDown = true;
3968
+ if (client) {
3969
+ await client.close().catch(() => void 0);
3970
+ }
3971
+ wdaProcess?.kill();
3972
+ await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
3973
+ if (terminateTarget) {
3974
+ await terminateApp(runner, udid, bundleId);
3975
+ }
3976
+ cleanupPreparedTestRun(preparedTestRun);
3977
+ };
3978
+ try {
3979
+ const port = await portAllocator();
3980
+ preparedTestRun = await preparer({ xctestrunPath: baseTestRun, port });
3981
+ stage = "wda-launch";
3982
+ wdaProcess = spawner(wdaTestRunArgs(preparedTestRun, udid));
3983
+ stage = "target-launch";
3984
+ terminateTarget = true;
3985
+ await launchApp(runner, udid, bundleId);
3986
+ stage = "connect";
3987
+ client = await connector({ host: "127.0.0.1", port, bundleId, requestTimeoutMs, readyDeadlineMs });
3988
+ const driver = createIosDriver(client, {
3989
+ appLabel: bundleId,
3990
+ captureScreenshot: (outPath) => captureScreenshot(runner, udid, outPath)
3991
+ });
3992
+ const teardown = async () => {
3993
+ await teardownAttempt();
3994
+ await releaseReservation();
3995
+ };
3996
+ return { client, driver, bundleId, udid, teardown };
3997
+ } catch (error) {
3998
+ await teardownAttempt();
3999
+ if (stage === "target-launch" || attempt >= WDA_STARTUP_ATTEMPTS) {
4000
+ throw error instanceof Error ? error : new Error(String(error));
4001
+ }
4002
+ }
4003
+ }
4004
+ throw new Error("WebDriverAgent startup failed without an error.");
4005
+ } catch (error) {
4006
+ await releaseReservation().catch(() => void 0);
4007
+ throw error;
4008
+ }
4009
+ }
4010
+ async function closeIosSession(session) {
4011
+ await session.teardown();
4012
+ }
4013
+
4014
+ // src/runner/steps.ts
4015
+ var import_node_fs9 = __toESM(require("fs"), 1);
4016
+ var import_node_path9 = __toESM(require("path"), 1);
4017
+ init_loader();
4018
+
4019
+ // src/runner/healing.ts
4020
+ var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
4021
+ function extractSelectorIntent(selector) {
4022
+ const raw = [];
4023
+ for (const match of selector.matchAll(/[#.]([A-Za-z_][\w-]*)/g)) {
4024
+ raw.push(match[1]);
4025
+ }
4026
+ for (const match of selector.matchAll(/\[[A-Za-z_:-]+\s*[~|^$*]?=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\]/g)) {
4027
+ const value = match[1] ?? match[2] ?? match[3];
4028
+ if (value) raw.push(value);
4029
+ }
4030
+ const words = [];
4031
+ for (const token of raw) {
4032
+ for (const part of splitToken(token)) {
4033
+ const lower = part.toLowerCase();
4034
+ if (lower.length > 0 && !words.includes(lower)) {
4035
+ words.push(lower);
4036
+ }
4037
+ }
4038
+ }
4039
+ return { words, label: words.join(" ") };
4040
+ }
4041
+ function splitToken(token) {
4042
+ return token.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[\s\-_.:]+/).filter((part) => part.length > 0);
4043
+ }
4044
+ function buildHealCandidates(selector) {
4045
+ const { words, label } = extractSelectorIntent(selector);
4046
+ if (words.length === 0) return [];
4047
+ const escaped = label.replace(/"/g, '\\"');
4048
+ const candidates = [];
4049
+ candidates.push({ selector: `text=${label}`, strategy: "text" });
4050
+ candidates.push({ selector: `[aria-label*="${escaped}" i]`, strategy: "aria" });
4051
+ for (const tag of INTERACTIVE_TAGS) {
4052
+ candidates.push({ selector: `${tag}:has-text("${escaped}")`, strategy: "structural" });
4053
+ }
4054
+ return candidates;
4055
+ }
4056
+ async function healSelector(probe, selector, options) {
4057
+ if (!options.enabled) return null;
4058
+ for (const candidate of buildHealCandidates(selector)) {
4059
+ let count;
4060
+ try {
4061
+ const locator2 = probe.locator(candidate.selector);
4062
+ count = await locator2.count();
4063
+ } catch {
4064
+ continue;
4065
+ }
4066
+ if (count === 1) {
4067
+ return { selector: candidate.selector, healedFrom: selector, strategy: candidate.strategy };
4068
+ }
4069
+ }
4070
+ return null;
4071
+ }
4072
+
4073
+ // src/runner/policy.ts
4074
+ var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
4075
+ function createRunPolicy(driver, options) {
4076
+ const { forbiddenSelectors, allowedDomains, maxSteps, selfHealing } = options;
4077
+ const allowedApps = options.allowedApps ?? [];
4078
+ function matchesForbiddenPattern(selector, forbidden) {
4079
+ const selectorText = driver.parseTextSelector(selector);
4080
+ if (selectorText === null) {
4081
+ return false;
4082
+ }
4083
+ const forbiddenText = driver.parseTextSelector(forbidden);
4084
+ if (forbiddenText !== null) {
4085
+ return selectorText.includes(forbiddenText);
4086
+ }
4087
+ return selectorText.includes(forbidden);
4088
+ }
4089
+ function isForbiddenSelector(selector) {
4090
+ return forbiddenSelectors.some(
4091
+ (forbidden) => selector.includes(forbidden) || matchesForbiddenPattern(selector, forbidden)
4092
+ );
4093
+ }
4094
+ function assertAllowedSelector(selector) {
4095
+ if (isForbiddenSelector(selector)) {
4096
+ throw new Error(`Forbidden selector: ${selector}`);
4097
+ }
4098
+ }
4099
+ function assertWithinMaxSteps(stepCount, huntName) {
4100
+ if (stepCount > maxSteps) {
4101
+ if (huntName) {
4102
+ throw new Error(`Hunt "${huntName}" has ${stepCount} steps. Max allowed is ${maxSteps}.`);
4103
+ }
4104
+ throw new Error(`Hunt has ${stepCount} steps. Max allowed is ${maxSteps}.`);
4105
+ }
4106
+ }
4107
+ function ensureUrlAllowed(urlValue) {
4108
+ for (const protocol of ALWAYS_ALLOWED_PROTOCOLS) {
4109
+ if (urlValue.startsWith(protocol)) {
4110
+ return;
4111
+ }
4112
+ }
4113
+ let url;
4114
+ try {
4115
+ url = new URL(urlValue);
4116
+ } catch {
4117
+ throw new Error(`Navigation target is not a valid absolute URL: ${urlValue}`);
4118
+ }
4119
+ if (!allowedDomains.includes(url.hostname)) {
4120
+ throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
4121
+ }
4122
+ }
4123
+ function ensureAppAllowed(app) {
4124
+ if (!allowedApps.includes(app)) {
4125
+ throw new Error(`Interaction with disallowed app: ${app}`);
4126
+ }
4127
+ }
4128
+ function ensureLocationAllowed(activeDriver) {
4129
+ if (activeDriver.capabilities.has("navigate")) {
4130
+ ensureUrlAllowed(activeDriver.currentUrl());
4131
+ }
4132
+ }
4133
+ const healProbe = {
4134
+ locator: (selector) => ({ count: () => driver.count(selector) })
4135
+ };
4136
+ async function resolveActionSelector(selector) {
4137
+ assertAllowedSelector(selector);
4138
+ if (!selfHealing) {
4139
+ return { selector };
4140
+ }
4141
+ let matched = false;
4142
+ try {
4143
+ matched = await driver.count(selector) > 0;
4144
+ } catch {
4145
+ return { selector };
4146
+ }
4147
+ if (matched) {
4148
+ return { selector };
4149
+ }
4150
+ const healed = await healSelector(healProbe, selector, { enabled: true });
4151
+ if (!healed) {
4152
+ return { selector };
4153
+ }
4154
+ assertAllowedSelector(healed.selector);
4155
+ console.warn(
4156
+ `Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
4157
+ );
4158
+ return { selector: healed.selector, healedFrom: healed.healedFrom };
4159
+ }
4160
+ return {
4161
+ assertWithinMaxSteps,
2019
4162
  ensureUrlAllowed,
2020
4163
  ensureAppAllowed,
2021
4164
  ensureLocationAllowed,
@@ -2024,6 +4167,246 @@ function createRunPolicy(driver, options) {
2024
4167
  };
2025
4168
  }
2026
4169
 
4170
+ // src/generator/ai.ts
4171
+ var DEFAULT_BASE_URL = {
4172
+ anthropic: "https://api.anthropic.com",
4173
+ openai: "https://api.openai.com"
4174
+ };
4175
+ var AI_REQUEST_TIMEOUT_MS = 3e4;
4176
+ function apiRoot(config) {
4177
+ return config.baseUrl ?? DEFAULT_BASE_URL[config.provider];
4178
+ }
4179
+ function defaultModelFor(provider) {
4180
+ return provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
4181
+ }
4182
+ function resolveAiEnv() {
4183
+ const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
4184
+ if (provider !== "anthropic" && provider !== "openai") {
4185
+ throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
4186
+ }
4187
+ const model = process.env.PROWL_AI_MODEL ?? defaultModelFor(provider);
4188
+ const baseUrl = normalizeBaseUrl(process.env.PROWL_AI_BASE_URL) ?? DEFAULT_BASE_URL[provider];
4189
+ return { provider, model, baseUrl, apiKey: process.env.PROWL_AI_KEY };
4190
+ }
4191
+ function normalizeBaseUrl(value) {
4192
+ if (value === void 0) return void 0;
4193
+ const trimmed = value.trim().replace(/\/+$/, "");
4194
+ return trimmed.length > 0 ? trimmed : void 0;
4195
+ }
4196
+ function resolveAiConfig() {
4197
+ const env = resolveAiEnv();
4198
+ if (!env.apiKey) {
4199
+ throw new Error(
4200
+ "PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
4201
+ );
4202
+ }
4203
+ return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
4204
+ }
4205
+ function tryResolveAiConfig() {
4206
+ const env = resolveAiEnv();
4207
+ if (!env.apiKey) {
4208
+ return null;
4209
+ }
4210
+ return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
4211
+ }
4212
+ async function generateWithAi(prompt, config) {
4213
+ if (config.provider === "anthropic") {
4214
+ return generateWithAnthropic(prompt, config);
4215
+ }
4216
+ return generateWithOpenAi(prompt, config);
4217
+ }
4218
+ async function generateWithAnthropic(prompt, config) {
4219
+ const data = await postJson(
4220
+ "Anthropic",
4221
+ `${apiRoot(config)}/v1/messages`,
4222
+ anthropicHeaders(config),
4223
+ {
4224
+ model: config.model,
4225
+ max_tokens: 4096,
4226
+ messages: [
4227
+ { role: "user", content: prompt }
4228
+ ]
4229
+ }
4230
+ );
4231
+ return extractAnthropicText(data);
4232
+ }
4233
+ async function generateWithOpenAi(prompt, config) {
4234
+ const data = await postJson(
4235
+ "OpenAI",
4236
+ `${apiRoot(config)}/v1/chat/completions`,
4237
+ openAiHeaders(config),
4238
+ {
4239
+ model: config.model,
4240
+ messages: [
4241
+ { role: "user", content: prompt }
4242
+ ],
4243
+ max_tokens: 4096
4244
+ }
4245
+ );
4246
+ return extractOpenAiText(data);
4247
+ }
4248
+ function buildVisionPrompt(assertion) {
4249
+ return [
4250
+ "You are a meticulous QA reviewer. You are given a screenshot of an application",
4251
+ "and a single assertion describing what should be true about it.",
4252
+ "",
4253
+ "Assertion:",
4254
+ assertion,
4255
+ "",
4256
+ "Decide whether the assertion holds for the screenshot. Judge ONLY what is",
4257
+ "visible; do not assume behavior you cannot see. Be strict: if the assertion",
4258
+ "is not clearly satisfied, it fails.",
4259
+ "",
4260
+ "Reply with ONLY a single JSON object on one line, no markdown, no code fences:",
4261
+ '{"pass": <true|false>, "reason": "<one concise sentence explaining the verdict>"}'
4262
+ ].join("\n");
4263
+ }
4264
+ function parseVisionVerdict(raw) {
4265
+ const cleaned = stripCodeFences(raw).trim();
4266
+ const candidate = extractJsonObject(cleaned);
4267
+ if (candidate === null) {
4268
+ throw new Error(
4269
+ `Could not parse a JSON verdict from the AI response: ${truncateForError(raw)}`
4270
+ );
4271
+ }
4272
+ let parsed;
4273
+ try {
4274
+ parsed = JSON.parse(candidate);
4275
+ } catch {
4276
+ throw new Error(
4277
+ `AI verdict was not valid JSON: ${truncateForError(raw)}`
4278
+ );
4279
+ }
4280
+ if (typeof parsed !== "object" || parsed === null) {
4281
+ throw new Error(`AI verdict was not a JSON object: ${truncateForError(raw)}`);
4282
+ }
4283
+ const record = parsed;
4284
+ if (typeof record.pass !== "boolean") {
4285
+ throw new Error(
4286
+ `AI verdict is missing a boolean "pass" field: ${truncateForError(raw)}`
4287
+ );
4288
+ }
4289
+ const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : record.pass ? "Assertion satisfied." : "Assertion not satisfied.";
4290
+ return { pass: record.pass, reason };
4291
+ }
4292
+ function stripCodeFences(text) {
4293
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
4294
+ return fence ? fence[1] : text;
4295
+ }
4296
+ function extractJsonObject(text) {
4297
+ const trimmed = text.trim();
4298
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
4299
+ return trimmed;
4300
+ }
4301
+ const first = trimmed.indexOf("{");
4302
+ const last = trimmed.lastIndexOf("}");
4303
+ if (first === -1 || last === -1 || last <= first) {
4304
+ return null;
4305
+ }
4306
+ return trimmed.slice(first, last + 1);
4307
+ }
4308
+ function truncateForError(text) {
4309
+ const collapsed = text.replace(/\s+/g, " ").trim();
4310
+ return collapsed.length > 200 ? `${collapsed.slice(0, 199)}\u2026` : collapsed;
4311
+ }
4312
+ async function assertWithAiVision(input, config) {
4313
+ const raw = config.provider === "anthropic" ? await visionWithAnthropic(input, config) : await visionWithOpenAi(input, config);
4314
+ return parseVisionVerdict(raw);
4315
+ }
4316
+ async function visionWithAnthropic(input, config) {
4317
+ const data = await postJson(
4318
+ "Anthropic",
4319
+ `${apiRoot(config)}/v1/messages`,
4320
+ anthropicHeaders(config),
4321
+ {
4322
+ model: config.model,
4323
+ max_tokens: 1024,
4324
+ temperature: 0,
4325
+ messages: [
4326
+ {
4327
+ role: "user",
4328
+ content: [
4329
+ {
4330
+ type: "image",
4331
+ source: {
4332
+ type: "base64",
4333
+ media_type: input.mediaType,
4334
+ data: input.imageBase64
4335
+ }
4336
+ },
4337
+ { type: "text", text: buildVisionPrompt(input.assertion) }
4338
+ ]
4339
+ }
4340
+ ]
4341
+ }
4342
+ );
4343
+ return extractAnthropicText(data);
4344
+ }
4345
+ async function visionWithOpenAi(input, config) {
4346
+ const data = await postJson(
4347
+ "OpenAI",
4348
+ `${apiRoot(config)}/v1/chat/completions`,
4349
+ openAiHeaders(config),
4350
+ {
4351
+ model: config.model,
4352
+ max_tokens: 1024,
4353
+ temperature: 0,
4354
+ messages: [
4355
+ {
4356
+ role: "user",
4357
+ content: [
4358
+ { type: "text", text: buildVisionPrompt(input.assertion) },
4359
+ {
4360
+ type: "image_url",
4361
+ image_url: { url: `data:${input.mediaType};base64,${input.imageBase64}` }
4362
+ }
4363
+ ]
4364
+ }
4365
+ ]
4366
+ }
4367
+ );
4368
+ return extractOpenAiText(data);
4369
+ }
4370
+ async function postJson(provider, url, headers, body) {
4371
+ const response = await fetch(url, {
4372
+ method: "POST",
4373
+ headers,
4374
+ body: JSON.stringify(body),
4375
+ signal: AbortSignal.timeout(AI_REQUEST_TIMEOUT_MS)
4376
+ });
4377
+ if (!response.ok) {
4378
+ const responseBody = await response.text();
4379
+ throw new Error(`${provider} API error (${response.status}): ${responseBody}`);
4380
+ }
4381
+ return await response.json();
4382
+ }
4383
+ function anthropicHeaders(config) {
4384
+ return {
4385
+ "Content-Type": "application/json",
4386
+ "x-api-key": config.apiKey,
4387
+ "anthropic-version": "2023-06-01"
4388
+ };
4389
+ }
4390
+ function openAiHeaders(config) {
4391
+ return {
4392
+ "Content-Type": "application/json",
4393
+ "Authorization": `Bearer ${config.apiKey}`
4394
+ };
4395
+ }
4396
+ function extractAnthropicText(data) {
4397
+ const textBlock = data.content?.find((c) => c.type === "text");
4398
+ if (!textBlock?.text) {
4399
+ throw new Error("Anthropic API returned no text content");
4400
+ }
4401
+ return textBlock.text;
4402
+ }
4403
+ function extractOpenAiText(data) {
4404
+ if (!data.choices?.[0]?.message?.content) {
4405
+ throw new Error("OpenAI API returned no content");
4406
+ }
4407
+ return data.choices[0].message.content;
4408
+ }
4409
+
2027
4410
  // src/runner/steps.ts
2028
4411
  function getStepType(step) {
2029
4412
  if ("navigate" in step) return "navigate";
@@ -2052,6 +4435,7 @@ function getStepType(step) {
2052
4435
  if ("evalScript" in step) return "evalScript";
2053
4436
  if ("runScript" in step) return "runScript";
2054
4437
  if ("assertScreenshot" in step) return "assertScreenshot";
4438
+ if ("assertWithAI" in step) return "assertWithAI";
2055
4439
  if ("copyText" in step) return "copyText";
2056
4440
  if ("waitForDownload" in step) return "waitForDownload";
2057
4441
  return "step";
@@ -2111,6 +4495,9 @@ function applyRuntimeVars(step, vars) {
2111
4495
  }
2112
4496
  };
2113
4497
  }
4498
+ if ("assertWithAI" in step) {
4499
+ return { assertWithAI: sub(step.assertWithAI) };
4500
+ }
2114
4501
  if ("copyText" in step) {
2115
4502
  return { copyText: { selector: sub(step.copyText.selector), as: step.copyText.as } };
2116
4503
  }
@@ -2361,7 +4748,7 @@ async function runInlineAssert(driver, policy, assertion) {
2361
4748
  throw new Error("assert step is missing an assertion type");
2362
4749
  }
2363
4750
  function screenshotPath(screenshotsDir, fileName) {
2364
- return import_node_path6.default.join(screenshotsDir, fileName);
4751
+ return import_node_path9.default.join(screenshotsDir, fileName);
2365
4752
  }
2366
4753
  function stepPath(prefix, index) {
2367
4754
  return prefix ? `${prefix}.${index}` : `${index}`;
@@ -2378,12 +4765,12 @@ function validateDownloadFilename(suggestedFilename) {
2378
4765
  const safeFilename = suggestedFilename.trim();
2379
4766
  const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
2380
4767
  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)) {
4768
+ if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path9.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
2382
4769
  throw new Error(`Invalid download filename: "${suggestedFilename}"`);
2383
4770
  }
2384
4771
  return safeFilename;
2385
4772
  }
2386
- async function captureScreenshot(taker, filePath) {
4773
+ async function captureScreenshot2(taker, filePath) {
2387
4774
  try {
2388
4775
  await taker.screenshot({ path: filePath, fullPage: true });
2389
4776
  } catch (error) {
@@ -2558,7 +4945,7 @@ var STEP_HANDLERS = {
2558
4945
  if (!("setInputFiles" in h.step)) unknownStep();
2559
4946
  const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
2560
4947
  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);
4948
+ const resolveFile = (f) => import_node_path9.default.isAbsolute(f) ? f : import_node_path9.default.join(h.context.configDir, f);
2562
4949
  const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
2563
4950
  await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
2564
4951
  h.policy.ensureLocationAllowed(h.driver);
@@ -2595,7 +4982,7 @@ var STEP_HANDLERS = {
2595
4982
  redactedFillSteps: subRedacted,
2596
4983
  randomVars
2597
4984
  } = interpolateHunt(subHunt, process.env, h.context.randomVars);
2598
- const subTargetType = h.driver.capabilities.has("navigate") ? "web" : "macos";
4985
+ const subTargetType = h.context.targetType ?? (h.driver.capabilities.has("navigate") ? "web" : "macos");
2599
4986
  assertStepsSupportedByTarget(interpolatedSubHunt.steps, subTargetType);
2600
4987
  assertHuntAssertionsSupportedByTarget(interpolatedSubHunt.assertions, subTargetType);
2601
4988
  h.policy.assertWithinMaxSteps(interpolatedSubHunt.steps.length, huntName);
@@ -2920,15 +5307,15 @@ var STEP_HANDLERS = {
2920
5307
  if (!responseFile) {
2921
5308
  throw new Error("mock.response must include either body or file");
2922
5309
  }
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);
5310
+ const candidateFilePath = import_node_path9.default.isAbsolute(responseFile) ? responseFile : import_node_path9.default.join(h.context.configDir, responseFile);
5311
+ const resolvedConfigDir = import_node_path9.default.resolve(h.context.configDir);
5312
+ const resolvedFilePath = import_node_path9.default.resolve(candidateFilePath);
5313
+ const relativePath = import_node_path9.default.relative(resolvedConfigDir, resolvedFilePath);
5314
+ const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path9.default.sep}`) && !import_node_path9.default.isAbsolute(relativePath);
2928
5315
  if (!isWithinConfigDir) {
2929
5316
  throw new Error("mock.response.file must resolve within config directory");
2930
5317
  }
2931
- responseBody = await import_node_fs6.default.promises.readFile(resolvedFilePath, "utf-8");
5318
+ responseBody = await import_node_fs9.default.promises.readFile(resolvedFilePath, "utf-8");
2932
5319
  }
2933
5320
  const contentType = mock.response.contentType ?? "application/json";
2934
5321
  const status = mock.response.status;
@@ -2991,8 +5378,8 @@ var STEP_HANDLERS = {
2991
5378
  capabilities: ["evaluate"],
2992
5379
  run: async (h) => {
2993
5380
  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");
5381
+ 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);
5382
+ const fileContents = import_node_fs9.default.readFileSync(filePath, "utf-8");
2996
5383
  await h.driver.evaluate(fileContents);
2997
5384
  return {
2998
5385
  kind: "result",
@@ -3013,19 +5400,19 @@ var STEP_HANDLERS = {
3013
5400
  const name = h.step.assertScreenshot.name;
3014
5401
  const threshold = h.step.assertScreenshot.threshold ?? 0.1;
3015
5402
  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 });
5403
+ const baselinePath = import_node_path9.default.join(baselineDir, `${name}.png`);
5404
+ const currentScreenshotPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
5405
+ import_node_fs9.default.mkdirSync(import_node_path9.default.dirname(currentScreenshotPath), { recursive: true });
3019
5406
  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);
5407
+ h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-current.png`));
5408
+ if (!import_node_fs9.default.existsSync(baselinePath)) {
5409
+ import_node_fs9.default.copyFileSync(currentScreenshotPath, baselinePath);
3023
5410
  return {
3024
5411
  kind: "result",
3025
5412
  result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
3026
5413
  };
3027
5414
  }
3028
- const diffPath = import_node_path6.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
5415
+ const diffPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
3029
5416
  const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
3030
5417
  if (comparison.match) {
3031
5418
  return {
@@ -3038,12 +5425,54 @@ var STEP_HANDLERS = {
3038
5425
  }
3039
5426
  };
3040
5427
  }
3041
- h.screenshots.push(import_node_path6.default.join("screenshots", `${name}-diff.png`));
5428
+ h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-diff.png`));
3042
5429
  throw new Error(
3043
5430
  `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
3044
5431
  );
3045
5432
  }
3046
5433
  },
5434
+ assertWithAI: {
5435
+ capabilities: ["screenshot"],
5436
+ run: async (h) => {
5437
+ if (!("assertWithAI" in h.step)) unknownStep();
5438
+ const assertion = h.step.assertWithAI;
5439
+ const resolve = h.context.resolveAiConfig ?? tryResolveAiConfig;
5440
+ const aiConfig = resolve();
5441
+ if (!aiConfig) {
5442
+ return {
5443
+ kind: "result",
5444
+ result: {
5445
+ type: "assertWithAI",
5446
+ status: "warn",
5447
+ durationMs: Date.now() - h.stepStart,
5448
+ value: `skipped: no AI provider configured (set PROWL_AI_KEY to enable) \u2014 "${assertion}"`
5449
+ }
5450
+ };
5451
+ }
5452
+ const fileName = `assertWithAI_step_${h.index + 1}.png`;
5453
+ const relative = await h.addScreenshot(fileName);
5454
+ const screenshotFullPath = import_node_path9.default.join(h.context.runDir, relative);
5455
+ const imageBase64 = import_node_fs9.default.readFileSync(screenshotFullPath).toString("base64");
5456
+ const assertVision = h.context.assertVision ?? assertWithAiVision;
5457
+ const verdict = await assertVision(
5458
+ { imageBase64, mediaType: "image/png", assertion },
5459
+ aiConfig
5460
+ );
5461
+ if (verdict.pass) {
5462
+ return {
5463
+ kind: "result",
5464
+ result: {
5465
+ type: "assertWithAI",
5466
+ status: "pass",
5467
+ durationMs: Date.now() - h.stepStart,
5468
+ value: verdict.reason,
5469
+ screenshot: relative
5470
+ }
5471
+ };
5472
+ }
5473
+ throw new Error(`AI assertion failed: ${verdict.reason}`);
5474
+ }
5475
+ },
3047
5476
  copyText: {
3048
5477
  capabilities: ["query"],
3049
5478
  run: async (h) => {
@@ -3080,7 +5509,7 @@ var STEP_HANDLERS = {
3080
5509
  `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
3081
5510
  );
3082
5511
  }
3083
- const savePath = import_node_path6.default.join(h.context.runDir, suggestedFilename);
5512
+ const savePath = import_node_path9.default.join(h.context.runDir, suggestedFilename);
3084
5513
  await download.saveAs(savePath);
3085
5514
  return {
3086
5515
  kind: "result",
@@ -3109,8 +5538,8 @@ async function executeSteps(context) {
3109
5538
  maxSteps: context.maxSteps,
3110
5539
  selfHealing: context.selfHealing
3111
5540
  });
3112
- const screenshotsDir = import_node_path6.default.join(context.runDir, "screenshots");
3113
- import_node_fs6.default.mkdirSync(screenshotsDir, { recursive: true });
5541
+ const screenshotsDir = import_node_path9.default.join(context.runDir, "screenshots");
5542
+ import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
3114
5543
  const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
3115
5544
  policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
3116
5545
  const results = [];
@@ -3119,8 +5548,8 @@ async function executeSteps(context) {
3119
5548
  context.runStartedAtMs = runStartedAtMs;
3120
5549
  const addScreenshot = async (fileName) => {
3121
5550
  const fullPath = screenshotPath(screenshotsDir, fileName);
3122
- await captureScreenshot(driver, fullPath);
3123
- const relative = import_node_path6.default.join("screenshots", fileName);
5551
+ await captureScreenshot2(driver, fullPath);
5552
+ const relative = import_node_path9.default.join("screenshots", fileName);
3124
5553
  screenshots.push(relative);
3125
5554
  return relative;
3126
5555
  };
@@ -3208,12 +5637,12 @@ async function executeSteps(context) {
3208
5637
  return { results, screenshots, failed: false };
3209
5638
  }
3210
5639
  async function captureFinalScreenshot(page, runDir) {
3211
- const screenshotsDir = import_node_path6.default.join(runDir, "screenshots");
3212
- import_node_fs6.default.mkdirSync(screenshotsDir, { recursive: true });
5640
+ const screenshotsDir = import_node_path9.default.join(runDir, "screenshots");
5641
+ import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
3213
5642
  const fileName = "final.png";
3214
5643
  const filePath = screenshotPath(screenshotsDir, fileName);
3215
- await captureScreenshot(page, filePath);
3216
- return import_node_path6.default.join("screenshots", fileName);
5644
+ await captureScreenshot2(page, filePath);
5645
+ return import_node_path9.default.join("screenshots", fileName);
3217
5646
  }
3218
5647
 
3219
5648
  // src/runner/assertions.ts
@@ -3373,18 +5802,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
3373
5802
  }
3374
5803
 
3375
5804
  // src/reporter/result.ts
3376
- var import_node_fs7 = __toESM(require("fs"), 1);
3377
- var import_node_path7 = __toESM(require("path"), 1);
5805
+ var import_node_fs10 = __toESM(require("fs"), 1);
5806
+ var import_node_path10 = __toESM(require("path"), 1);
3378
5807
  function writeResult(runDir, result) {
3379
5808
  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));
5809
+ const fullPath = import_node_path10.default.join(runDir, fileName);
5810
+ import_node_fs10.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
3382
5811
  return fileName;
3383
5812
  }
3384
5813
 
3385
5814
  // src/reporter/summary.ts
3386
- var import_node_fs8 = __toESM(require("fs"), 1);
3387
- var import_node_path8 = __toESM(require("path"), 1);
5815
+ var import_node_fs11 = __toESM(require("fs"), 1);
5816
+ var import_node_path11 = __toESM(require("path"), 1);
3388
5817
  function escapeMd(text) {
3389
5818
  return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
3390
5819
  }
@@ -3462,15 +5891,15 @@ function writeSummary(runDir, result) {
3462
5891
  }
3463
5892
  }
3464
5893
  const fileName = "summary.md";
3465
- const fullPath = import_node_path8.default.join(runDir, fileName);
3466
- import_node_fs8.default.writeFileSync(fullPath, `${lines.join("\n")}
5894
+ const fullPath = import_node_path11.default.join(runDir, fileName);
5895
+ import_node_fs11.default.writeFileSync(fullPath, `${lines.join("\n")}
3467
5896
  `);
3468
5897
  return fileName;
3469
5898
  }
3470
5899
 
3471
5900
  // src/reporter/junit.ts
3472
- var import_node_fs9 = __toESM(require("fs"), 1);
3473
- var import_node_path9 = __toESM(require("path"), 1);
5901
+ var import_node_fs12 = __toESM(require("fs"), 1);
5902
+ var import_node_path12 = __toESM(require("path"), 1);
3474
5903
  function escapeXml(text) {
3475
5904
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3476
5905
  }
@@ -3514,8 +5943,8 @@ function writeJunit(runDir, result) {
3514
5943
  lines.push(" </testsuite>");
3515
5944
  lines.push("</testsuites>");
3516
5945
  const fileName = "junit.xml";
3517
- const fullPath = import_node_path9.default.join(runDir, fileName);
3518
- import_node_fs9.default.writeFileSync(fullPath, `${lines.join("\n")}
5946
+ const fullPath = import_node_path12.default.join(runDir, fileName);
5947
+ import_node_fs12.default.writeFileSync(fullPath, `${lines.join("\n")}
3519
5948
  `);
3520
5949
  return fileName;
3521
5950
  }
@@ -3549,15 +5978,15 @@ function timestamp(prefix) {
3549
5978
  }
3550
5979
 
3551
5980
  // src/runner/history.ts
3552
- var import_node_fs10 = __toESM(require("fs"), 1);
3553
- var import_node_path10 = __toESM(require("path"), 1);
5981
+ var import_node_fs13 = __toESM(require("fs"), 1);
5982
+ var import_node_path13 = __toESM(require("path"), 1);
3554
5983
  var HISTORY_FILE = "history.json";
3555
5984
  var LOCK_FILE_SUFFIX = ".lock";
3556
5985
  var LOCK_RETRY_MS = 10;
3557
5986
  var LOCK_TIMEOUT_MS = 5e3;
3558
5987
  var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
3559
5988
  function historyPath(configDir) {
3560
- return import_node_path10.default.join(configDir, HISTORY_FILE);
5989
+ return import_node_path13.default.join(configDir, HISTORY_FILE);
3561
5990
  }
3562
5991
  function isHistoryEntry(value) {
3563
5992
  if (!value || typeof value !== "object") {
@@ -3568,11 +5997,11 @@ function isHistoryEntry(value) {
3568
5997
  }
3569
5998
  function readHistory(configDir) {
3570
5999
  const filePath = historyPath(configDir);
3571
- if (!import_node_fs10.default.existsSync(filePath)) {
6000
+ if (!import_node_fs13.default.existsSync(filePath)) {
3572
6001
  return { entries: [] };
3573
6002
  }
3574
6003
  try {
3575
- const raw = import_node_fs10.default.readFileSync(filePath, "utf-8");
6004
+ const raw = import_node_fs13.default.readFileSync(filePath, "utf-8");
3576
6005
  const parsed = JSON.parse(raw);
3577
6006
  if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
3578
6007
  const validatedEntries = parsed.entries.filter(isHistoryEntry);
@@ -3611,12 +6040,12 @@ function sleepSync(ms) {
3611
6040
  function withHistoryLock(configDir, fn) {
3612
6041
  const filePath = historyPath(configDir);
3613
6042
  const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
3614
- import_node_fs10.default.mkdirSync(import_node_path10.default.dirname(filePath), { recursive: true });
6043
+ import_node_fs13.default.mkdirSync(import_node_path13.default.dirname(filePath), { recursive: true });
3615
6044
  const startedAt = Date.now();
3616
6045
  while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
3617
6046
  let fd;
3618
6047
  try {
3619
- fd = import_node_fs10.default.openSync(lockPath, "wx");
6048
+ fd = import_node_fs13.default.openSync(lockPath, "wx");
3620
6049
  } catch (error) {
3621
6050
  if (error.code === "EEXIST") {
3622
6051
  sleepSync(LOCK_RETRY_MS);
@@ -3628,10 +6057,10 @@ function withHistoryLock(configDir, fn) {
3628
6057
  return fn();
3629
6058
  } finally {
3630
6059
  try {
3631
- import_node_fs10.default.closeSync(fd);
6060
+ import_node_fs13.default.closeSync(fd);
3632
6061
  } catch {
3633
6062
  }
3634
- import_node_fs10.default.rmSync(lockPath, { force: true });
6063
+ import_node_fs13.default.rmSync(lockPath, { force: true });
3635
6064
  }
3636
6065
  }
3637
6066
  throw new Error(
@@ -3644,9 +6073,9 @@ function appendEntry(configDir, entry, maxRuns) {
3644
6073
  const current = readHistory(configDir);
3645
6074
  const next = pruneEntries([...current.entries, entry], maxRuns);
3646
6075
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
3647
- import_node_fs10.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
6076
+ import_node_fs13.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
3648
6077
  `);
3649
- import_node_fs10.default.renameSync(tempPath, filePath);
6078
+ import_node_fs13.default.renameSync(tempPath, filePath);
3650
6079
  });
3651
6080
  }
3652
6081
 
@@ -3659,11 +6088,11 @@ function parseViewportFlag(value) {
3659
6088
  return value;
3660
6089
  }
3661
6090
  function resolvePath(configDir, inputPath) {
3662
- if (import_node_path11.default.isAbsolute(inputPath)) {
6091
+ if (import_node_path14.default.isAbsolute(inputPath)) {
3663
6092
  return inputPath;
3664
6093
  }
3665
- const projectRoot = import_node_path11.default.dirname(configDir);
3666
- return import_node_path11.default.join(projectRoot, inputPath);
6094
+ const projectRoot = import_node_path14.default.dirname(configDir);
6095
+ return import_node_path14.default.join(projectRoot, inputPath);
3667
6096
  }
3668
6097
  function buildRunResult(options) {
3669
6098
  return {
@@ -3682,12 +6111,12 @@ function buildRunResult(options) {
3682
6111
  }
3683
6112
  function writeConsoleLog(runDir, entries) {
3684
6113
  const fileName = "console.log";
3685
- const filePath = import_node_path11.default.join(runDir, fileName);
6114
+ const filePath = import_node_path14.default.join(runDir, fileName);
3686
6115
  const lines = entries.map((entry) => {
3687
6116
  const location = entry.location ? ` (${entry.location})` : "";
3688
6117
  return `[${entry.type}] ${entry.text}${location}`;
3689
6118
  });
3690
- import_node_fs11.default.writeFileSync(filePath, `${lines.join("\n")}
6119
+ import_node_fs14.default.writeFileSync(filePath, `${lines.join("\n")}
3691
6120
  `);
3692
6121
  return fileName;
3693
6122
  }
@@ -3695,8 +6124,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
3695
6124
  const headless = options.headed ? false : config.browser.headless;
3696
6125
  const slowMo = options.slowMo ?? config.browser.slowMo;
3697
6126
  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 });
6127
+ const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
6128
+ import_node_fs14.default.mkdirSync(runDir, { recursive: true });
3700
6129
  const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
3701
6130
  const engine = options.browser ?? config.browser.engine;
3702
6131
  const channel = options.channel ?? config.browser.channel;
@@ -3813,7 +6242,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
3813
6242
  }
3814
6243
  return { result, runDir, steps: interpolatedHunt.steps };
3815
6244
  }
3816
- function delay(ms) {
6245
+ function delay3(ms) {
3817
6246
  return new Promise((resolve) => setTimeout(resolve, ms));
3818
6247
  }
3819
6248
  async function runHunt(options) {
@@ -3821,6 +6250,12 @@ async function runHunt(options) {
3821
6250
  if (config.target.type === "macos") {
3822
6251
  return runMacHunt(options, config, configDir, config.target);
3823
6252
  }
6253
+ if (config.target.type === "android") {
6254
+ return runAndroidHunt(options, config, configDir, config.target);
6255
+ }
6256
+ if (config.target.type === "ios") {
6257
+ return runIosHunt(options, config, configDir, config.target);
6258
+ }
3824
6259
  const hunt = loadHunt(options.huntName, configDir);
3825
6260
  const {
3826
6261
  hunt: interpolatedHunt,
@@ -3842,7 +6277,7 @@ async function runHunt(options) {
3842
6277
  let lastResult;
3843
6278
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
3844
6279
  if (attempt > 0 && retryDelay > 0) {
3845
- await delay(retryDelay);
6280
+ await delay3(retryDelay);
3846
6281
  }
3847
6282
  lastResult = await executeHuntAttempt(
3848
6283
  options,
@@ -3871,19 +6306,17 @@ async function runHunt(options) {
3871
6306
  }
3872
6307
  return lastResult;
3873
6308
  }
3874
- async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
6309
+ async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
3875
6310
  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
- });
6311
+ const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
6312
+ import_node_fs14.default.mkdirSync(runDir, { recursive: true });
6313
+ const session = await native.launchSession();
3883
6314
  let result;
3884
6315
  try {
3885
- const targetLabel = `macos:${session.bundleId}`;
3886
- const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, target.app, session.bundleId])];
6316
+ const driver = native.sessionDriver(session);
6317
+ const appIdentity = native.sessionAppIdentity(session);
6318
+ const targetLabel = `${native.targetType}:${appIdentity}`;
6319
+ const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
3887
6320
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3888
6321
  const startTime = Date.now();
3889
6322
  let stepResults = [];
@@ -3891,7 +6324,8 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3891
6324
  let stepFailed = false;
3892
6325
  try {
3893
6326
  const stepExecution = await executeSteps({
3894
- driver: session.driver,
6327
+ driver,
6328
+ targetType: native.targetType,
3895
6329
  steps: interpolatedHunt.steps,
3896
6330
  targetUrl: targetLabel,
3897
6331
  runDir,
@@ -3918,7 +6352,7 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3918
6352
  }
3919
6353
  let finalScreenshot;
3920
6354
  try {
3921
- finalScreenshot = await captureFinalScreenshot(session.driver, runDir);
6355
+ finalScreenshot = await captureFinalScreenshot(driver, runDir);
3922
6356
  } catch {
3923
6357
  finalScreenshot = void 0;
3924
6358
  }
@@ -3939,16 +6373,116 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3939
6373
  });
3940
6374
  result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
3941
6375
  } finally {
3942
- await closeMacSession(session);
6376
+ await native.closeSession(session);
3943
6377
  }
3944
6378
  return { result, runDir, steps: interpolatedHunt.steps };
3945
6379
  }
6380
+ async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
6381
+ return executeNativeHuntAttempt(
6382
+ options,
6383
+ config,
6384
+ configDir,
6385
+ interpolatedHunt,
6386
+ redactedFillSteps,
6387
+ randomVars,
6388
+ allowedApps,
6389
+ {
6390
+ targetType: "macos",
6391
+ targetApp: target.app,
6392
+ launchSession: () => launchMacSession({
6393
+ app: target.app,
6394
+ timeoutMs: config.browser.timeout,
6395
+ clientFactory: options.macClientFactory
6396
+ }),
6397
+ closeSession: closeMacSession,
6398
+ sessionDriver: (session) => session.driver,
6399
+ sessionAppIdentity: (session) => session.bundleId
6400
+ }
6401
+ );
6402
+ }
3946
6403
  async function runMacHunt(options, config, configDir, target) {
6404
+ return runNativeHunt(options, config, configDir, target, {
6405
+ targetType: "macos",
6406
+ assertAppAllowed: (allowedApps, nativeTarget) => assertTargetAppAllowed(allowedApps, nativeTarget.app),
6407
+ attempt: executeMacHuntAttempt
6408
+ });
6409
+ }
6410
+ async function executeAndroidHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
6411
+ const launch = options.androidSessionFactory ?? launchAndroidSession;
6412
+ return executeNativeHuntAttempt(
6413
+ options,
6414
+ config,
6415
+ configDir,
6416
+ interpolatedHunt,
6417
+ redactedFillSteps,
6418
+ randomVars,
6419
+ allowedApps,
6420
+ {
6421
+ targetType: "android",
6422
+ targetApp: target.app,
6423
+ launchSession: () => launch({
6424
+ app: target.app,
6425
+ deviceSerial: target.deviceSerial,
6426
+ coldStart: target.coldStart,
6427
+ timeoutMs: config.browser.timeout,
6428
+ allowedApps
6429
+ }),
6430
+ closeSession: closeAndroidSession,
6431
+ sessionDriver: (session) => session.driver,
6432
+ sessionAppIdentity: (session) => session.package
6433
+ }
6434
+ );
6435
+ }
6436
+ async function runAndroidHunt(options, config, configDir, target) {
6437
+ return runNativeHunt(options, config, configDir, target, {
6438
+ targetType: "android",
6439
+ assertAppAllowed: (allowedApps, nativeTarget) => {
6440
+ if (!nativeTarget.app.toLowerCase().endsWith(".apk")) {
6441
+ assertAndroidAppAllowed(allowedApps, nativeTarget.app);
6442
+ }
6443
+ },
6444
+ attempt: executeAndroidHuntAttempt
6445
+ });
6446
+ }
6447
+ async function executeIosHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
6448
+ const launch = options.iosSessionFactory ?? launchIosSession;
6449
+ return executeNativeHuntAttempt(
6450
+ options,
6451
+ config,
6452
+ configDir,
6453
+ interpolatedHunt,
6454
+ redactedFillSteps,
6455
+ randomVars,
6456
+ allowedApps,
6457
+ {
6458
+ targetType: "ios",
6459
+ targetApp: target.app,
6460
+ launchSession: () => launch({
6461
+ app: target.app,
6462
+ udid: target.udid,
6463
+ coldStart: target.coldStart,
6464
+ timeoutMs: config.browser.timeout,
6465
+ allowedApps
6466
+ }),
6467
+ closeSession: closeIosSession,
6468
+ sessionDriver: (session) => session.driver,
6469
+ sessionAppIdentity: (session) => session.bundleId
6470
+ }
6471
+ );
6472
+ }
6473
+ async function runIosHunt(options, config, configDir, target) {
6474
+ return runNativeHunt(options, config, configDir, target, {
6475
+ targetType: "ios",
6476
+ assertAppAllowed: (allowedApps, nativeTarget) => assertIosAppAllowed(allowedApps, nativeTarget.app),
6477
+ attempt: executeIosHuntAttempt
6478
+ });
6479
+ }
6480
+ async function runNativeHunt(options, config, configDir, target, native) {
3947
6481
  const hunt = loadHunt(options.huntName, configDir);
3948
6482
  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);
6483
+ assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
6484
+ assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
6485
+ native.assertAppAllowed(config.guardrails.allowedApps, target);
3952
6486
  const maxSteps = config.guardrails.maxSteps;
3953
6487
  if (interpolatedHunt.steps.length > maxSteps) {
3954
6488
  throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
@@ -3958,9 +6492,9 @@ async function runMacHunt(options, config, configDir, target) {
3958
6492
  let lastResult;
3959
6493
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
3960
6494
  if (attempt > 0 && retryDelay > 0) {
3961
- await delay(retryDelay);
6495
+ await delay3(retryDelay);
3962
6496
  }
3963
- lastResult = await executeMacHuntAttempt(
6497
+ lastResult = await native.attempt(
3964
6498
  options,
3965
6499
  config,
3966
6500
  configDir,
@@ -3988,7 +6522,7 @@ async function runMacHunt(options, config, configDir, target) {
3988
6522
  }
3989
6523
  function recordHistory(configDir, outcome, maxRuns) {
3990
6524
  try {
3991
- const relativeRunDir = import_node_path11.default.relative(configDir, outcome.runDir);
6525
+ const relativeRunDir = import_node_path14.default.relative(configDir, outcome.runDir);
3992
6526
  appendEntry(
3993
6527
  configDir,
3994
6528
  {
@@ -4081,6 +6615,7 @@ function describeStep(step) {
4081
6615
  const th = step.assertScreenshot.threshold !== void 0 ? ` (threshold: ${step.assertScreenshot.threshold})` : "";
4082
6616
  return `assertScreenshot "${step.assertScreenshot.name}"${th}`;
4083
6617
  }
6618
+ if ("assertWithAI" in step) return `assertWithAI "${truncate(step.assertWithAI, 60)}"`;
4084
6619
  return "unknown step";
4085
6620
  }
4086
6621
  function truncate(text, max) {
@@ -4096,6 +6631,9 @@ function printStepResult(result, step, _index) {
4096
6631
  const duration = import_chalk.default.gray(`(${result.durationMs}ms)`);
4097
6632
  if (result.status === "pass") {
4098
6633
  console.log(` ${import_chalk.default.green("\u2713")} ${label} ${duration}`);
6634
+ } else if (result.status === "warn") {
6635
+ const note = result.value ? import_chalk.default.gray(` \u2014 ${result.value}`) : "";
6636
+ console.log(` ${import_chalk.default.yellow("\u25CB")} ${label} ${duration}${note}`);
4099
6637
  } else {
4100
6638
  const error = result.error ? import_chalk.default.gray(` \u2014 ${result.error}`) : "";
4101
6639
  console.log(` ${import_chalk.default.red("\u2717")} ${label} ${duration}${error}`);
@@ -4213,37 +6751,37 @@ function buildRunCommand() {
4213
6751
  }
4214
6752
 
4215
6753
  // src/cli/commands/init.ts
4216
- var import_node_fs12 = __toESM(require("fs"), 1);
4217
- var import_node_path12 = __toESM(require("path"), 1);
6754
+ var import_node_fs15 = __toESM(require("fs"), 1);
6755
+ var import_node_path15 = __toESM(require("path"), 1);
4218
6756
  var import_node_url2 = require("url");
4219
6757
  var import_commander2 = require("commander");
4220
6758
  var import_chalk4 = __toESM(require("chalk"), 1);
4221
6759
  init_loader();
4222
- var import_meta2 = {};
6760
+ var import_meta4 = {};
4223
6761
  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;
6762
+ const currentFile = (0, import_node_url2.fileURLToPath)(import_meta4.url);
6763
+ let dir = import_node_path15.default.dirname(currentFile);
6764
+ const root = import_node_path15.default.parse(dir).root;
4227
6765
  while (dir !== root) {
4228
- if (import_node_fs12.default.existsSync(import_node_path12.default.join(dir, "package.json"))) {
6766
+ if (import_node_fs15.default.existsSync(import_node_path15.default.join(dir, "package.json"))) {
4229
6767
  return dir;
4230
6768
  }
4231
- dir = import_node_path12.default.dirname(dir);
6769
+ dir = import_node_path15.default.dirname(dir);
4232
6770
  }
4233
- if (import_node_fs12.default.existsSync(import_node_path12.default.join(root, "package.json"))) {
6771
+ if (import_node_fs15.default.existsSync(import_node_path15.default.join(root, "package.json"))) {
4234
6772
  return root;
4235
6773
  }
4236
6774
  throw new Error("Cannot find package root. Reinstall prowl-tools.");
4237
6775
  }
4238
6776
  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);
6777
+ import_node_fs15.default.mkdirSync(import_node_path15.default.dirname(destination), { recursive: true });
6778
+ import_node_fs15.default.copyFileSync(source, destination);
4241
6779
  }
4242
6780
  function buildInitCommand() {
4243
6781
  const command = new import_commander2.Command("init").option("--force", `Overwrite existing ${CONFIG_DIR} directory`).action((options) => {
4244
6782
  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) {
6783
+ const prowlDir = import_node_path15.default.join(root, CONFIG_DIR);
6784
+ if (import_node_fs15.default.existsSync(prowlDir) && !options.force) {
4247
6785
  console.error(
4248
6786
  import_chalk4.default.red(
4249
6787
  `${CONFIG_DIR} already exists. Run with --force to reinitialize prowl configuration without deleting existing files.`
@@ -4253,20 +6791,20 @@ function buildInitCommand() {
4253
6791
  return;
4254
6792
  }
4255
6793
  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)) {
6794
+ const examplesDir = import_node_path15.default.join(packageRoot, "examples");
6795
+ const exampleConfig = import_node_path15.default.join(examplesDir, "config.yml");
6796
+ const exampleHuntsDir = import_node_path15.default.join(examplesDir, "hunts");
6797
+ if (!import_node_fs15.default.existsSync(exampleConfig) || !import_node_fs15.default.existsSync(exampleHuntsDir)) {
4260
6798
  console.error(import_chalk4.default.red("Examples not found in package. Reinstall prowl-tools."));
4261
6799
  process.exitCode = 1;
4262
6800
  return;
4263
6801
  }
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"));
6802
+ copyFile(exampleConfig, import_node_path15.default.join(prowlDir, "config.yml"));
6803
+ const huntFiles = import_node_fs15.default.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
4266
6804
  for (const huntFile of huntFiles) {
4267
6805
  copyFile(
4268
- import_node_path12.default.join(exampleHuntsDir, huntFile),
4269
- import_node_path12.default.join(prowlDir, "hunts", huntFile)
6806
+ import_node_path15.default.join(exampleHuntsDir, huntFile),
6807
+ import_node_path15.default.join(prowlDir, "hunts", huntFile)
4270
6808
  );
4271
6809
  }
4272
6810
  const gitignore = [
@@ -4280,7 +6818,7 @@ function buildInitCommand() {
4280
6818
  ".env",
4281
6819
  ""
4282
6820
  ].join("\n");
4283
- import_node_fs12.default.writeFileSync(import_node_path12.default.join(prowlDir, ".gitignore"), gitignore);
6821
+ import_node_fs15.default.writeFileSync(import_node_path15.default.join(prowlDir, ".gitignore"), gitignore);
4284
6822
  console.log(welcomeBanner());
4285
6823
  console.log(import_chalk4.default.green(` Initialized ${CONFIG_DIR} directory.`));
4286
6824
  console.log(import_chalk4.default.gray(" Run ") + import_chalk4.default.bold("prowl run hello") + import_chalk4.default.gray(" to get started."));
@@ -4290,17 +6828,17 @@ function buildInitCommand() {
4290
6828
  }
4291
6829
 
4292
6830
  // src/cli/commands/login.ts
4293
- var import_node_path13 = __toESM(require("path"), 1);
6831
+ var import_node_path16 = __toESM(require("path"), 1);
4294
6832
  var import_node_readline = __toESM(require("readline"), 1);
4295
6833
  var import_chalk5 = __toESM(require("chalk"), 1);
4296
6834
  var import_commander3 = require("commander");
4297
6835
  init_loader();
4298
6836
  function resolvePath2(configDir, inputPath) {
4299
- if (import_node_path13.default.isAbsolute(inputPath)) {
6837
+ if (import_node_path16.default.isAbsolute(inputPath)) {
4300
6838
  return inputPath;
4301
6839
  }
4302
- const projectRoot = import_node_path13.default.dirname(configDir);
4303
- return import_node_path13.default.join(projectRoot, inputPath);
6840
+ const projectRoot = import_node_path16.default.dirname(configDir);
6841
+ return import_node_path16.default.join(projectRoot, inputPath);
4304
6842
  }
4305
6843
  function waitForEnter(prompt) {
4306
6844
  return new Promise((resolve) => {
@@ -4316,7 +6854,7 @@ function buildLoginCommand() {
4316
6854
  let session = null;
4317
6855
  try {
4318
6856
  const { config, configDir } = loadConfig(options.config);
4319
- if (config.target.type === "macos") {
6857
+ if (config.target.type !== "web") {
4320
6858
  throw new Error("`prowl login` captures browser auth state and only applies to web targets.");
4321
6859
  }
4322
6860
  const targetUrl = options.url ?? config.target.url;
@@ -4387,18 +6925,18 @@ function buildListCommand() {
4387
6925
  }
4388
6926
 
4389
6927
  // src/cli/commands/watch.ts
4390
- var import_node_fs13 = __toESM(require("fs"), 1);
6928
+ var import_node_fs16 = __toESM(require("fs"), 1);
4391
6929
  var import_chalk7 = __toESM(require("chalk"), 1);
4392
6930
  var import_commander5 = require("commander");
4393
6931
  init_loader();
4394
6932
 
4395
6933
  // src/cli/watch-utils.ts
4396
- var import_node_path14 = __toESM(require("path"), 1);
6934
+ var import_node_path17 = __toESM(require("path"), 1);
4397
6935
  function getWatchTargets(configDir, huntName) {
4398
6936
  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")
6937
+ import_node_path17.default.join(configDir, "hunts", `${huntName}.yml`),
6938
+ import_node_path17.default.join(configDir, "config.yml"),
6939
+ import_node_path17.default.join(configDir, ".env")
4402
6940
  ];
4403
6941
  }
4404
6942
  function createDebouncer(delayMs, fn) {
@@ -4467,14 +7005,14 @@ function buildWatchCommand() {
4467
7005
  });
4468
7006
  const unwatch = [];
4469
7007
  for (const target of watchTargets) {
4470
- import_node_fs13.default.watchFile(target, { interval: 150 }, (curr, prev) => {
7008
+ import_node_fs16.default.watchFile(target, { interval: 150 }, (curr, prev) => {
4471
7009
  if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) {
4472
7010
  return;
4473
7011
  }
4474
7012
  console.log(import_chalk7.default.gray(`Change detected: ${target}`));
4475
7013
  debounced.trigger();
4476
7014
  });
4477
- unwatch.push(() => import_node_fs13.default.unwatchFile(target));
7015
+ unwatch.push(() => import_node_fs16.default.unwatchFile(target));
4478
7016
  }
4479
7017
  const stop = () => {
4480
7018
  if (stopped) {
@@ -4500,12 +7038,12 @@ var import_commander6 = require("commander");
4500
7038
  var import_chalk9 = __toESM(require("chalk"), 1);
4501
7039
 
4502
7040
  // src/runner/suite.ts
4503
- var import_node_path17 = __toESM(require("path"), 1);
7041
+ var import_node_path20 = __toESM(require("path"), 1);
4504
7042
  init_loader();
4505
7043
 
4506
7044
  // src/reporter/ci-summary.ts
4507
- var import_node_fs14 = __toESM(require("fs"), 1);
4508
- var import_node_path15 = __toESM(require("path"), 1);
7045
+ var import_node_fs17 = __toESM(require("fs"), 1);
7046
+ var import_node_path18 = __toESM(require("path"), 1);
4509
7047
  var import_chalk8 = __toESM(require("chalk"), 1);
4510
7048
  function countCiResults(results) {
4511
7049
  return {
@@ -4569,9 +7107,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
4569
7107
  ...flaky.length > 0 ? { flaky } : {},
4570
7108
  ...clusters.length > 0 ? { clusters } : {}
4571
7109
  };
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");
7110
+ import_node_fs17.default.mkdirSync(ciRunDir, { recursive: true });
7111
+ const filePath = import_node_path18.default.join(ciRunDir, "ci-result.json");
7112
+ import_node_fs17.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
4575
7113
  return filePath;
4576
7114
  }
4577
7115
 
@@ -4705,8 +7243,8 @@ function clusterFailures(failures) {
4705
7243
  }
4706
7244
 
4707
7245
  // src/backlog/index.ts
4708
- var import_node_fs15 = __toESM(require("fs"), 1);
4709
- var import_node_path16 = __toESM(require("path"), 1);
7246
+ var import_node_fs18 = __toESM(require("fs"), 1);
7247
+ var import_node_path19 = __toESM(require("path"), 1);
4710
7248
 
4711
7249
  // src/backlog/parse.ts
4712
7250
  var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
@@ -4796,7 +7334,7 @@ ${after}`;
4796
7334
  // src/backlog/index.ts
4797
7335
  function readFileOrEmpty(filePath) {
4798
7336
  try {
4799
- return import_node_fs15.default.readFileSync(filePath, "utf-8");
7337
+ return import_node_fs18.default.readFileSync(filePath, "utf-8");
4800
7338
  } catch (error) {
4801
7339
  const err = error;
4802
7340
  if (err.code === "ENOENT") return "";
@@ -4812,7 +7350,7 @@ function buildFailure(hunt) {
4812
7350
  if (!hunt.runDir) return failure;
4813
7351
  let run;
4814
7352
  try {
4815
- const resultJson = readFileOrEmpty(import_node_path16.default.join(hunt.runDir, "result.json"));
7353
+ const resultJson = readFileOrEmpty(import_node_path19.default.join(hunt.runDir, "result.json"));
4816
7354
  if (!resultJson) return failure;
4817
7355
  run = JSON.parse(resultJson);
4818
7356
  } catch (error) {
@@ -4841,8 +7379,8 @@ function extractFailures(suiteResult) {
4841
7379
  }
4842
7380
  function updateBacklogFromSuite(suiteResult, options = {}) {
4843
7381
  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");
7382
+ const backlogPath = options.backlogPath ?? import_node_path19.default.join(projectRoot, "docs", "backlog.md");
7383
+ const resolvedPath = options.resolvedPath ?? import_node_path19.default.join(projectRoot, "docs", "resolved.md");
4846
7384
  const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
4847
7385
  const summary = { created: [], regressions: [], skipped: [], backlogPath };
4848
7386
  const failures = extractFailures(suiteResult);
@@ -4874,8 +7412,8 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
4874
7412
  }
4875
7413
  }
4876
7414
  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));
7415
+ import_node_fs18.default.mkdirSync(import_node_path19.default.dirname(backlogPath), { recursive: true });
7416
+ import_node_fs18.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
4879
7417
  }
4880
7418
  return summary;
4881
7419
  }
@@ -5036,7 +7574,7 @@ async function runSuite(options = {}) {
5036
7574
  const clusters = clusterFailures(
5037
7575
  extractFailures({ result: { hunts: results }, resultPath: null })
5038
7576
  ).filter((cluster) => cluster.count > 1);
5039
- const ciRunDir = import_node_path17.default.join(configDir, "runs", timestamp("ci"));
7577
+ const ciRunDir = import_node_path20.default.join(configDir, "runs", timestamp("ci"));
5040
7578
  const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
5041
7579
  const { passed, failed, skipped } = countCiResults(results);
5042
7580
  return {
@@ -5154,8 +7692,8 @@ function buildCiCommand() {
5154
7692
  }
5155
7693
 
5156
7694
  // src/cli/commands/update-baselines.ts
5157
- var import_node_fs16 = __toESM(require("fs"), 1);
5158
- var import_node_path18 = __toESM(require("path"), 1);
7695
+ var import_node_fs19 = __toESM(require("fs"), 1);
7696
+ var import_node_path21 = __toESM(require("path"), 1);
5159
7697
  var import_commander7 = require("commander");
5160
7698
  var import_chalk10 = __toESM(require("chalk"), 1);
5161
7699
  init_loader();
@@ -5163,33 +7701,33 @@ function buildUpdateBaselinesCommand() {
5163
7701
  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
7702
  try {
5165
7703
  const { configDir } = loadConfig(options.config);
5166
- const baselinesDir = import_node_path18.default.join(configDir, "baselines");
5167
- import_node_fs16.default.mkdirSync(baselinesDir, { recursive: true });
7704
+ const baselinesDir = import_node_path21.default.join(configDir, "baselines");
7705
+ import_node_fs19.default.mkdirSync(baselinesDir, { recursive: true });
5168
7706
  let runDir;
5169
7707
  if (options.run) {
5170
- runDir = import_node_path18.default.isAbsolute(options.run) ? options.run : import_node_path18.default.resolve(options.run);
7708
+ runDir = import_node_path21.default.isAbsolute(options.run) ? options.run : import_node_path21.default.resolve(options.run);
5171
7709
  } else {
5172
- const runsDir = import_node_path18.default.join(configDir, "runs");
5173
- if (!import_node_fs16.default.existsSync(runsDir)) {
7710
+ const runsDir = import_node_path21.default.join(configDir, "runs");
7711
+ if (!import_node_fs19.default.existsSync(runsDir)) {
5174
7712
  console.error(import_chalk10.default.red(" No runs directory found. Run a hunt first."));
5175
7713
  process.exitCode = 1;
5176
7714
  return;
5177
7715
  }
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();
7716
+ 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
7717
  if (entries.length === 0) {
5180
7718
  console.error(import_chalk10.default.red(" No run directories found. Run a hunt first."));
5181
7719
  process.exitCode = 1;
5182
7720
  return;
5183
7721
  }
5184
- runDir = import_node_path18.default.join(runsDir, entries[0]);
7722
+ runDir = import_node_path21.default.join(runsDir, entries[0]);
5185
7723
  }
5186
- const screenshotsDir = import_node_path18.default.join(runDir, "screenshots");
5187
- if (!import_node_fs16.default.existsSync(screenshotsDir)) {
7724
+ const screenshotsDir = import_node_path21.default.join(runDir, "screenshots");
7725
+ if (!import_node_fs19.default.existsSync(screenshotsDir)) {
5188
7726
  console.error(import_chalk10.default.red(` No screenshots found in ${runDir}`));
5189
7727
  process.exitCode = 1;
5190
7728
  return;
5191
7729
  }
5192
- const screenshots = import_node_fs16.default.readdirSync(screenshotsDir).filter((f) => f.endsWith("-current.png"));
7730
+ const screenshots = import_node_fs19.default.readdirSync(screenshotsDir).filter((f) => f.endsWith("-current.png"));
5193
7731
  if (screenshots.length === 0) {
5194
7732
  console.log(import_chalk10.default.yellow(" No assertScreenshot results found in this run."));
5195
7733
  return;
@@ -5203,10 +7741,10 @@ function buildUpdateBaselinesCommand() {
5203
7741
  let updated = 0;
5204
7742
  for (const file of filtered) {
5205
7743
  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);
7744
+ const sourcePath = import_node_path21.default.join(screenshotsDir, file);
7745
+ const destPath = import_node_path21.default.join(baselinesDir, baselineName);
7746
+ const exists = import_node_fs19.default.existsSync(destPath);
7747
+ import_node_fs19.default.copyFileSync(sourcePath, destPath);
5210
7748
  updated++;
5211
7749
  const status = exists ? import_chalk10.default.yellow("updated") : import_chalk10.default.green("created");
5212
7750
  console.log(` ${status} ${baselineName}`);
@@ -5334,30 +7872,372 @@ async function analyzePage(page) {
5334
7872
  ...el.formIndex >= 0 ? { formGroup: el.formIndex } : {}
5335
7873
  };
5336
7874
  });
5337
- const links = raw.elements.filter((el) => el.tag === "a" && el.href).map((el) => {
5338
- let selector;
5339
- if (el.testId) {
5340
- selector = `[data-testid="${el.testId}"]`;
5341
- } else if (el.href) {
5342
- selector = `a[href="${el.href}"]`;
5343
- } else {
5344
- selector = `a`;
7875
+ const links = raw.elements.filter((el) => el.tag === "a" && el.href).map((el) => {
7876
+ let selector;
7877
+ if (el.testId) {
7878
+ selector = `[data-testid="${el.testId}"]`;
7879
+ } else if (el.href) {
7880
+ selector = `a[href="${el.href}"]`;
7881
+ } else {
7882
+ selector = `a`;
7883
+ }
7884
+ return {
7885
+ text: el.text || "",
7886
+ href: el.href,
7887
+ selector
7888
+ };
7889
+ });
7890
+ const forms = raw.forms;
7891
+ return {
7892
+ url: raw.url,
7893
+ title: raw.title,
7894
+ elements,
7895
+ forms,
7896
+ links
7897
+ };
7898
+ }
7899
+
7900
+ // src/analyzer/mac.ts
7901
+ var INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
7902
+ "AXButton",
7903
+ "AXTextField",
7904
+ "AXSecureTextField",
7905
+ "AXTextArea",
7906
+ "AXCheckBox",
7907
+ "AXRadioButton",
7908
+ "AXPopUpButton",
7909
+ "AXMenuButton",
7910
+ "AXLink",
7911
+ "AXMenuItem",
7912
+ "AXComboBox",
7913
+ "AXSlider",
7914
+ "AXDisclosureTriangle"
7915
+ ]);
7916
+ var DEFAULT_ANALYZE_TREE_DEPTH = 20;
7917
+ function str(value) {
7918
+ if (typeof value !== "string") {
7919
+ return void 0;
7920
+ }
7921
+ const trimmed = value.trim();
7922
+ return trimmed.length > 0 ? trimmed : void 0;
7923
+ }
7924
+ function toNode(raw) {
7925
+ const node = raw ?? {};
7926
+ const children = Array.isArray(node.children) ? node.children.map(toNode) : void 0;
7927
+ return {
7928
+ role: str(node.role),
7929
+ title: str(node.title),
7930
+ description: str(node.description),
7931
+ value: str(node.value),
7932
+ identifier: str(node.identifier),
7933
+ enabled: typeof node.enabled === "boolean" ? node.enabled : void 0,
7934
+ ...children ? { children } : {}
7935
+ };
7936
+ }
7937
+ function quote(value) {
7938
+ return `"${value}"`;
7939
+ }
7940
+ function rankMacSelectors(node) {
7941
+ const selectors = [];
7942
+ const identifier = node.identifier;
7943
+ const exactLabel = node.title ?? node.description;
7944
+ const name = node.title ?? node.description ?? node.value;
7945
+ if (identifier) {
7946
+ selectors.push(`id=${identifier}`);
7947
+ }
7948
+ if (exactLabel) {
7949
+ selectors.push(`label=${quote(exactLabel)}`);
7950
+ }
7951
+ if (node.role && name) {
7952
+ selectors.push(`role=${node.role}[name=${quote(name)}]`);
7953
+ }
7954
+ if (name) {
7955
+ selectors.push(`text=${quote(name)}`);
7956
+ }
7957
+ if (selectors.length === 0 && node.role) {
7958
+ selectors.push(`role=${node.role}`);
7959
+ }
7960
+ return selectors;
7961
+ }
7962
+ function toElement(node, source) {
7963
+ return {
7964
+ role: node.role ?? "?",
7965
+ ...node.title ? { title: node.title } : {},
7966
+ ...node.description ? { description: node.description } : {},
7967
+ ...node.value ? { value: node.value } : {},
7968
+ ...node.identifier ? { identifier: node.identifier } : {},
7969
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
7970
+ source,
7971
+ selectors: rankMacSelectors(node)
7972
+ };
7973
+ }
7974
+ function collectInteractive(root) {
7975
+ const out = [];
7976
+ const visit = (node) => {
7977
+ if (node.role && INTERACTIVE_ROLES.has(node.role)) {
7978
+ out.push(toElement(node, "window"));
7979
+ }
7980
+ for (const child of node.children ?? []) {
7981
+ visit(child);
7982
+ }
7983
+ };
7984
+ visit(root);
7985
+ return out;
7986
+ }
7987
+ function toWindow(node) {
7988
+ const [best] = rankMacSelectors(node);
7989
+ return {
7990
+ ...node.title ? { title: node.title } : {},
7991
+ ...node.identifier ? { identifier: node.identifier } : {},
7992
+ selector: best ?? "role=AXWindow"
7993
+ };
7994
+ }
7995
+ async function analyzeMacApp(client, options) {
7996
+ const depth = options.treeDepth ?? DEFAULT_ANALYZE_TREE_DEPTH;
7997
+ const treeResult = await client.request("tree", { depth });
7998
+ const elements = collectInteractive(toNode(treeResult.tree));
7999
+ const windowsResult = await client.request("windows");
8000
+ const rawWindows = Array.isArray(windowsResult.windows) ? windowsResult.windows : [];
8001
+ const windows = rawWindows.map((raw) => toWindow(toNode(raw)));
8002
+ const menuItems = await readStatusMenu(client, options.menuTimeoutSeconds);
8003
+ return { app: options.app, elements, windows, menuItems };
8004
+ }
8005
+ async function readStatusMenu(client, menuTimeoutSeconds) {
8006
+ let statusItems;
8007
+ try {
8008
+ const status = await client.request("statusItems");
8009
+ statusItems = Array.isArray(status.items) ? status.items : [];
8010
+ } catch {
8011
+ return [];
8012
+ }
8013
+ if (statusItems.length === 0) {
8014
+ return [];
8015
+ }
8016
+ const params = menuTimeoutSeconds !== void 0 ? { timeout: menuTimeoutSeconds } : {};
8017
+ try {
8018
+ const menu = await client.request("openMenu", params);
8019
+ const rawItems = Array.isArray(menu.items) ? menu.items : [];
8020
+ return rawItems.map((raw) => toNode(raw)).filter((node) => node.role !== "AXMenuItem" || node.title || node.identifier || node.description).map((node) => toElement(node, "menu"));
8021
+ } catch {
8022
+ return [];
8023
+ } finally {
8024
+ await client.request("closeMenu").catch(() => void 0);
8025
+ }
8026
+ }
8027
+
8028
+ // src/analyzer/android.ts
8029
+ var ANDROID_INTERACTIVE_CLASSES = /* @__PURE__ */ new Set([
8030
+ "android.widget.Button",
8031
+ "android.widget.ImageButton",
8032
+ "android.widget.EditText",
8033
+ "android.widget.CheckBox",
8034
+ "android.widget.RadioButton",
8035
+ "android.widget.Switch",
8036
+ "android.widget.ToggleButton",
8037
+ "android.widget.Spinner",
8038
+ "android.widget.SeekBar",
8039
+ "android.widget.RatingBar",
8040
+ "android.widget.CompoundButton",
8041
+ "android.widget.AutoCompleteTextView",
8042
+ "android.widget.MultiAutoCompleteTextView",
8043
+ "android.widget.CheckedTextView",
8044
+ "androidx.appcompat.widget.SwitchCompat",
8045
+ "androidx.appcompat.widget.AppCompatButton",
8046
+ "androidx.appcompat.widget.AppCompatEditText"
8047
+ ]);
8048
+ function str2(value) {
8049
+ if (value === void 0) {
8050
+ return void 0;
8051
+ }
8052
+ const trimmed = value.trim();
8053
+ return trimmed.length > 0 ? trimmed : void 0;
8054
+ }
8055
+ function bool(value) {
8056
+ if (value === void 0) {
8057
+ return void 0;
8058
+ }
8059
+ return value === "true";
8060
+ }
8061
+ function toAndroidNode(element) {
8062
+ const a = element.attrs;
8063
+ return {
8064
+ className: str2(a["class"]),
8065
+ resourceId: str2(a["resource-id"]),
8066
+ contentDesc: str2(a["content-desc"]),
8067
+ text: str2(a.text),
8068
+ package: str2(a.package),
8069
+ clickable: bool(a.clickable),
8070
+ longClickable: bool(a["long-clickable"]),
8071
+ checkable: bool(a.checkable),
8072
+ checked: bool(a.checked),
8073
+ scrollable: bool(a.scrollable),
8074
+ focusable: bool(a.focusable),
8075
+ focused: bool(a.focused),
8076
+ enabled: bool(a.enabled),
8077
+ children: element.children.map(toAndroidNode)
8078
+ };
8079
+ }
8080
+ function parseAndroidHierarchy(xml) {
8081
+ const root = parseXml(xml);
8082
+ return root ? toAndroidNode(root) : null;
8083
+ }
8084
+ function isAndroidInteractive(node) {
8085
+ if (node.clickable || node.longClickable || node.checkable || node.scrollable) {
8086
+ return true;
8087
+ }
8088
+ return node.className !== void 0 && ANDROID_INTERACTIVE_CLASSES.has(node.className);
8089
+ }
8090
+ function rankAndroidSelectors(node) {
8091
+ return rankNativeSelectors({
8092
+ id: node.resourceId,
8093
+ label: node.contentDesc,
8094
+ role: node.className,
8095
+ name: node.text
8096
+ });
8097
+ }
8098
+ function toElement2(node) {
8099
+ return {
8100
+ className: node.className ?? "?",
8101
+ ...node.resourceId ? { resourceId: node.resourceId } : {},
8102
+ ...node.contentDesc ? { contentDesc: node.contentDesc } : {},
8103
+ ...node.text ? { text: node.text } : {},
8104
+ ...node.clickable !== void 0 ? { clickable: node.clickable } : {},
8105
+ ...node.checkable !== void 0 ? { checkable: node.checkable } : {},
8106
+ ...node.scrollable !== void 0 ? { scrollable: node.scrollable } : {},
8107
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
8108
+ selectors: rankAndroidSelectors(node)
8109
+ };
8110
+ }
8111
+ function collectInteractive2(root) {
8112
+ const out = [];
8113
+ const visit = (node) => {
8114
+ if (isAndroidInteractive(node)) {
8115
+ out.push(toElement2(node));
5345
8116
  }
5346
- return {
5347
- text: el.text || "",
5348
- href: el.href,
5349
- selector
5350
- };
8117
+ for (const child of node.children) {
8118
+ visit(child);
8119
+ }
8120
+ };
8121
+ visit(root);
8122
+ return out;
8123
+ }
8124
+ async function analyzeAndroidApp(client, options) {
8125
+ const xml = await client.source();
8126
+ const root = parseAndroidHierarchy(xml);
8127
+ const elements = root ? collectInteractive2(root) : [];
8128
+ return { app: options.app, elements };
8129
+ }
8130
+
8131
+ // src/analyzer/ios.ts
8132
+ var IOS_INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
8133
+ "XCUIElementTypeButton",
8134
+ "XCUIElementTypeCell",
8135
+ "XCUIElementTypeTextField",
8136
+ "XCUIElementTypeSecureTextField",
8137
+ "XCUIElementTypeSearchField",
8138
+ "XCUIElementTypeSwitch",
8139
+ "XCUIElementTypeToggle",
8140
+ "XCUIElementTypeLink",
8141
+ "XCUIElementTypeMenuItem",
8142
+ "XCUIElementTypeSlider",
8143
+ "XCUIElementTypeStepper",
8144
+ "XCUIElementTypeTextView",
8145
+ "XCUIElementTypePickerWheel",
8146
+ "XCUIElementTypeTab",
8147
+ "XCUIElementTypeSegmentedControl",
8148
+ "XCUIElementTypeCheckBox",
8149
+ "XCUIElementTypeRadioButton",
8150
+ "XCUIElementTypeKey"
8151
+ ]);
8152
+ var IOS_WINDOW_TYPE = "XCUIElementTypeWindow";
8153
+ function str3(value) {
8154
+ if (value === void 0) {
8155
+ return void 0;
8156
+ }
8157
+ const trimmed = value.trim();
8158
+ return trimmed.length > 0 ? trimmed : void 0;
8159
+ }
8160
+ function bool2(value) {
8161
+ if (value === void 0) {
8162
+ return void 0;
8163
+ }
8164
+ return value === "true" || value === "1";
8165
+ }
8166
+ function toIosNode(element) {
8167
+ const a = element.attrs;
8168
+ return {
8169
+ type: str3(a.type) ?? str3(element.tag),
8170
+ name: str3(a.name),
8171
+ label: str3(a.label),
8172
+ value: str3(a.value),
8173
+ enabled: bool2(a.enabled),
8174
+ visible: bool2(a.visible),
8175
+ children: element.children.map(toIosNode)
8176
+ };
8177
+ }
8178
+ function parseIosHierarchy(xml) {
8179
+ const root = parseXml(xml);
8180
+ return root ? toIosNode(root) : null;
8181
+ }
8182
+ function hasAccessibilityId(node) {
8183
+ return node.name !== void 0 && node.name !== node.label;
8184
+ }
8185
+ function rankIosSelectors(node) {
8186
+ return rankNativeSelectors({
8187
+ id: hasAccessibilityId(node) ? node.name : void 0,
8188
+ label: node.label,
8189
+ role: node.type ? shortIosType(node.type) : void 0,
8190
+ name: node.label ?? node.value
5351
8191
  });
5352
- const forms = raw.forms;
8192
+ }
8193
+ function toElement3(node) {
5353
8194
  return {
5354
- url: raw.url,
5355
- title: raw.title,
5356
- elements,
5357
- forms,
5358
- links
8195
+ type: node.type ?? "?",
8196
+ ...node.name ? { name: node.name } : {},
8197
+ ...node.label ? { label: node.label } : {},
8198
+ ...node.value ? { value: node.value } : {},
8199
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
8200
+ ...node.visible !== void 0 ? { visible: node.visible } : {},
8201
+ selectors: rankIosSelectors(node)
5359
8202
  };
5360
8203
  }
8204
+ function toWindow2(node) {
8205
+ const [best] = rankIosSelectors(node);
8206
+ return {
8207
+ ...node.name ? { name: node.name } : {},
8208
+ ...node.label ? { label: node.label } : {},
8209
+ selector: best ?? `role=${shortIosType(IOS_WINDOW_TYPE)}`
8210
+ };
8211
+ }
8212
+ function isIosInteractive(node) {
8213
+ return node.type !== void 0 && IOS_INTERACTIVE_TYPES.has(node.type);
8214
+ }
8215
+ function collect(root) {
8216
+ const elements = [];
8217
+ const windows = [];
8218
+ const visit = (node) => {
8219
+ if (node.type === IOS_WINDOW_TYPE) {
8220
+ windows.push(toWindow2(node));
8221
+ }
8222
+ if (isIosInteractive(node)) {
8223
+ elements.push(toElement3(node));
8224
+ }
8225
+ for (const child of node.children) {
8226
+ visit(child);
8227
+ }
8228
+ };
8229
+ visit(root);
8230
+ return { elements, windows };
8231
+ }
8232
+ async function analyzeIosApp(client, options) {
8233
+ const xml = await client.source();
8234
+ const root = parseIosHierarchy(xml);
8235
+ if (!root) {
8236
+ return { app: options.app, elements: [], windows: [] };
8237
+ }
8238
+ const { elements, windows } = collect(root);
8239
+ return { app: options.app, elements, windows };
8240
+ }
5361
8241
 
5362
8242
  // src/cli/commands/analyze.ts
5363
8243
  init_loader();
@@ -5368,73 +8248,313 @@ function parseViewportFlag2(value) {
5368
8248
  }
5369
8249
  return value;
5370
8250
  }
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(`
8251
+ function tryLoadConfig(configPath) {
8252
+ const configLocation = configPath !== void 0 ? configPath : findConfigPath(process.cwd());
8253
+ if (configLocation === null) {
8254
+ return null;
8255
+ }
8256
+ try {
8257
+ return loadConfig(configLocation).config;
8258
+ } catch (error) {
8259
+ const message = error instanceof Error ? error.message : "Unknown configuration error";
8260
+ throw new Error(`Failed to load config at ${configLocation}: ${message}`);
8261
+ }
8262
+ }
8263
+ function looksLikeApk2(app) {
8264
+ return /\.apk$/i.test(app.trim());
8265
+ }
8266
+ function pickNativePlatform(platformFlag, config, app) {
8267
+ if (platformFlag !== void 0) {
8268
+ const normalized = platformFlag.trim().toLowerCase();
8269
+ if (normalized === "macos" || normalized === "android" || normalized === "ios") {
8270
+ return normalized;
8271
+ }
8272
+ throw new Error(`Unknown --platform "${platformFlag}". Use one of: macos, android, ios.`);
8273
+ }
8274
+ const configType = config?.target.type;
8275
+ if (configType === "macos" || configType === "android" || configType === "ios") {
8276
+ return configType;
8277
+ }
8278
+ return looksLikeApk2(app) ? "android" : "macos";
8279
+ }
8280
+ async function runWebAnalyze(url, options) {
8281
+ const engine = parseBrowserEngine(options.browser);
8282
+ const channel = options.channel;
8283
+ const viewport = options.viewport ? resolveViewport(parseViewportFlag2(options.viewport)) : { width: 1280, height: 720 };
8284
+ const session = await launchBrowser({
8285
+ headless: !options.headed,
8286
+ slowMo: 0,
8287
+ timeout: 3e4,
8288
+ trace: false,
8289
+ recordHar: false,
8290
+ runDir: process.cwd(),
8291
+ engine,
8292
+ channel,
8293
+ viewport
8294
+ });
8295
+ const driver = createPlaywrightDriver(session.page);
8296
+ try {
8297
+ await driver.goto(url, { waitUntil: "networkidle" });
8298
+ const result = await analyzePage(driver);
8299
+ if (options.json) {
8300
+ console.log(JSON.stringify(result, null, 2));
8301
+ } else {
8302
+ console.log(`
5396
8303
  ${import_chalk11.default.bold("Page Analysis:")} ${result.title}`);
5397
- console.log(` ${import_chalk11.default.gray("URL:")} ${result.url}
8304
+ console.log(` ${import_chalk11.default.gray("URL:")} ${result.url}
5398
8305
  `);
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
8306
+ if (result.forms.length > 0) {
8307
+ console.log(import_chalk11.default.bold(" Forms:"));
8308
+ for (const form of result.forms) {
8309
+ const method = form.method ? import_chalk11.default.cyan(form.method) : "";
8310
+ const action = form.action ? import_chalk11.default.gray(form.action) : "";
8311
+ console.log(` [${form.index}] ${method} ${action} (${form.fieldCount} fields)`);
8312
+ }
8313
+ console.log();
8314
+ }
8315
+ if (result.elements.length > 0) {
8316
+ console.log(import_chalk11.default.bold(" Interactive Elements:"));
8317
+ for (const el of result.elements) {
8318
+ const tag = import_chalk11.default.cyan(el.tag);
8319
+ const type = el.type ? import_chalk11.default.gray(`[${el.type}]`) : "";
8320
+ const bestSelector = el.selectors.testId ?? el.selectors.label ?? el.selectors.ariaLabel ?? el.selectors.css ?? el.selectors.name ?? "";
8321
+ const selectorStr = bestSelector ? import_chalk11.default.yellow(bestSelector) : import_chalk11.default.gray("(no selector)");
8322
+ const req = el.required ? import_chalk11.default.red(" *") : "";
8323
+ const form = el.formGroup !== void 0 ? import_chalk11.default.gray(` form[${el.formGroup}]`) : "";
8324
+ console.log(` ${tag}${type} ${selectorStr}${req}${form}`);
8325
+ }
8326
+ console.log();
8327
+ }
8328
+ if (result.links.length > 0) {
8329
+ console.log(import_chalk11.default.bold(" Links:"));
8330
+ for (const link of result.links.slice(0, 20)) {
8331
+ const text = link.text || import_chalk11.default.gray("(no text)");
8332
+ console.log(` ${text} ${import_chalk11.default.gray("\u2192")} ${import_chalk11.default.blue(link.href)}`);
8333
+ }
8334
+ if (result.links.length > 20) {
8335
+ console.log(import_chalk11.default.gray(` ... and ${result.links.length - 20} more`));
8336
+ }
8337
+ console.log();
8338
+ }
8339
+ console.log(import_chalk11.default.gray(` ${result.elements.length} elements, ${result.forms.length} forms, ${result.links.length} links
8340
+ `));
8341
+ }
8342
+ } finally {
8343
+ await closeBrowser(session);
8344
+ }
8345
+ }
8346
+ function printMacElement(el) {
8347
+ const role = import_chalk11.default.cyan(el.role);
8348
+ const [best] = el.selectors;
8349
+ const selectorStr = best ? import_chalk11.default.yellow(best) : import_chalk11.default.gray("(no selector)");
8350
+ const label = el.title ?? el.description ?? el.value;
8351
+ const labelStr = label ? ` ${import_chalk11.default.gray(`"${label}"`)}` : "";
8352
+ const disabled = el.enabled === false ? import_chalk11.default.gray(" (disabled)") : "";
8353
+ console.log(` ${role} ${selectorStr}${labelStr}${disabled}`);
8354
+ }
8355
+ async function runMacAnalyze(app, config, options) {
8356
+ const allowedApps = config?.guardrails.allowedApps ?? [];
8357
+ assertTargetAppAllowed(allowedApps, app);
8358
+ const session = await launchMacSession({ app, timeoutMs: config?.browser.timeout });
8359
+ try {
8360
+ const result = await analyzeMacApp(session.client, { app: session.bundleId });
8361
+ if (options.json) {
8362
+ console.log(JSON.stringify(result, null, 2));
8363
+ } else {
8364
+ console.log(`
8365
+ ${import_chalk11.default.bold("App Analysis:")} ${result.app}
8366
+ `);
8367
+ if (result.windows.length > 0) {
8368
+ console.log(import_chalk11.default.bold(" Windows:"));
8369
+ for (const win of result.windows) {
8370
+ const title = win.title ? import_chalk11.default.gray(`"${win.title}"`) : import_chalk11.default.gray("(untitled)");
8371
+ console.log(` ${title} ${import_chalk11.default.yellow(win.selector)}`);
8372
+ }
8373
+ console.log();
8374
+ }
8375
+ if (result.elements.length > 0) {
8376
+ console.log(import_chalk11.default.bold(" Interactive Elements:"));
8377
+ for (const el of result.elements) {
8378
+ printMacElement(el);
8379
+ }
8380
+ console.log();
8381
+ }
8382
+ if (result.menuItems.length > 0) {
8383
+ console.log(import_chalk11.default.bold(" Menu Bar:"));
8384
+ for (const el of result.menuItems) {
8385
+ printMacElement(el);
8386
+ }
8387
+ console.log();
8388
+ }
8389
+ console.log(
8390
+ import_chalk11.default.gray(
8391
+ ` ${result.elements.length} elements, ${result.windows.length} windows, ${result.menuItems.length} menu items
8392
+ `
8393
+ )
8394
+ );
8395
+ }
8396
+ } finally {
8397
+ await session.client.close();
8398
+ }
8399
+ }
8400
+ function printAndroidElement(el) {
8401
+ const role = import_chalk11.default.cyan(el.className);
8402
+ const [best] = el.selectors;
8403
+ const selectorStr = best ? import_chalk11.default.yellow(best) : import_chalk11.default.gray("(no selector)");
8404
+ const label = el.contentDesc ?? el.text;
8405
+ const labelStr = label ? ` ${import_chalk11.default.gray(`"${label}"`)}` : "";
8406
+ const disabled = el.enabled === false ? import_chalk11.default.gray(" (disabled)") : "";
8407
+ console.log(` ${role} ${selectorStr}${labelStr}${disabled}`);
8408
+ }
8409
+ async function runAndroidAnalyze(app, config, options, deviceSerial) {
8410
+ const allowedApps = config?.guardrails.allowedApps ?? [];
8411
+ if (!looksLikeApk2(app)) {
8412
+ assertAndroidAppAllowed(allowedApps, app);
8413
+ }
8414
+ const session = await launchAndroidSession({
8415
+ app,
8416
+ ...deviceSerial ? { deviceSerial } : {},
8417
+ timeoutMs: config?.browser.timeout,
8418
+ allowedApps
8419
+ });
8420
+ try {
8421
+ const sourceFn = session.client.source;
8422
+ if (typeof sourceFn !== "function") {
8423
+ throw new Error(
8424
+ "The Android agent client does not expose source(); a live uiautomator2 session is required to analyze."
8425
+ );
8426
+ }
8427
+ const result = await analyzeAndroidApp(
8428
+ { source: () => sourceFn.call(session.client) },
8429
+ { app: session.package }
8430
+ );
8431
+ if (options.json) {
8432
+ console.log(JSON.stringify(result, null, 2));
8433
+ } else {
8434
+ console.log(`
8435
+ ${import_chalk11.default.bold("App Analysis:")} ${result.app}
8436
+ `);
8437
+ if (result.elements.length > 0) {
8438
+ console.log(import_chalk11.default.bold(" Interactive Elements:"));
8439
+ for (const el of result.elements) {
8440
+ printAndroidElement(el);
8441
+ }
8442
+ console.log();
8443
+ }
8444
+ console.log(import_chalk11.default.gray(` ${result.elements.length} elements
5433
8445
  `));
8446
+ }
8447
+ } finally {
8448
+ await session.teardown();
8449
+ }
8450
+ }
8451
+ function printIosElement(el) {
8452
+ const role = import_chalk11.default.cyan(el.type);
8453
+ const [best] = el.selectors;
8454
+ const selectorStr = best ? import_chalk11.default.yellow(best) : import_chalk11.default.gray("(no selector)");
8455
+ const label = el.label ?? el.value ?? el.name;
8456
+ const labelStr = label ? ` ${import_chalk11.default.gray(`"${label}"`)}` : "";
8457
+ const disabled = el.enabled === false ? import_chalk11.default.gray(" (disabled)") : "";
8458
+ console.log(` ${role} ${selectorStr}${labelStr}${disabled}`);
8459
+ }
8460
+ async function runIosAnalyze(app, config, options, udid) {
8461
+ const allowedApps = config?.guardrails.allowedApps ?? [];
8462
+ if (!/\//.test(app) && !/\.app$/i.test(app.trim())) {
8463
+ assertIosAppAllowed(allowedApps, app);
8464
+ }
8465
+ const session = await launchIosSession({
8466
+ app,
8467
+ ...udid ? { udid } : {},
8468
+ timeoutMs: config?.browser.timeout,
8469
+ allowedApps
8470
+ });
8471
+ try {
8472
+ const sourceFn = session.client.source;
8473
+ if (typeof sourceFn !== "function") {
8474
+ throw new Error(
8475
+ "The iOS agent client does not expose source(); a live WebDriverAgent session is required to analyze."
8476
+ );
8477
+ }
8478
+ const result = await analyzeIosApp(
8479
+ { source: () => sourceFn.call(session.client) },
8480
+ { app: session.bundleId }
8481
+ );
8482
+ if (options.json) {
8483
+ console.log(JSON.stringify(result, null, 2));
8484
+ } else {
8485
+ console.log(`
8486
+ ${import_chalk11.default.bold("App Analysis:")} ${result.app}
8487
+ `);
8488
+ if (result.windows.length > 0) {
8489
+ console.log(import_chalk11.default.bold(" Windows:"));
8490
+ for (const win of result.windows) {
8491
+ const title = win.label ?? win.name;
8492
+ const titleStr = title ? import_chalk11.default.gray(`"${title}"`) : import_chalk11.default.gray("(untitled)");
8493
+ console.log(` ${titleStr} ${import_chalk11.default.yellow(win.selector)}`);
5434
8494
  }
5435
- } finally {
5436
- await closeBrowser(session);
8495
+ console.log();
8496
+ }
8497
+ if (result.elements.length > 0) {
8498
+ console.log(import_chalk11.default.bold(" Interactive Elements:"));
8499
+ for (const el of result.elements) {
8500
+ printIosElement(el);
8501
+ }
8502
+ console.log();
8503
+ }
8504
+ console.log(import_chalk11.default.gray(` ${result.elements.length} elements, ${result.windows.length} windows
8505
+ `));
8506
+ }
8507
+ } finally {
8508
+ await session.teardown();
8509
+ }
8510
+ }
8511
+ async function runNativeAnalyze(platform, app, config, options, device) {
8512
+ if (platform === "android") {
8513
+ await runAndroidAnalyze(app, config, options, device.deviceSerial);
8514
+ return;
8515
+ }
8516
+ if (platform === "ios") {
8517
+ await runIosAnalyze(app, config, options, device.udid);
8518
+ return;
8519
+ }
8520
+ await runMacAnalyze(app, config, options);
8521
+ }
8522
+ function buildAnalyzeCommand() {
8523
+ const command = new import_commander8.Command("analyze").argument("[url]", "URL to analyze (web target)").description("Analyze a page or native app to discover interactive elements and selectors").option("--json", "Output as JSON").option("--app <app>", "Native app to analyze: bundle id / package / .app / .apk (macOS, Android, or iOS)").option("--platform <name>", "Native platform for --app: macos, android, or ios (else inferred)").option("--device <serial>", "Android adb device serial (native Android target)").option("--udid <udid>", "iOS simulator UDID (native iOS 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) => {
8524
+ try {
8525
+ if (options.app && url) {
8526
+ throw new Error("Pass either a URL argument (web) or --app (native), not both.");
8527
+ }
8528
+ if (options.app) {
8529
+ const config2 = tryLoadConfig(options.config);
8530
+ const platform = pickNativePlatform(options.platform, config2, options.app);
8531
+ await runNativeAnalyze(platform, options.app, config2, options, {
8532
+ deviceSerial: options.device,
8533
+ udid: options.udid
8534
+ });
8535
+ return;
8536
+ }
8537
+ if (url) {
8538
+ await runWebAnalyze(url, options);
8539
+ return;
8540
+ }
8541
+ const config = tryLoadConfig(options.config);
8542
+ const target = config?.target;
8543
+ if (target?.type === "macos") {
8544
+ await runMacAnalyze(target.app, config, options);
8545
+ return;
5437
8546
  }
8547
+ if (target?.type === "android") {
8548
+ await runAndroidAnalyze(target.app, config, options, options.device ?? target.deviceSerial);
8549
+ return;
8550
+ }
8551
+ if (target?.type === "ios") {
8552
+ await runIosAnalyze(target.app, config, options, options.udid ?? target.udid);
8553
+ return;
8554
+ }
8555
+ throw new Error(
8556
+ "analyze needs a target: pass a URL for the web target, or use --app <bundle-id|package|.app|.apk> (optionally with --platform), or set a native target.type in .prowl/config.yml."
8557
+ );
5438
8558
  } catch (error) {
5439
8559
  const message = error instanceof Error ? error.message : "Analysis failed";
5440
8560
  if (options.json) {
@@ -5451,8 +8571,8 @@ function buildAnalyzeCommand() {
5451
8571
  }
5452
8572
 
5453
8573
  // src/cli/commands/generate.ts
5454
- var import_node_fs17 = __toESM(require("fs"), 1);
5455
- var import_node_path19 = __toESM(require("path"), 1);
8574
+ var import_node_fs20 = __toESM(require("fs"), 1);
8575
+ var import_node_path22 = __toESM(require("path"), 1);
5456
8576
  var import_commander9 = require("commander");
5457
8577
  var import_chalk12 = __toESM(require("chalk"), 1);
5458
8578
  var import_ora = __toESM(require("ora"), 1);
@@ -5503,6 +8623,7 @@ var STEP_REFERENCE = `
5503
8623
 
5504
8624
  ### Visual Regression
5505
8625
  - assertScreenshot: { name: "baseline-name", threshold: 0.1 }
8626
+ - assertWithAI: "The login form should show email and password fields" \u2014 AI checks the screenshot against the claim
5506
8627
 
5507
8628
  ### Control Flow
5508
8629
  - if: { visible: ".banner", then: [steps...] }
@@ -5544,81 +8665,6 @@ function extractYamlFromResponse(response) {
5544
8665
  return response.trim();
5545
8666
  }
5546
8667
 
5547
- // src/generator/ai.ts
5548
- function resolveAiConfig() {
5549
- const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
5550
- if (provider !== "anthropic" && provider !== "openai") {
5551
- throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
5552
- }
5553
- const apiKey = process.env.PROWL_AI_KEY;
5554
- if (!apiKey) {
5555
- throw new Error(
5556
- "PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
5557
- );
5558
- }
5559
- const defaultModel = provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
5560
- const model = process.env.PROWL_AI_MODEL ?? defaultModel;
5561
- return { provider, model, apiKey };
5562
- }
5563
- async function generateWithAi(prompt, config) {
5564
- if (config.provider === "anthropic") {
5565
- return generateWithAnthropic(prompt, config);
5566
- }
5567
- return generateWithOpenAi(prompt, config);
5568
- }
5569
- async function generateWithAnthropic(prompt, config) {
5570
- const response = await fetch("https://api.anthropic.com/v1/messages", {
5571
- method: "POST",
5572
- headers: {
5573
- "Content-Type": "application/json",
5574
- "x-api-key": config.apiKey,
5575
- "anthropic-version": "2023-06-01"
5576
- },
5577
- body: JSON.stringify({
5578
- model: config.model,
5579
- max_tokens: 4096,
5580
- messages: [
5581
- { role: "user", content: prompt }
5582
- ]
5583
- })
5584
- });
5585
- if (!response.ok) {
5586
- const body = await response.text();
5587
- throw new Error(`Anthropic API error (${response.status}): ${body}`);
5588
- }
5589
- const data = await response.json();
5590
- const textBlock = data.content.find((c) => c.type === "text");
5591
- if (!textBlock?.text) {
5592
- throw new Error("Anthropic API returned no text content");
5593
- }
5594
- return textBlock.text;
5595
- }
5596
- async function generateWithOpenAi(prompt, config) {
5597
- const response = await fetch("https://api.openai.com/v1/chat/completions", {
5598
- method: "POST",
5599
- headers: {
5600
- "Content-Type": "application/json",
5601
- "Authorization": `Bearer ${config.apiKey}`
5602
- },
5603
- body: JSON.stringify({
5604
- model: config.model,
5605
- messages: [
5606
- { role: "user", content: prompt }
5607
- ],
5608
- max_tokens: 4096
5609
- })
5610
- });
5611
- if (!response.ok) {
5612
- const body = await response.text();
5613
- throw new Error(`OpenAI API error (${response.status}): ${body}`);
5614
- }
5615
- const data = await response.json();
5616
- if (!data.choices?.[0]?.message?.content) {
5617
- throw new Error("OpenAI API returned no content");
5618
- }
5619
- return data.choices[0].message.content;
5620
- }
5621
-
5622
8668
  // src/generator/index.ts
5623
8669
  init_loader();
5624
8670
  init_schema();
@@ -5721,13 +8767,13 @@ function buildGenerateCommand() {
5721
8767
  const result = loadConfig2(options.config);
5722
8768
  configDir = result.configDir;
5723
8769
  } catch {
5724
- configDir = import_node_path19.default.join(process.cwd(), ".prowl");
8770
+ configDir = import_node_path22.default.join(process.cwd(), ".prowl");
5725
8771
  }
5726
- const huntsDir = import_node_path19.default.join(configDir, "hunts");
5727
- import_node_fs17.default.mkdirSync(huntsDir, { recursive: true });
8772
+ const huntsDir = import_node_path22.default.join(configDir, "hunts");
8773
+ import_node_fs20.default.mkdirSync(huntsDir, { recursive: true });
5728
8774
  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");
8775
+ const filePath = import_node_path22.default.join(huntsDir, fileName);
8776
+ import_node_fs20.default.writeFileSync(filePath, yamlStr + "\n", "utf-8");
5731
8777
  console.log(import_chalk12.default.green(` Saved to ${filePath}`));
5732
8778
  } else {
5733
8779
  console.log(yamlStr);
@@ -5924,7 +8970,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
5924
8970
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
5925
8971
 
5926
8972
  // src/mcp/tools.ts
5927
- var import_node_path20 = __toESM(require("path"), 1);
8973
+ var import_node_path23 = __toESM(require("path"), 1);
5928
8974
  init_loader();
5929
8975
  function listHuntsTool(configPath) {
5930
8976
  const { configDir } = loadConfig(configPath);
@@ -5932,7 +8978,7 @@ function listHuntsTool(configPath) {
5932
8978
  }
5933
8979
  async function runSuiteTool(args = {}, options = {}) {
5934
8980
  const { configPath: resolvedConfigPath, configDir, config } = loadConfig(options.configPath);
5935
- const projectRoot = options.projectRoot ?? import_node_path20.default.dirname(configDir);
8981
+ const projectRoot = options.projectRoot ?? import_node_path23.default.dirname(configDir);
5936
8982
  const suite = await runSuite({
5937
8983
  configPath: resolvedConfigPath,
5938
8984
  includeTags: args.includeTags,
@@ -5941,8 +8987,8 @@ async function runSuiteTool(args = {}, options = {}) {
5941
8987
  });
5942
8988
  const bugLogCfg = config.bugLog ?? {};
5943
8989
  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;
8990
+ const backlogPath = bugLogCfg.backlogPath ? import_node_path23.default.resolve(projectRoot, bugLogCfg.backlogPath) : void 0;
8991
+ 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
8992
  const bugs = logBugs ? updateBacklogFromSuite(suite, { projectRoot, backlogPath, resolvedPath }) : { created: [], regressions: [], skipped: [], backlogPath: null };
5947
8993
  const { status, totalHunts, passed, failed, skipped } = suite.result;
5948
8994
  return {
@@ -5969,9 +9015,9 @@ async function runHuntTool(args, configPath) {
5969
9015
  }
5970
9016
 
5971
9017
  // 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);
9018
+ var import_node_fs21 = __toESM(require("fs"), 1);
9019
+ var import_node_os3 = __toESM(require("os"), 1);
9020
+ var import_node_path24 = __toESM(require("path"), 1);
5975
9021
  var import_yaml3 = __toESM(require("yaml"), 1);
5976
9022
  var import_zod2 = require("zod");
5977
9023
  var projectEntrySchema = import_zod2.z.object({
@@ -5983,37 +9029,37 @@ var projectRegistrySchema = import_zod2.z.object({
5983
9029
  projects: import_zod2.z.record(import_zod2.z.string().min(1), projectEntrySchema)
5984
9030
  }).strict();
5985
9031
  function defaultRegistryPath() {
5986
- return import_node_path21.default.join(import_node_os.default.homedir(), ".prowl", "projects.yml");
9032
+ return import_node_path24.default.join(import_node_os3.default.homedir(), ".prowl", "projects.yml");
5987
9033
  }
5988
9034
  function legacyRegistryPath() {
5989
- return import_node_path21.default.join(import_node_os.default.homedir(), ".prowlqa", "projects.yml");
9035
+ return import_node_path24.default.join(import_node_os3.default.homedir(), ".prowlqa", "projects.yml");
5990
9036
  }
5991
9037
  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;
9038
+ const preferred = import_node_path24.default.join(root, ".prowl", "config.yml");
9039
+ if (import_node_fs21.default.existsSync(preferred)) return preferred;
9040
+ const legacy = import_node_path24.default.join(root, ".prowlqa", "config.yml");
9041
+ if (import_node_fs21.default.existsSync(legacy)) return legacy;
5996
9042
  return preferred;
5997
9043
  }
5998
9044
  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);
9045
+ return import_node_path24.default.isAbsolute(inputPath) ? inputPath : import_node_path24.default.resolve(import_node_path24.default.dirname(registry.registryPath), inputPath);
6000
9046
  }
6001
9047
  function resolveRegistryPath(explicitPath) {
6002
- if (explicitPath) return import_node_path21.default.resolve(explicitPath);
9048
+ if (explicitPath) return import_node_path24.default.resolve(explicitPath);
6003
9049
  const envPath = process.env.PROWL_PROJECTS ?? process.env.PROWLQA_PROJECTS;
6004
- if (envPath) return import_node_path21.default.resolve(envPath);
9050
+ if (envPath) return import_node_path24.default.resolve(envPath);
6005
9051
  const fallback = defaultRegistryPath();
6006
- if (import_node_fs18.default.existsSync(fallback)) return fallback;
9052
+ if (import_node_fs21.default.existsSync(fallback)) return fallback;
6007
9053
  const legacy = legacyRegistryPath();
6008
- return import_node_fs18.default.existsSync(legacy) ? legacy : null;
9054
+ return import_node_fs21.default.existsSync(legacy) ? legacy : null;
6009
9055
  }
6010
9056
  function loadProjectRegistry(explicitPath) {
6011
9057
  const registryPath = resolveRegistryPath(explicitPath);
6012
9058
  if (!registryPath) return null;
6013
- if (!import_node_fs18.default.existsSync(registryPath)) {
9059
+ if (!import_node_fs21.default.existsSync(registryPath)) {
6014
9060
  throw new Error(`Project registry not found at ${registryPath}`);
6015
9061
  }
6016
- const raw = import_node_fs18.default.readFileSync(registryPath, "utf-8");
9062
+ const raw = import_node_fs21.default.readFileSync(registryPath, "utf-8");
6017
9063
  const parsed = import_yaml3.default.parse(raw) ?? {};
6018
9064
  const validated = projectRegistrySchema.parse(parsed);
6019
9065
  return { projects: validated.projects, registryPath };