prowl-tools 0.1.5 → 0.1.7

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.
@@ -7,7 +7,7 @@ import {
7
7
  loadHunt,
8
8
  loadHuntTags,
9
9
  resolveViewport
10
- } from "./chunk-ITOSUJCN.js";
10
+ } from "./chunk-O3OUTZ2P.js";
11
11
 
12
12
  // src/config/interpolate.ts
13
13
  import crypto from "crypto";
@@ -303,6 +303,11 @@ function interpolateStep(step, vars, stepPath2, redacted) {
303
303
  }
304
304
  };
305
305
  }
306
+ if ("assertWithAI" in step) {
307
+ return {
308
+ assertWithAI: interpolateString(step.assertWithAI, vars).value
309
+ };
310
+ }
306
311
  if ("copyText" in step) {
307
312
  return {
308
313
  copyText: {
@@ -752,27 +757,71 @@ function createMacDriver(client, options = {}) {
752
757
  };
753
758
  }
754
759
 
760
+ // src/browser/macdriver-release.ts
761
+ import os from "os";
762
+ import path2 from "path";
763
+ var HELPER_BINARY = "prowl-macdriver";
764
+ var MACDRIVER_VERSION = "0.1.0";
765
+ var MACDRIVER_REPO = "prowl-tools/prowl";
766
+ var MACDRIVER_SIGNING_IDENTIFIER = "tools.prowl.macdriver";
767
+ var MACDRIVER_SIGNING_AUTHORITY_PREFIX = "Developer ID Application: Genkei Labs";
768
+ var MACDRIVER_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?$/;
769
+ function validateMacdriverVersion(version) {
770
+ if (!MACDRIVER_VERSION_PATTERN.test(version)) {
771
+ throw new Error(
772
+ `Invalid prowl-macdriver version "${version}". Expected a release version like 0.1.0.`
773
+ );
774
+ }
775
+ return version;
776
+ }
777
+ function macdriverReleaseTag(version = MACDRIVER_VERSION) {
778
+ return `macdriver-v${validateMacdriverVersion(version)}`;
779
+ }
780
+ function macdriverAssetName(version = MACDRIVER_VERSION) {
781
+ return `prowl-macdriver-v${validateMacdriverVersion(version)}-universal.zip`;
782
+ }
783
+ function macdriverChecksumName(version = MACDRIVER_VERSION) {
784
+ return `${macdriverAssetName(version)}.sha256`;
785
+ }
786
+ function macdriverAssetUrl(assetName, version = MACDRIVER_VERSION) {
787
+ return `https://github.com/${MACDRIVER_REPO}/releases/download/${macdriverReleaseTag(version)}/${assetName}`;
788
+ }
789
+ function macdriverInstallRoot(homedir = os.homedir()) {
790
+ return path2.join(homedir, ".prowl", "macdriver");
791
+ }
792
+ function macdriverVersionDir(version = MACDRIVER_VERSION, homedir = os.homedir()) {
793
+ const root = path2.resolve(macdriverInstallRoot(homedir));
794
+ const versionDir = path2.resolve(root, validateMacdriverVersion(version));
795
+ if (!versionDir.startsWith(root + path2.sep)) {
796
+ throw new Error(`Resolved prowl-macdriver version directory escaped install root: ${versionDir}`);
797
+ }
798
+ return versionDir;
799
+ }
800
+ function macdriverInstalledBinary(version = MACDRIVER_VERSION, homedir = os.homedir()) {
801
+ return path2.join(macdriverVersionDir(version, homedir), HELPER_BINARY);
802
+ }
803
+
755
804
  // src/browser/mac-helper.ts
756
805
  import { spawn } from "child_process";
757
806
  import fs2 from "fs";
758
- import path2 from "path";
807
+ import os2 from "os";
808
+ import path3 from "path";
759
809
  import { fileURLToPath } from "url";
760
- var HELPER_BINARY = "prowl-macdriver";
761
810
  function macdriverBuildInstructions() {
762
- return "The macOS target requires the experimental `prowl-macdriver` helper, which is not shipped in the npm package. Build it locally:\n cd macdriver && swift build -c release\nor point Prowl at a prebuilt binary via the PROWL_MACDRIVER_BIN environment variable.";
811
+ return "The macOS target needs the `prowl-macdriver` helper. Install the prebuilt, signed binary (recommended):\n prowl macdriver install\nContributors building from source can instead run:\n cd macdriver && swift build -c release\nor point Prowl at a prebuilt binary via the PROWL_MACDRIVER_BIN environment variable.";
763
812
  }
764
813
  function getPackageRoot() {
765
- let dir = path2.dirname(fileURLToPath(import.meta.url));
766
- const root = path2.parse(dir).root;
814
+ let dir = path3.dirname(fileURLToPath(import.meta.url));
815
+ const root = path3.parse(dir).root;
767
816
  while (dir !== root) {
768
- if (fs2.existsSync(path2.join(dir, "package.json"))) {
817
+ if (fs2.existsSync(path3.join(dir, "package.json"))) {
769
818
  return dir;
770
819
  }
771
- dir = path2.dirname(dir);
820
+ dir = path3.dirname(dir);
772
821
  }
773
822
  return root;
774
823
  }
775
- function resolveHelperBinary(env = process.env) {
824
+ function resolveHelperBinary(env = process.env, options = {}) {
776
825
  const override = env.PROWL_MACDRIVER_BIN;
777
826
  if (override) {
778
827
  if (!fs2.existsSync(override)) {
@@ -783,10 +832,15 @@ ${macdriverBuildInstructions()}`
783
832
  }
784
833
  return override;
785
834
  }
835
+ const homedir = options.homedir ?? os2.homedir();
836
+ const userBinary = macdriverInstalledBinary(MACDRIVER_VERSION, homedir);
837
+ if (fs2.existsSync(userBinary)) {
838
+ return userBinary;
839
+ }
786
840
  const root = getPackageRoot();
787
841
  const candidates = [
788
- path2.join(root, "macdriver", ".build", "release", HELPER_BINARY),
789
- path2.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
842
+ path3.join(root, "macdriver", ".build", "release", HELPER_BINARY),
843
+ path3.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
790
844
  ];
791
845
  for (const candidate of candidates) {
792
846
  if (fs2.existsSync(candidate)) {
@@ -958,6 +1012,287 @@ async function closeMacSession(session) {
958
1012
  }
959
1013
  }
960
1014
 
1015
+ // src/analyzer/xml.ts
1016
+ var ENTITIES = {
1017
+ amp: "&",
1018
+ lt: "<",
1019
+ gt: ">",
1020
+ quot: '"',
1021
+ apos: "'"
1022
+ };
1023
+ function decodeXmlEntities(value) {
1024
+ if (!value.includes("&")) {
1025
+ return value;
1026
+ }
1027
+ return value.replace(/&(#(?:[xX][0-9a-fA-F]+|[0-9]+)|[a-zA-Z]+);/g, (match, code) => {
1028
+ if (code[0] === "#") {
1029
+ const hex = code[1] === "x" || code[1] === "X";
1030
+ const num2 = Number.parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10);
1031
+ return Number.isSafeInteger(num2) && num2 <= 1114111 ? String.fromCodePoint(num2) : match;
1032
+ }
1033
+ const named = ENTITIES[code];
1034
+ return named ?? match;
1035
+ });
1036
+ }
1037
+ var ATTR_RE = /([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
1038
+ function parseTagBody(body) {
1039
+ const trimmed = body.trim();
1040
+ const nameMatch = /^([^\s/>]+)/.exec(trimmed);
1041
+ const tag = nameMatch ? nameMatch[1] : "";
1042
+ const attrs = {};
1043
+ const rest = trimmed.slice(tag.length);
1044
+ ATTR_RE.lastIndex = 0;
1045
+ let m;
1046
+ while ((m = ATTR_RE.exec(rest)) !== null) {
1047
+ const rawValue = m[3] !== void 0 ? m[3] : m[4] ?? "";
1048
+ attrs[m[1]] = decodeXmlEntities(rawValue);
1049
+ }
1050
+ return { tag, attrs };
1051
+ }
1052
+ function parseXml(input) {
1053
+ const n = input.length;
1054
+ const stack = [];
1055
+ let root = null;
1056
+ let i = 0;
1057
+ while (i < n) {
1058
+ const lt = input.indexOf("<", i);
1059
+ if (lt < 0) {
1060
+ break;
1061
+ }
1062
+ i = lt + 1;
1063
+ const ch = input[i];
1064
+ if (ch === "?") {
1065
+ const end = input.indexOf("?>", i);
1066
+ i = end < 0 ? n : end + 2;
1067
+ continue;
1068
+ }
1069
+ if (ch === "!") {
1070
+ if (input.startsWith("!--", i)) {
1071
+ const end = input.indexOf("-->", i);
1072
+ i = end < 0 ? n : end + 3;
1073
+ } else {
1074
+ const end = input.indexOf(">", i);
1075
+ i = end < 0 ? n : end + 1;
1076
+ }
1077
+ continue;
1078
+ }
1079
+ if (ch === "/") {
1080
+ const gt = input.indexOf(">", i);
1081
+ i = gt < 0 ? n : gt + 1;
1082
+ stack.pop();
1083
+ continue;
1084
+ }
1085
+ let j = i;
1086
+ let quote2 = null;
1087
+ while (j < n) {
1088
+ const c = input[j];
1089
+ if (quote2 !== null) {
1090
+ if (c === quote2) {
1091
+ quote2 = null;
1092
+ }
1093
+ } else if (c === '"' || c === "'") {
1094
+ quote2 = c;
1095
+ } else if (c === ">") {
1096
+ break;
1097
+ }
1098
+ j += 1;
1099
+ }
1100
+ const inner = input.slice(i, j);
1101
+ i = j + 1;
1102
+ const selfClose = inner.endsWith("/");
1103
+ const body = selfClose ? inner.slice(0, -1) : inner;
1104
+ const { tag, attrs } = parseTagBody(body);
1105
+ if (tag.length === 0) {
1106
+ continue;
1107
+ }
1108
+ const element = { tag, attrs, children: [] };
1109
+ const parent = stack[stack.length - 1];
1110
+ if (parent) {
1111
+ parent.children.push(element);
1112
+ }
1113
+ if (root === null) {
1114
+ root = element;
1115
+ }
1116
+ if (!selfClose) {
1117
+ stack.push(element);
1118
+ }
1119
+ }
1120
+ return root;
1121
+ }
1122
+
1123
+ // src/selector/native.ts
1124
+ function unquoteSelectorValue(value) {
1125
+ const trimmed = value.trim();
1126
+ const first = trimmed[0];
1127
+ if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
1128
+ return trimmed.slice(1, -1);
1129
+ }
1130
+ return trimmed;
1131
+ }
1132
+ function quoteSelectorValue(value) {
1133
+ return `"${value}"`;
1134
+ }
1135
+ var ROLE_SELECTOR_RE = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s;
1136
+ var NATIVE_SELECTOR_PREFIX_RE = /^(id|label|text|role)=/;
1137
+ function invalidSelectorMessage(selector, reason) {
1138
+ return `Invalid native selector ${JSON.stringify(selector)}: ${reason}.`;
1139
+ }
1140
+ function parseNativeSelector(selector) {
1141
+ const trimmed = selector.trim();
1142
+ if (trimmed.length === 0) {
1143
+ throw new Error(
1144
+ invalidSelectorMessage(
1145
+ selector,
1146
+ "selector is empty; use id=, label=, text=, role=, :focus, or a bare text value"
1147
+ )
1148
+ );
1149
+ }
1150
+ if (trimmed === ":focus") {
1151
+ return { kind: "focused" };
1152
+ }
1153
+ const idMatch = /^id=(.+)$/s.exec(trimmed);
1154
+ if (idMatch) {
1155
+ return { kind: "id", value: unquoteSelectorValue(idMatch[1]) };
1156
+ }
1157
+ const roleMatch = ROLE_SELECTOR_RE.exec(trimmed);
1158
+ if (roleMatch) {
1159
+ const name = roleMatch[2] !== void 0 ? unquoteSelectorValue(roleMatch[2]) : void 0;
1160
+ return name !== void 0 && name.length > 0 ? { kind: "role", role: roleMatch[1], name } : { kind: "role", role: roleMatch[1] };
1161
+ }
1162
+ const labelMatch = /^label=(.+)$/s.exec(trimmed);
1163
+ if (labelMatch) {
1164
+ return { kind: "label", value: unquoteSelectorValue(labelMatch[1]) };
1165
+ }
1166
+ const textMatch = /^text=(.+)$/s.exec(trimmed);
1167
+ if (textMatch) {
1168
+ return { kind: "text", value: unquoteSelectorValue(textMatch[1]) };
1169
+ }
1170
+ const prefix = NATIVE_SELECTOR_PREFIX_RE.exec(trimmed);
1171
+ if (prefix) {
1172
+ throw new Error(
1173
+ invalidSelectorMessage(
1174
+ selector,
1175
+ `malformed ${prefix[1]}= selector; expected id=<value>, label=<value>, text=<value>, or role=<Type>[name=<value>]`
1176
+ )
1177
+ );
1178
+ }
1179
+ return { kind: "text", value: trimmed };
1180
+ }
1181
+ function unwrapNativeTextSelector(selector) {
1182
+ const trimmed = selector.trim();
1183
+ if (!trimmed.startsWith("text=")) {
1184
+ return null;
1185
+ }
1186
+ return unquoteSelectorValue(trimmed.slice("text=".length));
1187
+ }
1188
+ function qualifyResourceId(value, appPackage) {
1189
+ if (value.includes(":") || !appPackage) {
1190
+ return value;
1191
+ }
1192
+ return `${appPackage}:id/${value}`;
1193
+ }
1194
+ function normalizeXcuiClassName(role) {
1195
+ return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
1196
+ }
1197
+ function shortIosType(type) {
1198
+ return type.startsWith("XCUIElementType") ? type.slice("XCUIElementType".length) : type;
1199
+ }
1200
+ function rankNativeSelectors(fields) {
1201
+ const selectors = [];
1202
+ if (fields.id) {
1203
+ selectors.push(`id=${fields.id}`);
1204
+ }
1205
+ if (fields.label) {
1206
+ selectors.push(`label=${quoteSelectorValue(fields.label)}`);
1207
+ }
1208
+ if (fields.role && fields.name) {
1209
+ selectors.push(`role=${fields.role}[name=${quoteSelectorValue(fields.name)}]`);
1210
+ }
1211
+ if (fields.name) {
1212
+ selectors.push(`text=${quoteSelectorValue(fields.name)}`);
1213
+ }
1214
+ if (selectors.length === 0 && fields.role) {
1215
+ selectors.push(`role=${fields.role}`);
1216
+ }
1217
+ return selectors;
1218
+ }
1219
+ var NATIVE_ATTRIBUTE_MAP = {
1220
+ android: {
1221
+ id: { attribute: "resource-id", match: "exact (package-qualified)" },
1222
+ label: { attribute: "content-desc", match: "exact" },
1223
+ text: { attribute: "text", match: "substring" },
1224
+ role: { attribute: "class", match: "exact" }
1225
+ },
1226
+ ios: {
1227
+ id: { attribute: "accessibility id (name)", match: "exact" },
1228
+ label: { attribute: "label", match: "exact" },
1229
+ text: { attribute: "label | value", match: "substring" },
1230
+ role: { attribute: "type (XCUIElementType\u2026)", match: "exact" }
1231
+ },
1232
+ macos: {
1233
+ id: { attribute: "AXIdentifier", match: "exact" },
1234
+ label: { attribute: "title | description", match: "exact" },
1235
+ text: { attribute: "title | description | value", match: "substring" },
1236
+ role: { attribute: "AXRole", match: "exact" }
1237
+ }
1238
+ };
1239
+ var ANDROID_MATCH_DIALECT = {
1240
+ platform: "android",
1241
+ normalizeRole: (role) => role,
1242
+ normalizeId: (value, options) => qualifyResourceId(value, options.appPackage)
1243
+ };
1244
+ var IOS_MATCH_DIALECT = {
1245
+ platform: "ios",
1246
+ normalizeRole: (role) => normalizeXcuiClassName(role),
1247
+ normalizeId: (value) => value
1248
+ };
1249
+ var MACOS_MATCH_DIALECT = {
1250
+ platform: "macos",
1251
+ normalizeRole: (role) => role,
1252
+ normalizeId: (value) => value
1253
+ };
1254
+ function nodeMatchesSelector(dialect, selector, node, options = {}) {
1255
+ switch (selector.kind) {
1256
+ case "id":
1257
+ return node.id !== void 0 && node.id === dialect.normalizeId(selector.value, options);
1258
+ case "label":
1259
+ return node.label !== void 0 && node.label === selector.value;
1260
+ case "text":
1261
+ return node.textValues.some((t) => t.includes(selector.value));
1262
+ case "focused":
1263
+ return node.focused === true;
1264
+ case "role": {
1265
+ if (node.role === void 0) {
1266
+ return false;
1267
+ }
1268
+ if (dialect.normalizeRole(node.role) !== dialect.normalizeRole(selector.role)) {
1269
+ return false;
1270
+ }
1271
+ if (selector.name === void 0 || selector.name.length === 0) {
1272
+ return true;
1273
+ }
1274
+ const name = selector.name;
1275
+ return node.textValues.some((t) => t.includes(name));
1276
+ }
1277
+ }
1278
+ }
1279
+ function matchNativeTree(dialect, selector, root, project, children, options = {}) {
1280
+ const out = [];
1281
+ const visit = (node) => {
1282
+ if (nodeMatchesSelector(dialect, selector, project(node), options)) {
1283
+ out.push(node);
1284
+ }
1285
+ for (const child of children(node)) {
1286
+ visit(child);
1287
+ }
1288
+ };
1289
+ visit(root);
1290
+ return out;
1291
+ }
1292
+ function parseSnapshot(xml) {
1293
+ return parseXml(xml);
1294
+ }
1295
+
961
1296
  // src/browser/android-driver.ts
962
1297
  import fs3 from "fs";
963
1298
  var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
@@ -993,50 +1328,29 @@ var ANDROID_KEYCODES = {
993
1328
  pageup: 92,
994
1329
  pagedown: 93
995
1330
  };
996
- function unquote2(value) {
997
- const trimmed = value.trim();
998
- const first = trimmed[0];
999
- if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
1000
- return trimmed.slice(1, -1);
1001
- }
1002
- return trimmed;
1003
- }
1004
1331
  function escapeUiSelectorArg(value) {
1005
1332
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1006
1333
  }
1007
- function parseAndroidSelector(selector) {
1008
- const trimmed = selector.trim();
1009
- if (trimmed === ":focus") {
1010
- return { by: "focused" };
1011
- }
1012
- const idMatch = /^id=(.+)$/s.exec(trimmed);
1013
- if (idMatch) {
1014
- return { by: "id", value: unquote2(idMatch[1]) };
1015
- }
1016
- const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
1017
- if (roleMatch) {
1018
- const name = roleMatch[2] !== void 0 ? unquote2(roleMatch[2]) : void 0;
1019
- return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
1020
- }
1021
- const labelMatch = /^label=(.+)$/s.exec(trimmed);
1022
- if (labelMatch) {
1023
- return { by: "accessibilityId", value: unquote2(labelMatch[1]) };
1024
- }
1025
- const textMatch = /^text=(.+)$/s.exec(trimmed);
1026
- if (textMatch) {
1027
- return { by: "text", value: unquote2(textMatch[1]) };
1334
+ function toAndroidQuery(selector) {
1335
+ switch (selector.kind) {
1336
+ case "focused":
1337
+ return { by: "focused" };
1338
+ case "id":
1339
+ return { by: "id", value: selector.value };
1340
+ case "role":
1341
+ return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
1342
+ case "label":
1343
+ return { by: "accessibilityId", value: selector.value };
1344
+ case "text":
1345
+ return { by: "text", value: selector.value };
1028
1346
  }
1029
- return { by: "text", value: trimmed };
1347
+ }
1348
+ function parseAndroidSelector(selector) {
1349
+ return toAndroidQuery(parseNativeSelector(selector));
1030
1350
  }
1031
1351
  function locator(strategy, selector) {
1032
1352
  return { strategy, selector, context: "" };
1033
1353
  }
1034
- function qualifyResourceId(value, appPackage) {
1035
- if (value.includes(":") || !appPackage) {
1036
- return value;
1037
- }
1038
- return `${appPackage}:id/${value}`;
1039
- }
1040
1354
  function androidQueryToLocator(query, options = {}) {
1041
1355
  switch (query.by) {
1042
1356
  case "id":
@@ -1063,11 +1377,7 @@ function androidQueryToLocator(query, options = {}) {
1063
1377
  }
1064
1378
  }
1065
1379
  function unwrapAndroidTextSelector(selector) {
1066
- const trimmed = selector.trim();
1067
- if (!trimmed.startsWith("text=")) {
1068
- return null;
1069
- }
1070
- return unquote2(trimmed.slice(5));
1380
+ return unwrapNativeTextSelector(selector);
1071
1381
  }
1072
1382
  function keyCodeFor(key) {
1073
1383
  const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
@@ -1342,22 +1652,63 @@ async function installApk(runner, serial, apkPath) {
1342
1652
  );
1343
1653
  }
1344
1654
  }
1655
+ function formatUnknownError(error) {
1656
+ return error instanceof Error ? error.message : String(error);
1657
+ }
1658
+ function runnerPhaseError(message, error) {
1659
+ return new Error(`${message}: ${formatUnknownError(error)}`, { cause: error });
1660
+ }
1661
+ function parseResolvedComponent(stdout, pkg) {
1662
+ const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
1663
+ for (let i = lines.length - 1; i >= 0; i--) {
1664
+ if (/^[\w.]+\/[\w.$]+$/.test(lines[i]) && lines[i].startsWith(`${pkg}/`)) {
1665
+ return lines[i];
1666
+ }
1667
+ }
1668
+ return null;
1669
+ }
1345
1670
  async function launchPackage(runner, serial, pkg) {
1346
- const result = await runner(
1347
- withSerial(serial, [
1348
- "shell",
1349
- "monkey",
1350
- "-p",
1351
- pkg,
1352
- "-c",
1353
- "android.intent.category.LAUNCHER",
1354
- "1"
1355
- ]),
1356
- { timeoutMs: 3e4 }
1357
- );
1358
- if (result.code !== 0 || /no activities found|aborted|cannot launch/i.test(result.stdout + result.stderr)) {
1671
+ let resolved;
1672
+ try {
1673
+ resolved = await runner(
1674
+ withSerial(serial, [
1675
+ "shell",
1676
+ "cmd",
1677
+ "package",
1678
+ "resolve-activity",
1679
+ "--brief",
1680
+ "-c",
1681
+ "android.intent.category.LAUNCHER",
1682
+ pkg
1683
+ ]),
1684
+ { timeoutMs: 3e4 }
1685
+ );
1686
+ } catch (error) {
1687
+ throw runnerPhaseError(
1688
+ `Failed to resolve the launcher activity for Android package "${pkg}" via adb`,
1689
+ error
1690
+ );
1691
+ }
1692
+ const component = parseResolvedComponent(resolved.stdout, pkg);
1693
+ if (resolved.code !== 0 || !component) {
1694
+ throw new Error(
1695
+ `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?`
1696
+ );
1697
+ }
1698
+ let start;
1699
+ try {
1700
+ start = await runner(withSerial(serial, ["shell", "am", "start", "-n", component]), {
1701
+ timeoutMs: 3e4
1702
+ });
1703
+ } catch (error) {
1704
+ throw runnerPhaseError(
1705
+ `Failed to launch Android package "${pkg}" with \`am start\` (${component}) via adb`,
1706
+ error
1707
+ );
1708
+ }
1709
+ if (start.code !== 0 || /error|does not exist|cannot start/i.test(start.stdout + start.stderr)) {
1359
1710
  throw new Error(
1360
- `Failed to launch Android package "${pkg}" (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 400)}. Is it installed?`
1711
+ `Failed to launch Android package "${pkg}" (${component}, exit ${start.code}): ${(start.stdout + start.stderr).trim().slice(0, 400)}.`
1361
1712
  );
1362
1713
  }
1363
1714
  }
@@ -1419,12 +1770,12 @@ var Uia2Transport = class {
1419
1770
  * {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
1420
1771
  * per-request deadline elapses.
1421
1772
  */
1422
- async request(method, path16, body, timeoutMs) {
1773
+ async request(method, path18, body, timeoutMs) {
1423
1774
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
1424
1775
  const controller = new AbortController();
1425
1776
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
1426
1777
  timer.unref?.();
1427
- const url = `${this.baseUrl}${path16}`;
1778
+ const url = `${this.baseUrl}${path18}`;
1428
1779
  let response;
1429
1780
  try {
1430
1781
  response = await this.fetchImpl(url, {
@@ -1436,7 +1787,7 @@ var Uia2Transport = class {
1436
1787
  } catch (error) {
1437
1788
  if (controller.signal.aborted) {
1438
1789
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
1439
- throw new Error(`uiautomator2 request ${method} ${path16} timed out after ${shown}`);
1790
+ throw new Error(`uiautomator2 request ${method} ${path18} timed out after ${shown}`);
1440
1791
  }
1441
1792
  throw error instanceof Error ? error : new Error(String(error));
1442
1793
  } finally {
@@ -1447,7 +1798,7 @@ var Uia2Transport = class {
1447
1798
  if (!response.ok) {
1448
1799
  const wdError = extractWebdriverError(parsed);
1449
1800
  throw new Uia2HttpError(
1450
- `uiautomator2 ${method} ${path16} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
1801
+ `uiautomator2 ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
1451
1802
  response.status,
1452
1803
  wdError
1453
1804
  );
@@ -1530,10 +1881,10 @@ function sleep(ms) {
1530
1881
  }
1531
1882
  function createUia2AgentClient(transport, sessionId, options = {}) {
1532
1883
  const base = `/session/${sessionId}`;
1533
- async function locate(query, path16) {
1884
+ async function locate(query, path18) {
1534
1885
  return transport.request(
1535
1886
  "POST",
1536
- `${base}${path16}`,
1887
+ `${base}${path18}`,
1537
1888
  androidQueryToLocator(query, { appPackage: options.appPackage })
1538
1889
  );
1539
1890
  }
@@ -1575,6 +1926,13 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
1575
1926
  }
1576
1927
  return Buffer.from(value, "base64");
1577
1928
  },
1929
+ async source() {
1930
+ const value = await transport.request("GET", `${base}/source`);
1931
+ if (typeof value !== "string") {
1932
+ throw new Error("uiautomator2 /source did not return XML text; cannot analyze Android UI hierarchy");
1933
+ }
1934
+ return value;
1935
+ },
1578
1936
  async close() {
1579
1937
  await transport.request("DELETE", base).catch(() => void 0);
1580
1938
  }
@@ -1585,7 +1943,7 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
1585
1943
  import { createRequire } from "module";
1586
1944
  import { execFile as execFile2 } from "child_process";
1587
1945
  import fs4 from "fs";
1588
- import path3 from "path";
1946
+ import path4 from "path";
1589
1947
  var UIA2_REMOTE_PORT = 6790;
1590
1948
  function resolveAgentApks(requireFn = createRequire(import.meta.url)) {
1591
1949
  let pkgJsonPath;
@@ -1596,10 +1954,10 @@ function resolveAgentApks(requireFn = createRequire(import.meta.url)) {
1596
1954
  "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"
1597
1955
  );
1598
1956
  }
1599
- const pkgDir = path3.dirname(pkgJsonPath);
1957
+ const pkgDir = path4.dirname(pkgJsonPath);
1600
1958
  const version = requireFn(pkgJsonPath).version;
1601
- const serverApk = path3.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
1602
- const testApk = path3.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
1959
+ const serverApk = path4.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
1960
+ const testApk = path4.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
1603
1961
  for (const apk of [serverApk, testApk]) {
1604
1962
  if (!fs4.existsSync(apk)) {
1605
1963
  throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
@@ -1647,7 +2005,7 @@ async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
1647
2005
  assertAndroidAppAllowed(allowedApps, app);
1648
2006
  return app;
1649
2007
  }
1650
- const apkPath = path3.resolve(app);
2008
+ const apkPath = path4.resolve(app);
1651
2009
  if (!fs4.existsSync(apkPath)) {
1652
2010
  throw new Error(`APK not found: ${apkPath}`);
1653
2011
  }
@@ -1729,43 +2087,25 @@ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
1729
2087
  var WAIT_POLL_INTERVAL_MS2 = 250;
1730
2088
  var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
1731
2089
  var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
1732
- function unquote3(value) {
1733
- const trimmed = value.trim();
1734
- const first = trimmed[0];
1735
- if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
1736
- return trimmed.slice(1, -1);
1737
- }
1738
- return trimmed;
1739
- }
1740
2090
  function escapePredicateArg(value) {
1741
2091
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1742
2092
  }
1743
- function normalizeXcuiClassName(role) {
1744
- return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
2093
+ function toIosQuery(selector) {
2094
+ switch (selector.kind) {
2095
+ case "focused":
2096
+ return { by: "focused" };
2097
+ case "id":
2098
+ return { by: "accessibilityId", value: selector.value };
2099
+ case "role":
2100
+ return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
2101
+ case "label":
2102
+ return { by: "label", value: selector.value };
2103
+ case "text":
2104
+ return { by: "text", value: selector.value };
2105
+ }
1745
2106
  }
1746
2107
  function parseIosSelector(selector) {
1747
- const trimmed = selector.trim();
1748
- if (trimmed === ":focus") {
1749
- return { by: "focused" };
1750
- }
1751
- const idMatch = /^id=(.+)$/s.exec(trimmed);
1752
- if (idMatch) {
1753
- return { by: "accessibilityId", value: unquote3(idMatch[1]) };
1754
- }
1755
- const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
1756
- if (roleMatch) {
1757
- const name = roleMatch[2] !== void 0 ? unquote3(roleMatch[2]) : void 0;
1758
- return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
1759
- }
1760
- const labelMatch = /^label=(.+)$/s.exec(trimmed);
1761
- if (labelMatch) {
1762
- return { by: "label", value: unquote3(labelMatch[1]) };
1763
- }
1764
- const textMatch = /^text=(.+)$/s.exec(trimmed);
1765
- if (textMatch) {
1766
- return { by: "text", value: unquote3(textMatch[1]) };
1767
- }
1768
- return { by: "text", value: trimmed };
2108
+ return toIosQuery(parseNativeSelector(selector));
1769
2109
  }
1770
2110
  function iosQueryToLocator(query) {
1771
2111
  switch (query.by) {
@@ -1797,11 +2137,7 @@ function iosQueryToLocator(query) {
1797
2137
  }
1798
2138
  }
1799
2139
  function unwrapIosTextSelector(selector) {
1800
- const trimmed = selector.trim();
1801
- if (!trimmed.startsWith("text=")) {
1802
- return null;
1803
- }
1804
- return unquote3(trimmed.slice(5));
2140
+ return unwrapNativeTextSelector(selector);
1805
2141
  }
1806
2142
  function delay2(ms) {
1807
2143
  return new Promise((resolve) => {
@@ -1960,12 +2296,25 @@ function createIosDriver(client, options) {
1960
2296
  }
1961
2297
 
1962
2298
  // src/browser/ios-simctl.ts
1963
- import { execFile as execFile3 } from "child_process";
2299
+ import { execFile as execFile3, spawn as spawn3 } from "child_process";
1964
2300
  import { mkdir, readFile, rm, writeFile } from "fs/promises";
1965
2301
  import net from "net";
1966
- import os from "os";
1967
- import path4 from "path";
1968
- var DEFAULT_SIMULATOR_LOCK_ROOT = path4.join(os.tmpdir(), "prowl-ios-simulator-locks");
2302
+ import os3 from "os";
2303
+ import path5 from "path";
2304
+ var spawnXcrunProcess = (args, options) => {
2305
+ const child = spawn3("xcrun", args, {
2306
+ stdio: "ignore",
2307
+ env: options?.env ? { ...process.env, ...options.env } : process.env
2308
+ });
2309
+ child.on("error", () => {
2310
+ });
2311
+ return {
2312
+ kill: () => {
2313
+ child.kill();
2314
+ }
2315
+ };
2316
+ };
2317
+ var DEFAULT_SIMULATOR_LOCK_ROOT = path5.join(os3.tmpdir(), "prowl-ios-simulator-locks");
1969
2318
  var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
1970
2319
  var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
1971
2320
  execFile3(
@@ -2005,7 +2354,7 @@ function isProcessAlive(pid) {
2005
2354
  }
2006
2355
  async function removeStaleSimulatorLock(lockPath) {
2007
2356
  try {
2008
- const ownerText = await readFile(path4.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
2357
+ const ownerText = await readFile(path5.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
2009
2358
  const owner = JSON.parse(ownerText);
2010
2359
  if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
2011
2360
  await rm(lockPath, { recursive: true, force: true });
@@ -2023,7 +2372,7 @@ function simulatorReservedError(udid) {
2023
2372
  }
2024
2373
  async function reserveSimulatorUdid(udid, options = {}) {
2025
2374
  const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
2026
- const lockPath = path4.join(lockRoot, simulatorLockName(udid));
2375
+ const lockPath = path5.join(lockRoot, simulatorLockName(udid));
2027
2376
  await mkdir(lockRoot, { recursive: true });
2028
2377
  for (let attempt = 0; attempt < 2; attempt += 1) {
2029
2378
  try {
@@ -2047,7 +2396,7 @@ async function reserveSimulatorUdid(udid, options = {}) {
2047
2396
  };
2048
2397
  try {
2049
2398
  await writeFile(
2050
- path4.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
2399
+ path5.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
2051
2400
  `${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
2052
2401
  `,
2053
2402
  { flag: "wx" }
@@ -2239,15 +2588,15 @@ var WdaTransport = class {
2239
2588
  * {@link WdaHttpError} on a non-2xx response, or a timeout error when the
2240
2589
  * per-request deadline elapses.
2241
2590
  */
2242
- async requestFull(method, path16, body, timeoutMs) {
2591
+ async requestFull(method, path18, body, timeoutMs) {
2243
2592
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
2244
2593
  const controller = new AbortController();
2245
2594
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
2246
2595
  timer.unref?.();
2247
- const url = `${this.baseUrl}${path16}`;
2596
+ const url = `${this.baseUrl}${path18}`;
2248
2597
  const timeoutError = () => {
2249
2598
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
2250
- return new Error(`WebDriverAgent request ${method} ${path16} timed out after ${shown}`);
2599
+ return new Error(`WebDriverAgent request ${method} ${path18} timed out after ${shown}`);
2251
2600
  };
2252
2601
  let response;
2253
2602
  try {
@@ -2279,7 +2628,7 @@ var WdaTransport = class {
2279
2628
  if (!response.ok) {
2280
2629
  const wdError = extractWebdriverError2(parsed);
2281
2630
  throw new WdaHttpError(
2282
- `WebDriverAgent ${method} ${path16} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2631
+ `WebDriverAgent ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2283
2632
  response.status,
2284
2633
  wdError
2285
2634
  );
@@ -2287,8 +2636,8 @@ var WdaTransport = class {
2287
2636
  return parsed;
2288
2637
  }
2289
2638
  /** Like {@link requestFull} but returns just the `value` field. */
2290
- async request(method, path16, body, timeoutMs) {
2291
- const parsed = await this.requestFull(method, path16, body, timeoutMs);
2639
+ async request(method, path18, body, timeoutMs) {
2640
+ const parsed = await this.requestFull(method, path18, body, timeoutMs);
2292
2641
  return parsed?.value;
2293
2642
  }
2294
2643
  };
@@ -2364,8 +2713,8 @@ function sleep2(ms) {
2364
2713
  }
2365
2714
  function createWdaAgentClient(transport, sessionId) {
2366
2715
  const base = `/session/${sessionId}`;
2367
- async function locate(query, path16) {
2368
- return transport.request("POST", `${base}${path16}`, iosQueryToLocator(query));
2716
+ async function locate(query, path18) {
2717
+ return transport.request("POST", `${base}${path18}`, iosQueryToLocator(query));
2369
2718
  }
2370
2719
  return {
2371
2720
  async findElement(query) {
@@ -2401,6 +2750,13 @@ function createWdaAgentClient(transport, sessionId) {
2401
2750
  async homescreen() {
2402
2751
  await transport.request("POST", "/wda/homescreen", {});
2403
2752
  },
2753
+ async source() {
2754
+ const value = await transport.request("GET", "/source");
2755
+ if (typeof value !== "string") {
2756
+ throw new Error("WebDriverAgent /source did not return XML text; cannot analyze iOS UI hierarchy");
2757
+ }
2758
+ return value;
2759
+ },
2404
2760
  async close() {
2405
2761
  await transport.request("DELETE", base).catch(() => void 0);
2406
2762
  }
@@ -2409,12 +2765,13 @@ function createWdaAgentClient(transport, sessionId) {
2409
2765
 
2410
2766
  // src/browser/ios-helper.ts
2411
2767
  import { createRequire as createRequire2 } from "module";
2768
+ import { execFile as execFile4 } from "child_process";
2412
2769
  import fs5 from "fs";
2413
- import os2 from "os";
2414
- import path5 from "path";
2770
+ import os4 from "os";
2771
+ import path6 from "path";
2415
2772
  var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
2416
- var WDA_RUNNER_APP_NAME = "WebDriverAgentRunner-Runner.app";
2417
2773
  var WDA_USE_PORT_ENV = "USE_PORT";
2774
+ var PREPARED_XCTESTRUN_PREFIX = "prowl-wda-xctestrun-";
2418
2775
  var WDA_STARTUP_ATTEMPTS = 2;
2419
2776
  var defaultIosAgentConnector = async ({
2420
2777
  host,
@@ -2437,32 +2794,63 @@ function resolveWdaProject(requireFn = createRequire2(import.meta.url)) {
2437
2794
  "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"
2438
2795
  );
2439
2796
  }
2440
- const pkgDir = path5.dirname(pkgJsonPath);
2797
+ const pkgDir = path6.dirname(pkgJsonPath);
2441
2798
  const version = requireFn(pkgJsonPath).version;
2442
- const projectPath = path5.join(pkgDir, "WebDriverAgent.xcodeproj");
2799
+ const projectPath = path6.join(pkgDir, "WebDriverAgent.xcodeproj");
2443
2800
  if (!fs5.existsSync(projectPath)) {
2444
2801
  throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
2445
2802
  }
2446
2803
  return { projectPath, version };
2447
2804
  }
2448
- function wdaCacheDir(wdaVersion, xcode, homeDir = os2.homedir()) {
2449
- return path5.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
2805
+ function wdaCacheDir(wdaVersion, xcode, homeDir = os4.homedir()) {
2806
+ return path6.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
2450
2807
  }
2451
- function runnerAppPath(derivedDataPath) {
2452
- return path5.join(derivedDataPath, "Build", "Products", "Debug-iphonesimulator", WDA_RUNNER_APP_NAME);
2808
+ function productsDir(derivedDataPath) {
2809
+ return path6.join(derivedDataPath, "Build", "Products");
2453
2810
  }
2454
- async function resolveWdaRunner(options = {}) {
2455
- const runner = options.runner ?? execFileXcrunRunner;
2456
- const env = options.env ?? process.env;
2457
- const homeDir = options.homeDir ?? os2.homedir();
2458
- const log = options.logger ?? ((message) => process.stderr.write(`${message}
2459
- `));
2811
+ function findXctestrunIn(dir) {
2812
+ let entries;
2813
+ try {
2814
+ entries = fs5.readdirSync(dir);
2815
+ } catch {
2816
+ return null;
2817
+ }
2818
+ const match = entries.filter((name) => name.endsWith(".xctestrun") && !name.startsWith(PREPARED_XCTESTRUN_PREFIX)).sort()[0];
2819
+ return match ? path6.join(dir, match) : null;
2820
+ }
2821
+ function resolveOverrideXctestrun(override) {
2822
+ if (!fs5.existsSync(override)) {
2823
+ throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
2824
+ }
2825
+ if (override.endsWith(".xctestrun")) {
2826
+ return override;
2827
+ }
2828
+ const candidates = [];
2829
+ const stat = fs5.statSync(override);
2830
+ if (override.endsWith(".app")) {
2831
+ candidates.push(path6.dirname(path6.dirname(override)));
2832
+ } else if (stat.isDirectory()) {
2833
+ candidates.push(override, productsDir(override));
2834
+ }
2835
+ for (const dir of candidates) {
2836
+ const found = findXctestrunIn(dir);
2837
+ if (found) {
2838
+ return found;
2839
+ }
2840
+ }
2841
+ throw new Error(
2842
+ `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\`.`
2843
+ );
2844
+ }
2845
+ async function resolveWdaTestRun(options = {}) {
2846
+ const runner = options.runner ?? execFileXcrunRunner;
2847
+ const env = options.env ?? process.env;
2848
+ const homeDir = options.homeDir ?? os4.homedir();
2849
+ const log = options.logger ?? ((message) => process.stderr.write(`${message}
2850
+ `));
2460
2851
  const override = env.PROWL_WDA_RUNNER;
2461
2852
  if (override) {
2462
- if (!fs5.existsSync(override)) {
2463
- throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
2464
- }
2465
- return override;
2853
+ return resolveOverrideXctestrun(override);
2466
2854
  }
2467
2855
  const { projectPath, version } = resolveWdaProject(options.requireFn);
2468
2856
  const xcode = await xcodeVersion(runner);
@@ -2472,9 +2860,9 @@ async function resolveWdaRunner(options = {}) {
2472
2860
  );
2473
2861
  }
2474
2862
  const cacheDir = wdaCacheDir(version, xcode, homeDir);
2475
- const cachedRunner = runnerAppPath(cacheDir);
2476
- if (fs5.existsSync(cachedRunner)) {
2477
- return cachedRunner;
2863
+ const cached = findXctestrunIn(productsDir(cacheDir));
2864
+ if (cached) {
2865
+ return cached;
2478
2866
  }
2479
2867
  log(
2480
2868
  `Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
@@ -2501,12 +2889,106 @@ async function resolveWdaRunner(options = {}) {
2501
2889
  `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)}`
2502
2890
  );
2503
2891
  }
2504
- if (!fs5.existsSync(cachedRunner)) {
2892
+ const built = findXctestrunIn(productsDir(cacheDir));
2893
+ if (!built) {
2894
+ throw new Error(
2895
+ `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.`
2896
+ );
2897
+ }
2898
+ return built;
2899
+ }
2900
+ function injectUsePortIntoXctestrun(plist, port) {
2901
+ const clone = structuredClone(plist);
2902
+ let injected = 0;
2903
+ const visit = (node) => {
2904
+ if (Array.isArray(node)) {
2905
+ for (const entry of node) {
2906
+ visit(entry);
2907
+ }
2908
+ return;
2909
+ }
2910
+ if (!node || typeof node !== "object") {
2911
+ return;
2912
+ }
2913
+ const obj = node;
2914
+ const isTarget = typeof obj.TestBundlePath === "string" || typeof obj.TestHostPath === "string";
2915
+ const existingEnv = obj.EnvironmentVariables && typeof obj.EnvironmentVariables === "object" && !Array.isArray(obj.EnvironmentVariables) ? obj.EnvironmentVariables : void 0;
2916
+ if (isTarget || existingEnv) {
2917
+ const env = existingEnv ?? {};
2918
+ env[WDA_USE_PORT_ENV] = String(port);
2919
+ obj.EnvironmentVariables = env;
2920
+ injected += 1;
2921
+ }
2922
+ for (const value of Object.values(obj)) {
2923
+ visit(value);
2924
+ }
2925
+ };
2926
+ visit(clone);
2927
+ if (injected === 0) {
2505
2928
  throw new Error(
2506
- `WebDriverAgent build succeeded but the runner app was not found at ${cachedRunner}. This may indicate an Xcode layout change; set PROWL_WDA_RUNNER to a prebuilt runner.`
2929
+ "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."
2507
2930
  );
2508
2931
  }
2509
- return cachedRunner;
2932
+ return clone;
2933
+ }
2934
+ function runPlutil(args) {
2935
+ return new Promise((resolve, reject) => {
2936
+ execFile4(
2937
+ "plutil",
2938
+ args,
2939
+ { encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 },
2940
+ (error, stdout, stderr) => {
2941
+ if (error) {
2942
+ reject(
2943
+ new Error(
2944
+ `\`plutil ${args.join(" ")}\` failed: ${(stderr || error.message).slice(0, 400)}`
2945
+ )
2946
+ );
2947
+ return;
2948
+ }
2949
+ resolve(stdout ?? "");
2950
+ }
2951
+ );
2952
+ });
2953
+ }
2954
+ var defaultWdaTestRunPreparer = async ({ xctestrunPath, port }) => {
2955
+ const json = await runPlutil(["-convert", "json", "-o", "-", xctestrunPath]);
2956
+ let parsed;
2957
+ try {
2958
+ parsed = JSON.parse(json);
2959
+ } catch {
2960
+ throw new Error(`Could not parse the WebDriverAgent .xctestrun as JSON: ${xctestrunPath}`);
2961
+ }
2962
+ const injected = injectUsePortIntoXctestrun(parsed, port);
2963
+ const stem = `${PREPARED_XCTESTRUN_PREFIX}${port}-${process.pid}`;
2964
+ const outPath = path6.join(path6.dirname(xctestrunPath), `${stem}.xctestrun`);
2965
+ const jsonPath = path6.join(os4.tmpdir(), `${stem}.json`);
2966
+ fs5.writeFileSync(jsonPath, JSON.stringify(injected));
2967
+ try {
2968
+ await runPlutil(["-convert", "xml1", jsonPath, "-o", outPath]);
2969
+ } finally {
2970
+ fs5.rmSync(jsonPath, { force: true });
2971
+ }
2972
+ return outPath;
2973
+ };
2974
+ function cleanupPreparedTestRun(preparedPath) {
2975
+ if (!preparedPath) {
2976
+ return;
2977
+ }
2978
+ if (!path6.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
2979
+ return;
2980
+ }
2981
+ fs5.rmSync(preparedPath, { force: true });
2982
+ }
2983
+ function wdaTestRunArgs(xctestrunPath, udid) {
2984
+ return [
2985
+ "xcodebuild",
2986
+ "test-without-building",
2987
+ "-xctestrun",
2988
+ xctestrunPath,
2989
+ "-destination",
2990
+ `id=${udid}`
2991
+ ];
2510
2992
  }
2511
2993
  async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
2512
2994
  if (!looksLikeIosAppPath(app)) {
@@ -2518,7 +3000,7 @@ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
2518
3000
  }
2519
3001
  return app;
2520
3002
  }
2521
- const appPath = path5.resolve(app);
3003
+ const appPath = path6.resolve(app);
2522
3004
  if (!fs5.existsSync(appPath)) {
2523
3005
  throw new Error(`.app bundle not found: ${appPath}`);
2524
3006
  }
@@ -2537,6 +3019,8 @@ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
2537
3019
  }
2538
3020
  async function launchIosSession(options) {
2539
3021
  const runner = options.runner ?? execFileXcrunRunner;
3022
+ const spawner = options.spawner ?? spawnXcrunProcess;
3023
+ const preparer = options.testRunPreparer ?? defaultWdaTestRunPreparer;
2540
3024
  const portAllocator = options.portAllocator ?? findFreePort;
2541
3025
  const connector = options.agentConnector ?? defaultIosAgentConnector;
2542
3026
  const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
@@ -2560,14 +3044,14 @@ async function launchIosSession(options) {
2560
3044
  options.coldStart ?? false,
2561
3045
  options.allowedApps ?? []
2562
3046
  );
2563
- const wdaRunnerApp = options.wdaRunnerApp ?? await resolveWdaRunner({ runner, logger: options.logger });
2564
- await installApp(runner, udid, wdaRunnerApp);
3047
+ const baseTestRun = options.wdaTestRun ?? await resolveWdaTestRun({ runner, logger: options.logger });
2565
3048
  for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
2566
3049
  let client;
3050
+ let wdaProcess;
3051
+ let preparedTestRun;
2567
3052
  let tornDown = false;
2568
- let terminateWda = false;
2569
3053
  let terminateTarget = false;
2570
- let stage = "port";
3054
+ let stage = "prepare";
2571
3055
  const teardownAttempt = async () => {
2572
3056
  if (tornDown) {
2573
3057
  return;
@@ -2576,18 +3060,18 @@ async function launchIosSession(options) {
2576
3060
  if (client) {
2577
3061
  await client.close().catch(() => void 0);
2578
3062
  }
2579
- if (terminateWda) {
2580
- await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
2581
- }
3063
+ wdaProcess?.kill();
3064
+ await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
2582
3065
  if (terminateTarget) {
2583
3066
  await terminateApp(runner, udid, bundleId);
2584
3067
  }
3068
+ cleanupPreparedTestRun(preparedTestRun);
2585
3069
  };
2586
3070
  try {
2587
3071
  const port = await portAllocator();
3072
+ preparedTestRun = await preparer({ xctestrunPath: baseTestRun, port });
2588
3073
  stage = "wda-launch";
2589
- terminateWda = true;
2590
- await launchApp(runner, udid, WDA_RUNNER_BUNDLE_ID, { [WDA_USE_PORT_ENV]: String(port) });
3074
+ wdaProcess = spawner(wdaTestRunArgs(preparedTestRun, udid));
2591
3075
  stage = "target-launch";
2592
3076
  terminateTarget = true;
2593
3077
  await launchApp(runner, udid, bundleId);
@@ -2675,14 +3159,14 @@ async function healSelector(probe, selector, options) {
2675
3159
 
2676
3160
  // src/runner/history.ts
2677
3161
  import fs6 from "fs";
2678
- import path6 from "path";
3162
+ import path7 from "path";
2679
3163
  var HISTORY_FILE = "history.json";
2680
3164
  var LOCK_FILE_SUFFIX = ".lock";
2681
3165
  var LOCK_RETRY_MS = 10;
2682
3166
  var LOCK_TIMEOUT_MS = 5e3;
2683
3167
  var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
2684
3168
  function historyPath(configDir) {
2685
- return path6.join(configDir, HISTORY_FILE);
3169
+ return path7.join(configDir, HISTORY_FILE);
2686
3170
  }
2687
3171
  function isHistoryEntry(value) {
2688
3172
  if (!value || typeof value !== "object") {
@@ -2736,7 +3220,7 @@ function sleepSync(ms) {
2736
3220
  function withHistoryLock(configDir, fn) {
2737
3221
  const filePath = historyPath(configDir);
2738
3222
  const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
2739
- fs6.mkdirSync(path6.dirname(filePath), { recursive: true });
3223
+ fs6.mkdirSync(path7.dirname(filePath), { recursive: true });
2740
3224
  const startedAt = Date.now();
2741
3225
  while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
2742
3226
  let fd;
@@ -2777,11 +3261,11 @@ function appendEntry(configDir, entry, maxRuns) {
2777
3261
 
2778
3262
  // src/runner/index.ts
2779
3263
  import fs12 from "fs";
2780
- import path12 from "path";
3264
+ import path13 from "path";
2781
3265
 
2782
3266
  // src/browser/playwright-driver.ts
2783
3267
  import fs7 from "fs";
2784
- import path7 from "path";
3268
+ import path8 from "path";
2785
3269
  import {
2786
3270
  chromium,
2787
3271
  firefox,
@@ -2814,7 +3298,7 @@ async function launchBrowser(options) {
2814
3298
  }
2815
3299
  }
2816
3300
  if (options.recordHar) {
2817
- contextOptions.recordHar = { path: path7.join(options.runDir, "network.har") };
3301
+ contextOptions.recordHar = { path: path8.join(options.runDir, "network.har") };
2818
3302
  }
2819
3303
  const context = await browser.newContext(contextOptions);
2820
3304
  const page = await context.newPage();
@@ -2822,7 +3306,7 @@ async function launchBrowser(options) {
2822
3306
  page.setDefaultNavigationTimeout(options.timeout);
2823
3307
  let tracePath;
2824
3308
  if (options.trace) {
2825
- tracePath = path7.join(options.runDir, "trace.zip");
3309
+ tracePath = path8.join(options.runDir, "trace.zip");
2826
3310
  await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
2827
3311
  }
2828
3312
  return { browser, context, page, tracePath };
@@ -3001,7 +3485,7 @@ function createPlaywrightDriver(page) {
3001
3485
 
3002
3486
  // src/runner/steps.ts
3003
3487
  import fs8 from "fs";
3004
- import path8 from "path";
3488
+ import path9 from "path";
3005
3489
 
3006
3490
  // src/runner/policy.ts
3007
3491
  var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
@@ -3100,6 +3584,246 @@ function createRunPolicy(driver, options) {
3100
3584
  };
3101
3585
  }
3102
3586
 
3587
+ // src/generator/ai.ts
3588
+ var DEFAULT_BASE_URL = {
3589
+ anthropic: "https://api.anthropic.com",
3590
+ openai: "https://api.openai.com"
3591
+ };
3592
+ var AI_REQUEST_TIMEOUT_MS = 3e4;
3593
+ function apiRoot(config) {
3594
+ return config.baseUrl ?? DEFAULT_BASE_URL[config.provider];
3595
+ }
3596
+ function defaultModelFor(provider) {
3597
+ return provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
3598
+ }
3599
+ function resolveAiEnv() {
3600
+ const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
3601
+ if (provider !== "anthropic" && provider !== "openai") {
3602
+ throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
3603
+ }
3604
+ const model = process.env.PROWL_AI_MODEL ?? defaultModelFor(provider);
3605
+ const baseUrl = normalizeBaseUrl(process.env.PROWL_AI_BASE_URL) ?? DEFAULT_BASE_URL[provider];
3606
+ return { provider, model, baseUrl, apiKey: process.env.PROWL_AI_KEY };
3607
+ }
3608
+ function normalizeBaseUrl(value) {
3609
+ if (value === void 0) return void 0;
3610
+ const trimmed = value.trim().replace(/\/+$/, "");
3611
+ return trimmed.length > 0 ? trimmed : void 0;
3612
+ }
3613
+ function resolveAiConfig() {
3614
+ const env = resolveAiEnv();
3615
+ if (!env.apiKey) {
3616
+ throw new Error(
3617
+ "PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
3618
+ );
3619
+ }
3620
+ return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
3621
+ }
3622
+ function tryResolveAiConfig() {
3623
+ const env = resolveAiEnv();
3624
+ if (!env.apiKey) {
3625
+ return null;
3626
+ }
3627
+ return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
3628
+ }
3629
+ async function generateWithAi(prompt, config) {
3630
+ if (config.provider === "anthropic") {
3631
+ return generateWithAnthropic(prompt, config);
3632
+ }
3633
+ return generateWithOpenAi(prompt, config);
3634
+ }
3635
+ async function generateWithAnthropic(prompt, config) {
3636
+ const data = await postJson(
3637
+ "Anthropic",
3638
+ `${apiRoot(config)}/v1/messages`,
3639
+ anthropicHeaders(config),
3640
+ {
3641
+ model: config.model,
3642
+ max_tokens: 4096,
3643
+ messages: [
3644
+ { role: "user", content: prompt }
3645
+ ]
3646
+ }
3647
+ );
3648
+ return extractAnthropicText(data);
3649
+ }
3650
+ async function generateWithOpenAi(prompt, config) {
3651
+ const data = await postJson(
3652
+ "OpenAI",
3653
+ `${apiRoot(config)}/v1/chat/completions`,
3654
+ openAiHeaders(config),
3655
+ {
3656
+ model: config.model,
3657
+ messages: [
3658
+ { role: "user", content: prompt }
3659
+ ],
3660
+ max_tokens: 4096
3661
+ }
3662
+ );
3663
+ return extractOpenAiText(data);
3664
+ }
3665
+ function buildVisionPrompt(assertion) {
3666
+ return [
3667
+ "You are a meticulous QA reviewer. You are given a screenshot of an application",
3668
+ "and a single assertion describing what should be true about it.",
3669
+ "",
3670
+ "Assertion:",
3671
+ assertion,
3672
+ "",
3673
+ "Decide whether the assertion holds for the screenshot. Judge ONLY what is",
3674
+ "visible; do not assume behavior you cannot see. Be strict: if the assertion",
3675
+ "is not clearly satisfied, it fails.",
3676
+ "",
3677
+ "Reply with ONLY a single JSON object on one line, no markdown, no code fences:",
3678
+ '{"pass": <true|false>, "reason": "<one concise sentence explaining the verdict>"}'
3679
+ ].join("\n");
3680
+ }
3681
+ function parseVisionVerdict(raw) {
3682
+ const cleaned = stripCodeFences(raw).trim();
3683
+ const candidate = extractJsonObject(cleaned);
3684
+ if (candidate === null) {
3685
+ throw new Error(
3686
+ `Could not parse a JSON verdict from the AI response: ${truncateForError(raw)}`
3687
+ );
3688
+ }
3689
+ let parsed;
3690
+ try {
3691
+ parsed = JSON.parse(candidate);
3692
+ } catch {
3693
+ throw new Error(
3694
+ `AI verdict was not valid JSON: ${truncateForError(raw)}`
3695
+ );
3696
+ }
3697
+ if (typeof parsed !== "object" || parsed === null) {
3698
+ throw new Error(`AI verdict was not a JSON object: ${truncateForError(raw)}`);
3699
+ }
3700
+ const record = parsed;
3701
+ if (typeof record.pass !== "boolean") {
3702
+ throw new Error(
3703
+ `AI verdict is missing a boolean "pass" field: ${truncateForError(raw)}`
3704
+ );
3705
+ }
3706
+ const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : record.pass ? "Assertion satisfied." : "Assertion not satisfied.";
3707
+ return { pass: record.pass, reason };
3708
+ }
3709
+ function stripCodeFences(text) {
3710
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
3711
+ return fence ? fence[1] : text;
3712
+ }
3713
+ function extractJsonObject(text) {
3714
+ const trimmed = text.trim();
3715
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
3716
+ return trimmed;
3717
+ }
3718
+ const first = trimmed.indexOf("{");
3719
+ const last = trimmed.lastIndexOf("}");
3720
+ if (first === -1 || last === -1 || last <= first) {
3721
+ return null;
3722
+ }
3723
+ return trimmed.slice(first, last + 1);
3724
+ }
3725
+ function truncateForError(text) {
3726
+ const collapsed = text.replace(/\s+/g, " ").trim();
3727
+ return collapsed.length > 200 ? `${collapsed.slice(0, 199)}\u2026` : collapsed;
3728
+ }
3729
+ async function assertWithAiVision(input, config) {
3730
+ const raw = config.provider === "anthropic" ? await visionWithAnthropic(input, config) : await visionWithOpenAi(input, config);
3731
+ return parseVisionVerdict(raw);
3732
+ }
3733
+ async function visionWithAnthropic(input, config) {
3734
+ const data = await postJson(
3735
+ "Anthropic",
3736
+ `${apiRoot(config)}/v1/messages`,
3737
+ anthropicHeaders(config),
3738
+ {
3739
+ model: config.model,
3740
+ max_tokens: 1024,
3741
+ temperature: 0,
3742
+ messages: [
3743
+ {
3744
+ role: "user",
3745
+ content: [
3746
+ {
3747
+ type: "image",
3748
+ source: {
3749
+ type: "base64",
3750
+ media_type: input.mediaType,
3751
+ data: input.imageBase64
3752
+ }
3753
+ },
3754
+ { type: "text", text: buildVisionPrompt(input.assertion) }
3755
+ ]
3756
+ }
3757
+ ]
3758
+ }
3759
+ );
3760
+ return extractAnthropicText(data);
3761
+ }
3762
+ async function visionWithOpenAi(input, config) {
3763
+ const data = await postJson(
3764
+ "OpenAI",
3765
+ `${apiRoot(config)}/v1/chat/completions`,
3766
+ openAiHeaders(config),
3767
+ {
3768
+ model: config.model,
3769
+ max_tokens: 1024,
3770
+ temperature: 0,
3771
+ messages: [
3772
+ {
3773
+ role: "user",
3774
+ content: [
3775
+ { type: "text", text: buildVisionPrompt(input.assertion) },
3776
+ {
3777
+ type: "image_url",
3778
+ image_url: { url: `data:${input.mediaType};base64,${input.imageBase64}` }
3779
+ }
3780
+ ]
3781
+ }
3782
+ ]
3783
+ }
3784
+ );
3785
+ return extractOpenAiText(data);
3786
+ }
3787
+ async function postJson(provider, url, headers, body) {
3788
+ const response = await fetch(url, {
3789
+ method: "POST",
3790
+ headers,
3791
+ body: JSON.stringify(body),
3792
+ signal: AbortSignal.timeout(AI_REQUEST_TIMEOUT_MS)
3793
+ });
3794
+ if (!response.ok) {
3795
+ const responseBody = await response.text();
3796
+ throw new Error(`${provider} API error (${response.status}): ${responseBody}`);
3797
+ }
3798
+ return await response.json();
3799
+ }
3800
+ function anthropicHeaders(config) {
3801
+ return {
3802
+ "Content-Type": "application/json",
3803
+ "x-api-key": config.apiKey,
3804
+ "anthropic-version": "2023-06-01"
3805
+ };
3806
+ }
3807
+ function openAiHeaders(config) {
3808
+ return {
3809
+ "Content-Type": "application/json",
3810
+ "Authorization": `Bearer ${config.apiKey}`
3811
+ };
3812
+ }
3813
+ function extractAnthropicText(data) {
3814
+ const textBlock = data.content?.find((c) => c.type === "text");
3815
+ if (!textBlock?.text) {
3816
+ throw new Error("Anthropic API returned no text content");
3817
+ }
3818
+ return textBlock.text;
3819
+ }
3820
+ function extractOpenAiText(data) {
3821
+ if (!data.choices?.[0]?.message?.content) {
3822
+ throw new Error("OpenAI API returned no content");
3823
+ }
3824
+ return data.choices[0].message.content;
3825
+ }
3826
+
3103
3827
  // src/runner/steps.ts
3104
3828
  function getStepType(step) {
3105
3829
  if ("navigate" in step) return "navigate";
@@ -3128,6 +3852,7 @@ function getStepType(step) {
3128
3852
  if ("evalScript" in step) return "evalScript";
3129
3853
  if ("runScript" in step) return "runScript";
3130
3854
  if ("assertScreenshot" in step) return "assertScreenshot";
3855
+ if ("assertWithAI" in step) return "assertWithAI";
3131
3856
  if ("copyText" in step) return "copyText";
3132
3857
  if ("waitForDownload" in step) return "waitForDownload";
3133
3858
  return "step";
@@ -3187,6 +3912,9 @@ function applyRuntimeVars(step, vars) {
3187
3912
  }
3188
3913
  };
3189
3914
  }
3915
+ if ("assertWithAI" in step) {
3916
+ return { assertWithAI: sub(step.assertWithAI) };
3917
+ }
3190
3918
  if ("copyText" in step) {
3191
3919
  return { copyText: { selector: sub(step.copyText.selector), as: step.copyText.as } };
3192
3920
  }
@@ -3437,7 +4165,7 @@ async function runInlineAssert(driver, policy, assertion) {
3437
4165
  throw new Error("assert step is missing an assertion type");
3438
4166
  }
3439
4167
  function screenshotPath(screenshotsDir, fileName) {
3440
- return path8.join(screenshotsDir, fileName);
4168
+ return path9.join(screenshotsDir, fileName);
3441
4169
  }
3442
4170
  function stepPath(prefix, index) {
3443
4171
  return prefix ? `${prefix}.${index}` : `${index}`;
@@ -3454,7 +4182,7 @@ function validateDownloadFilename(suggestedFilename) {
3454
4182
  const safeFilename = suggestedFilename.trim();
3455
4183
  const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
3456
4184
  const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
3457
- if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== path8.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
4185
+ if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== path9.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
3458
4186
  throw new Error(`Invalid download filename: "${suggestedFilename}"`);
3459
4187
  }
3460
4188
  return safeFilename;
@@ -3634,7 +4362,7 @@ var STEP_HANDLERS = {
3634
4362
  if (!("setInputFiles" in h.step)) unknownStep();
3635
4363
  const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
3636
4364
  const rawFiles = h.step.setInputFiles.files;
3637
- const resolveFile = (f) => path8.isAbsolute(f) ? f : path8.join(h.context.configDir, f);
4365
+ const resolveFile = (f) => path9.isAbsolute(f) ? f : path9.join(h.context.configDir, f);
3638
4366
  const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
3639
4367
  await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
3640
4368
  h.policy.ensureLocationAllowed(h.driver);
@@ -3996,11 +4724,11 @@ var STEP_HANDLERS = {
3996
4724
  if (!responseFile) {
3997
4725
  throw new Error("mock.response must include either body or file");
3998
4726
  }
3999
- const candidateFilePath = path8.isAbsolute(responseFile) ? responseFile : path8.join(h.context.configDir, responseFile);
4000
- const resolvedConfigDir = path8.resolve(h.context.configDir);
4001
- const resolvedFilePath = path8.resolve(candidateFilePath);
4002
- const relativePath = path8.relative(resolvedConfigDir, resolvedFilePath);
4003
- const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${path8.sep}`) && !path8.isAbsolute(relativePath);
4727
+ const candidateFilePath = path9.isAbsolute(responseFile) ? responseFile : path9.join(h.context.configDir, responseFile);
4728
+ const resolvedConfigDir = path9.resolve(h.context.configDir);
4729
+ const resolvedFilePath = path9.resolve(candidateFilePath);
4730
+ const relativePath = path9.relative(resolvedConfigDir, resolvedFilePath);
4731
+ const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${path9.sep}`) && !path9.isAbsolute(relativePath);
4004
4732
  if (!isWithinConfigDir) {
4005
4733
  throw new Error("mock.response.file must resolve within config directory");
4006
4734
  }
@@ -4067,7 +4795,7 @@ var STEP_HANDLERS = {
4067
4795
  capabilities: ["evaluate"],
4068
4796
  run: async (h) => {
4069
4797
  if (!("runScript" in h.step)) unknownStep();
4070
- const filePath = path8.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : path8.join(h.context.configDir, h.step.runScript.file);
4798
+ const filePath = path9.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : path9.join(h.context.configDir, h.step.runScript.file);
4071
4799
  const fileContents = fs8.readFileSync(filePath, "utf-8");
4072
4800
  await h.driver.evaluate(fileContents);
4073
4801
  return {
@@ -4089,11 +4817,11 @@ var STEP_HANDLERS = {
4089
4817
  const name = h.step.assertScreenshot.name;
4090
4818
  const threshold = h.step.assertScreenshot.threshold ?? 0.1;
4091
4819
  const baselineDir = ensureBaselineDir(h.context.configDir);
4092
- const baselinePath = path8.join(baselineDir, `${name}.png`);
4093
- const currentScreenshotPath = path8.join(h.context.runDir, "screenshots", `${name}-current.png`);
4094
- fs8.mkdirSync(path8.dirname(currentScreenshotPath), { recursive: true });
4820
+ const baselinePath = path9.join(baselineDir, `${name}.png`);
4821
+ const currentScreenshotPath = path9.join(h.context.runDir, "screenshots", `${name}-current.png`);
4822
+ fs8.mkdirSync(path9.dirname(currentScreenshotPath), { recursive: true });
4095
4823
  await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
4096
- h.screenshots.push(path8.join("screenshots", `${name}-current.png`));
4824
+ h.screenshots.push(path9.join("screenshots", `${name}-current.png`));
4097
4825
  if (!fs8.existsSync(baselinePath)) {
4098
4826
  fs8.copyFileSync(currentScreenshotPath, baselinePath);
4099
4827
  return {
@@ -4101,7 +4829,7 @@ var STEP_HANDLERS = {
4101
4829
  result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
4102
4830
  };
4103
4831
  }
4104
- const diffPath = path8.join(h.context.runDir, "screenshots", `${name}-diff.png`);
4832
+ const diffPath = path9.join(h.context.runDir, "screenshots", `${name}-diff.png`);
4105
4833
  const comparison = await compareScreenshots(baselinePath, currentScreenshotPath, diffPath, threshold);
4106
4834
  if (comparison.match) {
4107
4835
  return {
@@ -4114,12 +4842,54 @@ var STEP_HANDLERS = {
4114
4842
  }
4115
4843
  };
4116
4844
  }
4117
- h.screenshots.push(path8.join("screenshots", `${name}-diff.png`));
4845
+ h.screenshots.push(path9.join("screenshots", `${name}-diff.png`));
4118
4846
  throw new Error(
4119
4847
  `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
4120
4848
  );
4121
4849
  }
4122
4850
  },
4851
+ assertWithAI: {
4852
+ capabilities: ["screenshot"],
4853
+ run: async (h) => {
4854
+ if (!("assertWithAI" in h.step)) unknownStep();
4855
+ const assertion = h.step.assertWithAI;
4856
+ const resolve = h.context.resolveAiConfig ?? tryResolveAiConfig;
4857
+ const aiConfig = resolve();
4858
+ if (!aiConfig) {
4859
+ return {
4860
+ kind: "result",
4861
+ result: {
4862
+ type: "assertWithAI",
4863
+ status: "warn",
4864
+ durationMs: Date.now() - h.stepStart,
4865
+ value: `skipped: no AI provider configured (set PROWL_AI_KEY to enable) \u2014 "${assertion}"`
4866
+ }
4867
+ };
4868
+ }
4869
+ const fileName = `assertWithAI_step_${h.index + 1}.png`;
4870
+ const relative = await h.addScreenshot(fileName);
4871
+ const screenshotFullPath = path9.join(h.context.runDir, relative);
4872
+ const imageBase64 = fs8.readFileSync(screenshotFullPath).toString("base64");
4873
+ const assertVision = h.context.assertVision ?? assertWithAiVision;
4874
+ const verdict = await assertVision(
4875
+ { imageBase64, mediaType: "image/png", assertion },
4876
+ aiConfig
4877
+ );
4878
+ if (verdict.pass) {
4879
+ return {
4880
+ kind: "result",
4881
+ result: {
4882
+ type: "assertWithAI",
4883
+ status: "pass",
4884
+ durationMs: Date.now() - h.stepStart,
4885
+ value: verdict.reason,
4886
+ screenshot: relative
4887
+ }
4888
+ };
4889
+ }
4890
+ throw new Error(`AI assertion failed: ${verdict.reason}`);
4891
+ }
4892
+ },
4123
4893
  copyText: {
4124
4894
  capabilities: ["query"],
4125
4895
  run: async (h) => {
@@ -4156,7 +4926,7 @@ var STEP_HANDLERS = {
4156
4926
  `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
4157
4927
  );
4158
4928
  }
4159
- const savePath = path8.join(h.context.runDir, suggestedFilename);
4929
+ const savePath = path9.join(h.context.runDir, suggestedFilename);
4160
4930
  await download.saveAs(savePath);
4161
4931
  return {
4162
4932
  kind: "result",
@@ -4185,7 +4955,7 @@ async function executeSteps(context) {
4185
4955
  maxSteps: context.maxSteps,
4186
4956
  selfHealing: context.selfHealing
4187
4957
  });
4188
- const screenshotsDir = path8.join(context.runDir, "screenshots");
4958
+ const screenshotsDir = path9.join(context.runDir, "screenshots");
4189
4959
  fs8.mkdirSync(screenshotsDir, { recursive: true });
4190
4960
  const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
4191
4961
  policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
@@ -4196,7 +4966,7 @@ async function executeSteps(context) {
4196
4966
  const addScreenshot = async (fileName) => {
4197
4967
  const fullPath = screenshotPath(screenshotsDir, fileName);
4198
4968
  await captureScreenshot2(driver, fullPath);
4199
- const relative = path8.join("screenshots", fileName);
4969
+ const relative = path9.join("screenshots", fileName);
4200
4970
  screenshots.push(relative);
4201
4971
  return relative;
4202
4972
  };
@@ -4284,12 +5054,12 @@ async function executeSteps(context) {
4284
5054
  return { results, screenshots, failed: false };
4285
5055
  }
4286
5056
  async function captureFinalScreenshot(page, runDir) {
4287
- const screenshotsDir = path8.join(runDir, "screenshots");
5057
+ const screenshotsDir = path9.join(runDir, "screenshots");
4288
5058
  fs8.mkdirSync(screenshotsDir, { recursive: true });
4289
5059
  const fileName = "final.png";
4290
5060
  const filePath = screenshotPath(screenshotsDir, fileName);
4291
5061
  await captureScreenshot2(page, filePath);
4292
- return path8.join("screenshots", fileName);
5062
+ return path9.join("screenshots", fileName);
4293
5063
  }
4294
5064
 
4295
5065
  // src/runner/assertions.ts
@@ -4411,6 +5181,59 @@ async function evaluateAssertions(options) {
4411
5181
  }
4412
5182
  return results;
4413
5183
  }
5184
+ async function evaluateNativeAssertions(options) {
5185
+ const assertions = mergeAssertions(options.config, options.huntAssertions);
5186
+ const authoredTypes = new Set(
5187
+ (options.huntAssertions ?? []).map((assertion) => Object.keys(assertion)[0])
5188
+ );
5189
+ const results = [];
5190
+ const warnings = [];
5191
+ for (const assertion of assertions) {
5192
+ const type = Object.keys(assertion)[0] ?? "assertion";
5193
+ try {
5194
+ if ("selectorExists" in assertion) {
5195
+ options.assertAllowedSelector?.(assertion.selectorExists);
5196
+ const count = await options.driver.count(assertion.selectorExists);
5197
+ results.push({
5198
+ type: "selectorExists",
5199
+ value: assertion.selectorExists,
5200
+ status: count > 0 ? "pass" : "fail",
5201
+ error: count > 0 ? void 0 : "Selector not found"
5202
+ });
5203
+ continue;
5204
+ }
5205
+ if ("selectorNotExists" in assertion) {
5206
+ options.assertAllowedSelector?.(assertion.selectorNotExists);
5207
+ const count = await options.driver.count(assertion.selectorNotExists);
5208
+ results.push({
5209
+ type: "selectorNotExists",
5210
+ value: assertion.selectorNotExists,
5211
+ status: count === 0 ? "pass" : "fail",
5212
+ error: count === 0 ? void 0 : "Selector exists"
5213
+ });
5214
+ continue;
5215
+ }
5216
+ const rawValue = assertion[type];
5217
+ results.push({
5218
+ type,
5219
+ value: rawValue,
5220
+ status: "skipped",
5221
+ error: `skipped (web-only): not supported on the ${options.targetLabel} target`
5222
+ });
5223
+ if (authoredTypes.has(type)) {
5224
+ warnings.push(`${type} is web-only; skipped on ${options.targetLabel} target`);
5225
+ }
5226
+ } catch (error) {
5227
+ const detail = error instanceof Error ? error.message : "no error details";
5228
+ results.push({
5229
+ type,
5230
+ status: "fail",
5231
+ error: `Native assertion "${type}" failed: ${detail}`
5232
+ });
5233
+ }
5234
+ }
5235
+ return { results, warnings };
5236
+ }
4414
5237
 
4415
5238
  // src/runner/tracing.ts
4416
5239
  var DEFAULT_TRACE_HEADER = "traceparent";
@@ -4450,17 +5273,17 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
4450
5273
 
4451
5274
  // src/reporter/result.ts
4452
5275
  import fs9 from "fs";
4453
- import path9 from "path";
5276
+ import path10 from "path";
4454
5277
  function writeResult(runDir, result) {
4455
5278
  const fileName = "result.json";
4456
- const fullPath = path9.join(runDir, fileName);
5279
+ const fullPath = path10.join(runDir, fileName);
4457
5280
  fs9.writeFileSync(fullPath, JSON.stringify(result, null, 2));
4458
5281
  return fileName;
4459
5282
  }
4460
5283
 
4461
5284
  // src/reporter/summary.ts
4462
5285
  import fs10 from "fs";
4463
- import path10 from "path";
5286
+ import path11 from "path";
4464
5287
  function escapeMd(text) {
4465
5288
  return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
4466
5289
  }
@@ -4538,7 +5361,7 @@ function writeSummary(runDir, result) {
4538
5361
  }
4539
5362
  }
4540
5363
  const fileName = "summary.md";
4541
- const fullPath = path10.join(runDir, fileName);
5364
+ const fullPath = path11.join(runDir, fileName);
4542
5365
  fs10.writeFileSync(fullPath, `${lines.join("\n")}
4543
5366
  `);
4544
5367
  return fileName;
@@ -4546,20 +5369,21 @@ function writeSummary(runDir, result) {
4546
5369
 
4547
5370
  // src/reporter/junit.ts
4548
5371
  import fs11 from "fs";
4549
- import path11 from "path";
5372
+ import path12 from "path";
4550
5373
  function escapeXml(text) {
4551
5374
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
4552
5375
  }
4553
5376
  function writeJunit(runDir, result) {
4554
5377
  const totalTests = result.steps.length + result.assertions.length;
4555
5378
  const failures = result.steps.filter((s) => s.status === "fail").length + result.assertions.filter((a) => a.status === "fail").length;
5379
+ const skipped = result.assertions.filter((a) => a.status === "skipped").length;
4556
5380
  const timeSeconds = (result.durationMs / 1e3).toFixed(3);
4557
5381
  const huntName = escapeXml(result.hunt);
4558
5382
  const lines = [];
4559
5383
  lines.push('<?xml version="1.0" encoding="UTF-8"?>');
4560
5384
  lines.push("<testsuites>");
4561
5385
  lines.push(
4562
- ` <testsuite name="${huntName}" tests="${totalTests}" failures="${failures}" errors="0" time="${timeSeconds}" timestamp="${escapeXml(result.startedAt)}">`
5386
+ ` <testsuite name="${huntName}" tests="${totalTests}" failures="${failures}" errors="0" skipped="${skipped}" time="${timeSeconds}" timestamp="${escapeXml(result.startedAt)}">`
4563
5387
  );
4564
5388
  for (let i = 0; i < result.steps.length; i++) {
4565
5389
  const step = result.steps[i];
@@ -4583,6 +5407,11 @@ function writeJunit(runDir, result) {
4583
5407
  lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0">`);
4584
5408
  lines.push(` <failure message="${escapedFailureText}" type="assertion">${escapedFailureText}</failure>`);
4585
5409
  lines.push(" </testcase>");
5410
+ } else if (assertion.status === "skipped") {
5411
+ const skipText = escapeXml(assertion.error ?? "skipped");
5412
+ lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0">`);
5413
+ lines.push(` <skipped message="${skipText}"/>`);
5414
+ lines.push(" </testcase>");
4586
5415
  } else {
4587
5416
  lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0"/>`);
4588
5417
  }
@@ -4590,7 +5419,7 @@ function writeJunit(runDir, result) {
4590
5419
  lines.push(" </testsuite>");
4591
5420
  lines.push("</testsuites>");
4592
5421
  const fileName = "junit.xml";
4593
- const fullPath = path11.join(runDir, fileName);
5422
+ const fullPath = path12.join(runDir, fileName);
4594
5423
  fs11.writeFileSync(fullPath, `${lines.join("\n")}
4595
5424
  `);
4596
5425
  return fileName;
@@ -4633,11 +5462,11 @@ function parseViewportFlag(value) {
4633
5462
  return value;
4634
5463
  }
4635
5464
  function resolvePath(configDir, inputPath) {
4636
- if (path12.isAbsolute(inputPath)) {
5465
+ if (path13.isAbsolute(inputPath)) {
4637
5466
  return inputPath;
4638
5467
  }
4639
- const projectRoot = path12.dirname(configDir);
4640
- return path12.join(projectRoot, inputPath);
5468
+ const projectRoot = path13.dirname(configDir);
5469
+ return path13.join(projectRoot, inputPath);
4641
5470
  }
4642
5471
  function buildRunResult(options) {
4643
5472
  return {
@@ -4656,7 +5485,7 @@ function buildRunResult(options) {
4656
5485
  }
4657
5486
  function writeConsoleLog(runDir, entries) {
4658
5487
  const fileName = "console.log";
4659
- const filePath = path12.join(runDir, fileName);
5488
+ const filePath = path13.join(runDir, fileName);
4660
5489
  const lines = entries.map((entry) => {
4661
5490
  const location = entry.location ? ` (${entry.location})` : "";
4662
5491
  return `[${entry.type}] ${entry.text}${location}`;
@@ -4669,7 +5498,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
4669
5498
  const headless = options.headed ? false : config.browser.headless;
4670
5499
  const slowMo = options.slowMo ?? config.browser.slowMo;
4671
5500
  const maxSteps = config.guardrails.maxSteps;
4672
- const runDir = path12.join(configDir, "runs", timestamp());
5501
+ const runDir = path13.join(configDir, "runs", timestamp());
4673
5502
  fs12.mkdirSync(runDir, { recursive: true });
4674
5503
  const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
4675
5504
  const engine = options.browser ?? config.browser.engine;
@@ -4853,7 +5682,7 @@ async function runHunt(options) {
4853
5682
  }
4854
5683
  async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
4855
5684
  const maxSteps = config.guardrails.maxSteps;
4856
- const runDir = path12.join(configDir, "runs", timestamp());
5685
+ const runDir = path13.join(configDir, "runs", timestamp());
4857
5686
  fs12.mkdirSync(runDir, { recursive: true });
4858
5687
  const session = await native.launchSession();
4859
5688
  let result;
@@ -4862,6 +5691,13 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
4862
5691
  const appIdentity = native.sessionAppIdentity(session);
4863
5692
  const targetLabel = `${native.targetType}:${appIdentity}`;
4864
5693
  const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
5694
+ const assertionPolicy = createRunPolicy(driver, {
5695
+ forbiddenSelectors: config.guardrails.forbiddenSelectors,
5696
+ allowedDomains: [],
5697
+ allowedApps: effectiveAllowedApps,
5698
+ maxSteps,
5699
+ selfHealing: config.guardrails.selfHealing
5700
+ });
4865
5701
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
4866
5702
  const startTime = Date.now();
4867
5703
  let stepResults = [];
@@ -4901,8 +5737,19 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
4901
5737
  } catch {
4902
5738
  finalScreenshot = void 0;
4903
5739
  }
5740
+ const { results: assertionResults, warnings: assertionWarnings } = await evaluateNativeAssertions({
5741
+ driver,
5742
+ config,
5743
+ huntAssertions: interpolatedHunt.assertions,
5744
+ assertAllowedSelector: assertionPolicy.assertAllowedSelector,
5745
+ targetLabel: nativeTargetLabel(native.targetType)
5746
+ });
5747
+ for (const warning of assertionWarnings) {
5748
+ console.warn(warning);
5749
+ }
4904
5750
  const durationMs = Date.now() - startTime;
4905
- const status = stepFailed ? "fail" : "pass";
5751
+ const assertionsFailed = assertionResults.some((assertion) => assertion.status === "fail");
5752
+ const status = stepFailed || assertionsFailed ? "fail" : "pass";
4906
5753
  const artifacts = {
4907
5754
  screenshots: finalScreenshot ? [...stepScreenshots, finalScreenshot] : stepScreenshots
4908
5755
  };
@@ -4913,7 +5760,7 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
4913
5760
  hunt: options.huntName,
4914
5761
  targetUrl: targetLabel,
4915
5762
  steps: stepResults,
4916
- assertions: [],
5763
+ assertions: assertionResults,
4917
5764
  artifacts
4918
5765
  });
4919
5766
  result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
@@ -5026,7 +5873,6 @@ async function runNativeHunt(options, config, configDir, target, native) {
5026
5873
  const hunt = loadHunt(options.huntName, configDir);
5027
5874
  const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
5028
5875
  assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
5029
- assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
5030
5876
  native.assertAppAllowed(config.guardrails.allowedApps, target);
5031
5877
  const maxSteps = config.guardrails.maxSteps;
5032
5878
  if (interpolatedHunt.steps.length > maxSteps) {
@@ -5067,7 +5913,7 @@ async function runNativeHunt(options, config, configDir, target, native) {
5067
5913
  }
5068
5914
  function recordHistory(configDir, outcome, maxRuns) {
5069
5915
  try {
5070
- const relativeRunDir = path12.relative(configDir, outcome.runDir);
5916
+ const relativeRunDir = path13.relative(configDir, outcome.runDir);
5071
5917
  appendEntry(
5072
5918
  configDir,
5073
5919
  {
@@ -5189,7 +6035,7 @@ function clusterFailures(failures) {
5189
6035
 
5190
6036
  // src/backlog/index.ts
5191
6037
  import fs13 from "fs";
5192
- import path13 from "path";
6038
+ import path14 from "path";
5193
6039
 
5194
6040
  // src/backlog/parse.ts
5195
6041
  var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
@@ -5295,7 +6141,7 @@ function buildFailure(hunt) {
5295
6141
  if (!hunt.runDir) return failure;
5296
6142
  let run;
5297
6143
  try {
5298
- const resultJson = readFileOrEmpty(path13.join(hunt.runDir, "result.json"));
6144
+ const resultJson = readFileOrEmpty(path14.join(hunt.runDir, "result.json"));
5299
6145
  if (!resultJson) return failure;
5300
6146
  run = JSON.parse(resultJson);
5301
6147
  } catch (error) {
@@ -5324,8 +6170,8 @@ function extractFailures(suiteResult) {
5324
6170
  }
5325
6171
  function updateBacklogFromSuite(suiteResult, options = {}) {
5326
6172
  const projectRoot = options.projectRoot ?? process.cwd();
5327
- const backlogPath = options.backlogPath ?? path13.join(projectRoot, "docs", "backlog.md");
5328
- const resolvedPath = options.resolvedPath ?? path13.join(projectRoot, "docs", "resolved.md");
6173
+ const backlogPath = options.backlogPath ?? path14.join(projectRoot, "docs", "backlog.md");
6174
+ const resolvedPath = options.resolvedPath ?? path14.join(projectRoot, "docs", "resolved.md");
5329
6175
  const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
5330
6176
  const summary = { created: [], regressions: [], skipped: [], backlogPath };
5331
6177
  const failures = extractFailures(suiteResult);
@@ -5357,18 +6203,18 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
5357
6203
  }
5358
6204
  }
5359
6205
  if (ticketsToAdd.length > 0) {
5360
- fs13.mkdirSync(path13.dirname(backlogPath), { recursive: true });
6206
+ fs13.mkdirSync(path14.dirname(backlogPath), { recursive: true });
5361
6207
  fs13.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
5362
6208
  }
5363
6209
  return summary;
5364
6210
  }
5365
6211
 
5366
6212
  // src/runner/suite.ts
5367
- import path15 from "path";
6213
+ import path16 from "path";
5368
6214
 
5369
6215
  // src/reporter/ci-summary.ts
5370
6216
  import fs14 from "fs";
5371
- import path14 from "path";
6217
+ import path15 from "path";
5372
6218
  import chalk from "chalk";
5373
6219
  function countCiResults(results) {
5374
6220
  return {
@@ -5433,7 +6279,7 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
5433
6279
  ...clusters.length > 0 ? { clusters } : {}
5434
6280
  };
5435
6281
  fs14.mkdirSync(ciRunDir, { recursive: true });
5436
- const filePath = path14.join(ciRunDir, "ci-result.json");
6282
+ const filePath = path15.join(ciRunDir, "ci-result.json");
5437
6283
  fs14.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
5438
6284
  return filePath;
5439
6285
  }
@@ -5619,7 +6465,7 @@ async function runSuite(options = {}) {
5619
6465
  const clusters = clusterFailures(
5620
6466
  extractFailures({ result: { hunts: results }, resultPath: null })
5621
6467
  ).filter((cluster) => cluster.count > 1);
5622
- const ciRunDir = path15.join(configDir, "runs", timestamp("ci"));
6468
+ const ciRunDir = path16.join(configDir, "runs", timestamp("ci"));
5623
6469
  const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
5624
6470
  const { passed, failed, skipped } = countCiResults(results);
5625
6471
  return {
@@ -5882,6 +6728,273 @@ async function readStatusMenu(client, menuTimeoutSeconds) {
5882
6728
  }
5883
6729
  }
5884
6730
 
6731
+ // src/analyzer/android.ts
6732
+ var ANDROID_INTERACTIVE_CLASSES = /* @__PURE__ */ new Set([
6733
+ "android.widget.Button",
6734
+ "android.widget.ImageButton",
6735
+ "android.widget.EditText",
6736
+ "android.widget.CheckBox",
6737
+ "android.widget.RadioButton",
6738
+ "android.widget.Switch",
6739
+ "android.widget.ToggleButton",
6740
+ "android.widget.Spinner",
6741
+ "android.widget.SeekBar",
6742
+ "android.widget.RatingBar",
6743
+ "android.widget.CompoundButton",
6744
+ "android.widget.AutoCompleteTextView",
6745
+ "android.widget.MultiAutoCompleteTextView",
6746
+ "android.widget.CheckedTextView",
6747
+ "androidx.appcompat.widget.SwitchCompat",
6748
+ "androidx.appcompat.widget.AppCompatButton",
6749
+ "androidx.appcompat.widget.AppCompatEditText"
6750
+ ]);
6751
+ function str2(value) {
6752
+ if (value === void 0) {
6753
+ return void 0;
6754
+ }
6755
+ const trimmed = value.trim();
6756
+ return trimmed.length > 0 ? trimmed : void 0;
6757
+ }
6758
+ function bool(value) {
6759
+ if (value === void 0) {
6760
+ return void 0;
6761
+ }
6762
+ return value === "true";
6763
+ }
6764
+ function toAndroidNode(element) {
6765
+ const a = element.attrs;
6766
+ return {
6767
+ className: str2(a["class"]),
6768
+ resourceId: str2(a["resource-id"]),
6769
+ contentDesc: str2(a["content-desc"]),
6770
+ text: str2(a.text),
6771
+ package: str2(a.package),
6772
+ clickable: bool(a.clickable),
6773
+ longClickable: bool(a["long-clickable"]),
6774
+ checkable: bool(a.checkable),
6775
+ checked: bool(a.checked),
6776
+ scrollable: bool(a.scrollable),
6777
+ focusable: bool(a.focusable),
6778
+ focused: bool(a.focused),
6779
+ enabled: bool(a.enabled),
6780
+ children: element.children.map(toAndroidNode)
6781
+ };
6782
+ }
6783
+ function parseAndroidHierarchy(xml) {
6784
+ const root = parseXml(xml);
6785
+ return root ? toAndroidNode(root) : null;
6786
+ }
6787
+ function isAndroidInteractive(node) {
6788
+ if (node.clickable || node.longClickable || node.checkable || node.scrollable) {
6789
+ return true;
6790
+ }
6791
+ return node.className !== void 0 && ANDROID_INTERACTIVE_CLASSES.has(node.className);
6792
+ }
6793
+ function rankAndroidSelectors(node) {
6794
+ return rankNativeSelectors({
6795
+ id: node.resourceId,
6796
+ label: node.contentDesc,
6797
+ role: node.className,
6798
+ name: node.text
6799
+ });
6800
+ }
6801
+ function androidNodeToNative(node) {
6802
+ return {
6803
+ ...node.resourceId !== void 0 ? { id: node.resourceId } : {},
6804
+ ...node.contentDesc !== void 0 ? { label: node.contentDesc } : {},
6805
+ ...node.className !== void 0 ? { role: node.className } : {},
6806
+ textValues: node.text !== void 0 ? [node.text] : [],
6807
+ ...node.focused !== void 0 ? { focused: node.focused } : {}
6808
+ };
6809
+ }
6810
+ function matchAndroidSelector(xml, selector, options = {}) {
6811
+ const parsedSelector = parseNativeSelector(selector);
6812
+ const root = parseAndroidHierarchy(xml);
6813
+ if (!root) {
6814
+ return [];
6815
+ }
6816
+ return matchNativeTree(
6817
+ ANDROID_MATCH_DIALECT,
6818
+ parsedSelector,
6819
+ root,
6820
+ androidNodeToNative,
6821
+ (node) => node.children,
6822
+ options
6823
+ );
6824
+ }
6825
+ function toElement2(node) {
6826
+ return {
6827
+ className: node.className ?? "?",
6828
+ ...node.resourceId ? { resourceId: node.resourceId } : {},
6829
+ ...node.contentDesc ? { contentDesc: node.contentDesc } : {},
6830
+ ...node.text ? { text: node.text } : {},
6831
+ ...node.clickable !== void 0 ? { clickable: node.clickable } : {},
6832
+ ...node.checkable !== void 0 ? { checkable: node.checkable } : {},
6833
+ ...node.scrollable !== void 0 ? { scrollable: node.scrollable } : {},
6834
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
6835
+ selectors: rankAndroidSelectors(node)
6836
+ };
6837
+ }
6838
+ function collectInteractive2(root) {
6839
+ const out = [];
6840
+ const visit = (node) => {
6841
+ if (isAndroidInteractive(node)) {
6842
+ out.push(toElement2(node));
6843
+ }
6844
+ for (const child of node.children) {
6845
+ visit(child);
6846
+ }
6847
+ };
6848
+ visit(root);
6849
+ return out;
6850
+ }
6851
+ async function analyzeAndroidApp(client, options) {
6852
+ const xml = await client.source();
6853
+ const root = parseAndroidHierarchy(xml);
6854
+ const elements = root ? collectInteractive2(root) : [];
6855
+ return { app: options.app, elements };
6856
+ }
6857
+
6858
+ // src/analyzer/ios.ts
6859
+ var IOS_INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
6860
+ "XCUIElementTypeButton",
6861
+ "XCUIElementTypeCell",
6862
+ "XCUIElementTypeTextField",
6863
+ "XCUIElementTypeSecureTextField",
6864
+ "XCUIElementTypeSearchField",
6865
+ "XCUIElementTypeSwitch",
6866
+ "XCUIElementTypeToggle",
6867
+ "XCUIElementTypeLink",
6868
+ "XCUIElementTypeMenuItem",
6869
+ "XCUIElementTypeSlider",
6870
+ "XCUIElementTypeStepper",
6871
+ "XCUIElementTypeTextView",
6872
+ "XCUIElementTypePickerWheel",
6873
+ "XCUIElementTypeTab",
6874
+ "XCUIElementTypeSegmentedControl",
6875
+ "XCUIElementTypeCheckBox",
6876
+ "XCUIElementTypeRadioButton",
6877
+ "XCUIElementTypeKey"
6878
+ ]);
6879
+ var IOS_WINDOW_TYPE = "XCUIElementTypeWindow";
6880
+ function str3(value) {
6881
+ if (value === void 0) {
6882
+ return void 0;
6883
+ }
6884
+ const trimmed = value.trim();
6885
+ return trimmed.length > 0 ? trimmed : void 0;
6886
+ }
6887
+ function bool2(value) {
6888
+ if (value === void 0) {
6889
+ return void 0;
6890
+ }
6891
+ return value === "true" || value === "1";
6892
+ }
6893
+ function toIosNode(element) {
6894
+ const a = element.attrs;
6895
+ return {
6896
+ type: str3(a.type) ?? str3(element.tag),
6897
+ name: str3(a.name),
6898
+ label: str3(a.label),
6899
+ value: str3(a.value),
6900
+ enabled: bool2(a.enabled),
6901
+ visible: bool2(a.visible),
6902
+ children: element.children.map(toIosNode)
6903
+ };
6904
+ }
6905
+ function parseIosHierarchy(xml) {
6906
+ const root = parseXml(xml);
6907
+ return root ? toIosNode(root) : null;
6908
+ }
6909
+ function hasAccessibilityId(node) {
6910
+ return node.name !== void 0 && node.name !== node.label;
6911
+ }
6912
+ function rankIosSelectors(node) {
6913
+ return rankNativeSelectors({
6914
+ id: hasAccessibilityId(node) ? node.name : void 0,
6915
+ label: node.label,
6916
+ role: node.type ? shortIosType(node.type) : void 0,
6917
+ name: node.label ?? node.value
6918
+ });
6919
+ }
6920
+ function iosNodeToNative(node) {
6921
+ const textValues = [];
6922
+ if (node.label !== void 0) {
6923
+ textValues.push(node.label);
6924
+ }
6925
+ if (node.value !== void 0) {
6926
+ textValues.push(node.value);
6927
+ }
6928
+ return {
6929
+ ...node.name !== void 0 ? { id: node.name } : {},
6930
+ ...node.label !== void 0 ? { label: node.label } : {},
6931
+ ...node.type !== void 0 ? { role: node.type } : {},
6932
+ textValues
6933
+ };
6934
+ }
6935
+ function matchIosSelector(xml, selector) {
6936
+ const parsedSelector = parseNativeSelector(selector);
6937
+ const root = parseIosHierarchy(xml);
6938
+ if (!root) {
6939
+ return [];
6940
+ }
6941
+ return matchNativeTree(
6942
+ IOS_MATCH_DIALECT,
6943
+ parsedSelector,
6944
+ root,
6945
+ iosNodeToNative,
6946
+ (node) => node.children
6947
+ );
6948
+ }
6949
+ function toElement3(node) {
6950
+ return {
6951
+ type: node.type ?? "?",
6952
+ ...node.name ? { name: node.name } : {},
6953
+ ...node.label ? { label: node.label } : {},
6954
+ ...node.value ? { value: node.value } : {},
6955
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
6956
+ ...node.visible !== void 0 ? { visible: node.visible } : {},
6957
+ selectors: rankIosSelectors(node)
6958
+ };
6959
+ }
6960
+ function toWindow2(node) {
6961
+ const [best] = rankIosSelectors(node);
6962
+ return {
6963
+ ...node.name ? { name: node.name } : {},
6964
+ ...node.label ? { label: node.label } : {},
6965
+ selector: best ?? `role=${shortIosType(IOS_WINDOW_TYPE)}`
6966
+ };
6967
+ }
6968
+ function isIosInteractive(node) {
6969
+ return node.type !== void 0 && IOS_INTERACTIVE_TYPES.has(node.type);
6970
+ }
6971
+ function collect(root) {
6972
+ const elements = [];
6973
+ const windows = [];
6974
+ const visit = (node) => {
6975
+ if (node.type === IOS_WINDOW_TYPE) {
6976
+ windows.push(toWindow2(node));
6977
+ }
6978
+ if (isIosInteractive(node)) {
6979
+ elements.push(toElement3(node));
6980
+ }
6981
+ for (const child of node.children) {
6982
+ visit(child);
6983
+ }
6984
+ };
6985
+ visit(root);
6986
+ return { elements, windows };
6987
+ }
6988
+ async function analyzeIosApp(client, options) {
6989
+ const xml = await client.source();
6990
+ const root = parseIosHierarchy(xml);
6991
+ if (!root) {
6992
+ return { app: options.app, elements: [], windows: [] };
6993
+ }
6994
+ const { elements, windows } = collect(root);
6995
+ return { app: options.app, elements, windows };
6996
+ }
6997
+
5885
6998
  // src/generator/index.ts
5886
6999
  import yaml from "yaml";
5887
7000
 
@@ -5942,6 +7055,7 @@ var STEP_REFERENCE = `
5942
7055
 
5943
7056
  ### Visual Regression
5944
7057
  - assertScreenshot: { name: "baseline-name", threshold: 0.1 }
7058
+ - assertWithAI: "The login form should show email and password fields" \u2014 AI checks the screenshot against the claim
5945
7059
 
5946
7060
  ### Control Flow
5947
7061
  - if: { visible: ".banner", then: [steps...] }
@@ -5983,81 +7097,6 @@ function extractYamlFromResponse(response) {
5983
7097
  return response.trim();
5984
7098
  }
5985
7099
 
5986
- // src/generator/ai.ts
5987
- function resolveAiConfig() {
5988
- const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
5989
- if (provider !== "anthropic" && provider !== "openai") {
5990
- throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
5991
- }
5992
- const apiKey = process.env.PROWL_AI_KEY;
5993
- if (!apiKey) {
5994
- throw new Error(
5995
- "PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
5996
- );
5997
- }
5998
- const defaultModel = provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
5999
- const model = process.env.PROWL_AI_MODEL ?? defaultModel;
6000
- return { provider, model, apiKey };
6001
- }
6002
- async function generateWithAi(prompt, config) {
6003
- if (config.provider === "anthropic") {
6004
- return generateWithAnthropic(prompt, config);
6005
- }
6006
- return generateWithOpenAi(prompt, config);
6007
- }
6008
- async function generateWithAnthropic(prompt, config) {
6009
- const response = await fetch("https://api.anthropic.com/v1/messages", {
6010
- method: "POST",
6011
- headers: {
6012
- "Content-Type": "application/json",
6013
- "x-api-key": config.apiKey,
6014
- "anthropic-version": "2023-06-01"
6015
- },
6016
- body: JSON.stringify({
6017
- model: config.model,
6018
- max_tokens: 4096,
6019
- messages: [
6020
- { role: "user", content: prompt }
6021
- ]
6022
- })
6023
- });
6024
- if (!response.ok) {
6025
- const body = await response.text();
6026
- throw new Error(`Anthropic API error (${response.status}): ${body}`);
6027
- }
6028
- const data = await response.json();
6029
- const textBlock = data.content.find((c) => c.type === "text");
6030
- if (!textBlock?.text) {
6031
- throw new Error("Anthropic API returned no text content");
6032
- }
6033
- return textBlock.text;
6034
- }
6035
- async function generateWithOpenAi(prompt, config) {
6036
- const response = await fetch("https://api.openai.com/v1/chat/completions", {
6037
- method: "POST",
6038
- headers: {
6039
- "Content-Type": "application/json",
6040
- "Authorization": `Bearer ${config.apiKey}`
6041
- },
6042
- body: JSON.stringify({
6043
- model: config.model,
6044
- messages: [
6045
- { role: "user", content: prompt }
6046
- ],
6047
- max_tokens: 4096
6048
- })
6049
- });
6050
- if (!response.ok) {
6051
- const body = await response.text();
6052
- throw new Error(`OpenAI API error (${response.status}): ${body}`);
6053
- }
6054
- const data = await response.json();
6055
- if (!data.choices?.[0]?.message?.content) {
6056
- throw new Error("OpenAI API returned no content");
6057
- }
6058
- return data.choices[0].message.content;
6059
- }
6060
-
6061
7100
  // src/generator/index.ts
6062
7101
  function parseViewportFlag2(value) {
6063
7102
  const match = /^(\d+)x(\d+)$/i.exec(value);
@@ -6101,6 +7140,298 @@ async function generateHunt(options) {
6101
7140
  return yamlStr;
6102
7141
  }
6103
7142
 
7143
+ // src/browser/macdriver-install.ts
7144
+ import { execFile as execFile5 } from "child_process";
7145
+ import { createHash as createHash2 } from "crypto";
7146
+ import fs15 from "fs";
7147
+ import os5 from "os";
7148
+ import path17 from "path";
7149
+ import { promisify } from "util";
7150
+ var execFileAsync = promisify(execFile5);
7151
+ function parseChecksumFile(text) {
7152
+ const token = text.trim().split(/\s+/, 1)[0] ?? "";
7153
+ const digest = token.toLowerCase();
7154
+ if (!/^[0-9a-f]{64}$/.test(digest)) {
7155
+ throw new Error(`Malformed .sha256 checksum file (expected a 64-char hex digest, got: ${text.trim().slice(0, 80)})`);
7156
+ }
7157
+ return digest;
7158
+ }
7159
+ function sha256Hex(bytes) {
7160
+ return createHash2("sha256").update(bytes).digest("hex");
7161
+ }
7162
+ var commandRunner = async (file, args) => execFileAsync(file, args);
7163
+ function commandOutput(result) {
7164
+ return [result.stdout, result.stderr].filter((value) => value !== void 0).map((value) => value.toString()).join("\n").trim();
7165
+ }
7166
+ function commandErrorDetail(error) {
7167
+ const err = error;
7168
+ return [err.stderr, err.stdout, err.message].filter((value) => value !== void 0 && value !== "").map((value) => value.toString()).join("\n").trim();
7169
+ }
7170
+ async function runRequiredCommand(run, binaryPath, command, args, label) {
7171
+ try {
7172
+ return await run(command, args);
7173
+ } catch (error) {
7174
+ const detail = commandErrorDetail(error);
7175
+ throw new Error(`${label} failed for ${binaryPath}${detail ? `: ${detail}` : ""}`);
7176
+ }
7177
+ }
7178
+ var zipinfoArchiveLister = async (zipPath) => {
7179
+ try {
7180
+ const { stdout } = await execFileAsync("zipinfo", ["-1", zipPath]);
7181
+ return stdout.toString().split(/\r?\n/).filter((entry) => entry.length > 0);
7182
+ } catch (error) {
7183
+ const detail = commandErrorDetail(error);
7184
+ throw new Error(`Failed to inspect release archive ${zipPath} with zipinfo${detail ? `: ${detail}` : ""}`);
7185
+ }
7186
+ };
7187
+ function normalizeArchiveEntryName(entry) {
7188
+ if (entry.length === 0 || entry !== entry.trim() || entry.includes("\0") || entry.includes("\\")) {
7189
+ throw new Error(`Unsafe path in prowl-macdriver release archive: ${JSON.stringify(entry)}`);
7190
+ }
7191
+ if (entry.endsWith("/")) {
7192
+ throw new Error(`Unexpected directory in prowl-macdriver release archive: ${entry}`);
7193
+ }
7194
+ if (path17.posix.isAbsolute(entry)) {
7195
+ throw new Error(`Unsafe absolute path in prowl-macdriver release archive: ${entry}`);
7196
+ }
7197
+ const parts = entry.split("/");
7198
+ if (parts.some((part) => part === "" || part === "." || part === "..")) {
7199
+ throw new Error(`Unsafe path in prowl-macdriver release archive: ${entry}`);
7200
+ }
7201
+ return entry;
7202
+ }
7203
+ function validateMacdriverArchiveEntries(entries) {
7204
+ const normalized = entries.map(normalizeArchiveEntryName);
7205
+ if (normalized.length !== 1 || normalized[0] !== HELPER_BINARY) {
7206
+ const shown = normalized.length > 0 ? normalized.join(", ") : "(empty archive)";
7207
+ throw new Error(
7208
+ `Unexpected prowl-macdriver release archive contents: ${shown}. Expected exactly "${HELPER_BINARY}" at the archive root.`
7209
+ );
7210
+ }
7211
+ }
7212
+ var dittoExtractor = async (zipPath, destDir) => {
7213
+ await execFileAsync("ditto", ["-x", "-k", zipPath, destDir]);
7214
+ };
7215
+ function parseCodesignDetails(text) {
7216
+ const details = { identifier: null, authorities: [], teamIdentifier: null };
7217
+ for (const line of text.split(/\r?\n/)) {
7218
+ const trimmed = line.trim();
7219
+ if (trimmed.startsWith("Identifier=")) {
7220
+ details.identifier = trimmed.slice("Identifier=".length);
7221
+ } else if (trimmed.startsWith("Authority=")) {
7222
+ details.authorities.push(trimmed.slice("Authority=".length));
7223
+ } else if (trimmed.startsWith("TeamIdentifier=")) {
7224
+ details.teamIdentifier = trimmed.slice("TeamIdentifier=".length);
7225
+ }
7226
+ }
7227
+ return details;
7228
+ }
7229
+ function validateCodesignDetails(details, binaryPath) {
7230
+ if (details.identifier !== MACDRIVER_SIGNING_IDENTIFIER) {
7231
+ throw new Error(
7232
+ `codesign verification failed for ${binaryPath}: expected identifier "${MACDRIVER_SIGNING_IDENTIFIER}", got "${details.identifier ?? "missing"}"`
7233
+ );
7234
+ }
7235
+ const developerIdAuthority = details.authorities.find(
7236
+ (authority) => authority.startsWith(`${MACDRIVER_SIGNING_AUTHORITY_PREFIX} (`)
7237
+ );
7238
+ const authorityTeamId = developerIdAuthority?.match(/\(([A-Z0-9]{10})\)$/)?.[1] ?? null;
7239
+ if (!developerIdAuthority || !authorityTeamId) {
7240
+ const shown = details.authorities.length > 0 ? details.authorities.join(" / ") : "missing";
7241
+ throw new Error(
7242
+ `codesign verification failed for ${binaryPath}: expected ${MACDRIVER_SIGNING_AUTHORITY_PREFIX} signer, got ${shown}`
7243
+ );
7244
+ }
7245
+ if (!details.teamIdentifier) {
7246
+ throw new Error(`codesign verification failed for ${binaryPath}: missing TeamIdentifier`);
7247
+ }
7248
+ if (details.teamIdentifier !== authorityTeamId) {
7249
+ throw new Error(
7250
+ `codesign verification failed for ${binaryPath}: TeamIdentifier ${details.teamIdentifier} does not match Developer ID authority team ${authorityTeamId}`
7251
+ );
7252
+ }
7253
+ }
7254
+ async function verifyMacdriverSignature(binaryPath, run = commandRunner) {
7255
+ await runRequiredCommand(run, binaryPath, "codesign", ["--verify", "--strict", binaryPath], "codesign verification");
7256
+ const display = await runRequiredCommand(
7257
+ run,
7258
+ binaryPath,
7259
+ "codesign",
7260
+ ["--display", "--verbose=4", binaryPath],
7261
+ "codesign detail inspection"
7262
+ );
7263
+ validateCodesignDetails(parseCodesignDetails(commandOutput(display)), binaryPath);
7264
+ await runRequiredCommand(
7265
+ run,
7266
+ binaryPath,
7267
+ "spctl",
7268
+ ["--assess", "--type", "execute", "--verbose=4", binaryPath],
7269
+ "spctl assessment"
7270
+ );
7271
+ }
7272
+ var codesignVerifier = async (binaryPath) => {
7273
+ await verifyMacdriverSignature(binaryPath);
7274
+ };
7275
+ async function downloadAndVerify(options = {}) {
7276
+ const version = options.version ?? MACDRIVER_VERSION;
7277
+ const fetchImpl = options.fetchImpl ?? fetch;
7278
+ const assetName = macdriverAssetName(version);
7279
+ const zipUrl = macdriverAssetUrl(assetName, version);
7280
+ const sumUrl = macdriverAssetUrl(macdriverChecksumName(version), version);
7281
+ const [zipRes, sumRes] = await Promise.all([
7282
+ fetchImpl(zipUrl, { redirect: "follow" }),
7283
+ fetchImpl(sumUrl, { redirect: "follow" })
7284
+ ]);
7285
+ if (zipRes.status === 404 || sumRes.status === 404) {
7286
+ throw new Error(
7287
+ `No published prowl-macdriver release for ${macdriverReleaseTag(version)} yet.
7288
+ The signed binary is cut by the maintainer; until then build from source:
7289
+ cd macdriver && swift build -c release`
7290
+ );
7291
+ }
7292
+ if (!zipRes.ok) {
7293
+ throw new Error(`Failed to download ${assetName} (HTTP ${zipRes.status}) from ${zipUrl}`);
7294
+ }
7295
+ if (!sumRes.ok) {
7296
+ throw new Error(`Failed to download the checksum (HTTP ${sumRes.status}) from ${sumUrl}`);
7297
+ }
7298
+ const zipBytes = Buffer.from(await zipRes.arrayBuffer());
7299
+ const expected = parseChecksumFile(await sumRes.text());
7300
+ const actual = sha256Hex(zipBytes);
7301
+ if (actual !== expected) {
7302
+ throw new Error(
7303
+ `Checksum mismatch for ${assetName}.
7304
+ expected: ${expected}
7305
+ actual: ${actual}
7306
+ The download was rejected and discarded; re-run the install, and report it if it repeats.`
7307
+ );
7308
+ }
7309
+ return zipBytes;
7310
+ }
7311
+ async function installMacdriver(options = {}) {
7312
+ const version = validateMacdriverVersion(options.version ?? MACDRIVER_VERSION);
7313
+ const homedir = options.homedir ?? os5.homedir();
7314
+ const listArchiveEntries = options.listArchiveEntries ?? zipinfoArchiveLister;
7315
+ const extract = options.extract ?? dittoExtractor;
7316
+ const verifySignature = options.verifySignature ?? codesignVerifier;
7317
+ const installRoot = macdriverInstallRoot(homedir);
7318
+ const versionDir = macdriverVersionDir(version, homedir);
7319
+ const binaryPath = macdriverInstalledBinary(version, homedir);
7320
+ if (!options.force && fs15.existsSync(binaryPath)) {
7321
+ return { version, binaryPath, alreadyInstalled: true };
7322
+ }
7323
+ const zipBytes = await downloadAndVerify({ version, fetchImpl: options.fetchImpl });
7324
+ fs15.mkdirSync(installRoot, { recursive: true });
7325
+ const stagingDir = fs15.mkdtempSync(path17.join(installRoot, `.tmp-${version}-`));
7326
+ const extractDir = path17.join(stagingDir, "extract");
7327
+ const zipPath = path17.join(stagingDir, macdriverAssetName(version));
7328
+ try {
7329
+ fs15.mkdirSync(extractDir);
7330
+ fs15.writeFileSync(zipPath, zipBytes);
7331
+ validateMacdriverArchiveEntries(await listArchiveEntries(zipPath));
7332
+ await extract(zipPath, extractDir);
7333
+ const stagedBinaryPath = path17.join(extractDir, HELPER_BINARY);
7334
+ assertExtractedHelper(extractDir, stagedBinaryPath);
7335
+ fs15.chmodSync(stagedBinaryPath, 493);
7336
+ await verifySignature(stagedBinaryPath);
7337
+ replaceVersionDir(versionDir, extractDir, installRoot);
7338
+ } finally {
7339
+ fs15.rmSync(stagingDir, { recursive: true, force: true });
7340
+ }
7341
+ return { version, binaryPath, alreadyInstalled: false };
7342
+ }
7343
+ function assertExtractedHelper(extractDir, binaryPath) {
7344
+ const entries = fs15.readdirSync(extractDir);
7345
+ if (!entries.includes(HELPER_BINARY)) {
7346
+ throw new Error(`The release archive did not contain a "${HELPER_BINARY}" binary.`);
7347
+ }
7348
+ if (entries.length !== 1 || entries[0] !== HELPER_BINARY) {
7349
+ const shown = entries.length > 0 ? entries.join(", ") : "(empty directory)";
7350
+ throw new Error(
7351
+ `Unexpected extracted prowl-macdriver archive contents: ${shown}. Expected exactly "${HELPER_BINARY}".`
7352
+ );
7353
+ }
7354
+ let stat = null;
7355
+ try {
7356
+ stat = fs15.lstatSync(binaryPath);
7357
+ } catch {
7358
+ }
7359
+ if (!stat?.isFile()) {
7360
+ throw new Error(`The release archive "${HELPER_BINARY}" entry is not a regular file.`);
7361
+ }
7362
+ }
7363
+ function replaceVersionDir(versionDir, stagedVersionDir, installRoot) {
7364
+ const backupDir = path17.join(installRoot, `.previous-${path17.basename(versionDir)}-${process.pid}-${Date.now()}`);
7365
+ let backedUp = false;
7366
+ try {
7367
+ if (fs15.existsSync(versionDir)) {
7368
+ fs15.renameSync(versionDir, backupDir);
7369
+ backedUp = true;
7370
+ }
7371
+ fs15.renameSync(stagedVersionDir, versionDir);
7372
+ if (backedUp) {
7373
+ fs15.rmSync(backupDir, { recursive: true, force: true });
7374
+ }
7375
+ } catch (error) {
7376
+ if (backedUp && !fs15.existsSync(versionDir) && fs15.existsSync(backupDir)) {
7377
+ fs15.renameSync(backupDir, versionDir);
7378
+ }
7379
+ throw error;
7380
+ }
7381
+ }
7382
+ var runVersionProbe = async (binaryPath) => {
7383
+ try {
7384
+ const { stdout } = await execFileAsync(binaryPath, ["version"], { timeout: 5e3 });
7385
+ const match = stdout.match(/prowl-macdriver\s+(\S+)/);
7386
+ return match ? match[1] : stdout.trim() || null;
7387
+ } catch {
7388
+ return null;
7389
+ }
7390
+ };
7391
+ async function collectMacdriverStatus(options = {}) {
7392
+ const env = options.env ?? process.env;
7393
+ const homedir = options.homedir ?? os5.homedir();
7394
+ const probe = options.probe ?? runVersionProbe;
7395
+ let resolved = null;
7396
+ try {
7397
+ const resolvedPath = resolveHelperBinary(env, { homedir });
7398
+ resolved = { path: resolvedPath, source: classifyResolvedSource(resolvedPath, env, homedir) };
7399
+ } catch {
7400
+ resolved = null;
7401
+ }
7402
+ const installed = [];
7403
+ const root = macdriverInstallRoot(homedir);
7404
+ if (fs15.existsSync(root)) {
7405
+ for (const entry of fs15.readdirSync(root, { withFileTypes: true })) {
7406
+ if (!entry.isDirectory()) continue;
7407
+ let binaryPath;
7408
+ try {
7409
+ binaryPath = macdriverInstalledBinary(entry.name, homedir);
7410
+ } catch {
7411
+ continue;
7412
+ }
7413
+ if (fs15.existsSync(binaryPath)) {
7414
+ installed.push({ version: entry.name, binaryPath });
7415
+ }
7416
+ }
7417
+ installed.sort((a, b) => a.version.localeCompare(b.version));
7418
+ }
7419
+ const probedVersion = resolved ? await probe(resolved.path) : null;
7420
+ return { resolved, pinnedVersion: MACDRIVER_VERSION, installed, probedVersion };
7421
+ }
7422
+ function classifyResolvedSource(resolvedPath, env, homedir) {
7423
+ if (env.PROWL_MACDRIVER_BIN && resolvedPath === env.PROWL_MACDRIVER_BIN) {
7424
+ return "env";
7425
+ }
7426
+ if (resolvedPath.startsWith(macdriverInstallRoot(homedir) + path17.sep)) {
7427
+ return "user-install";
7428
+ }
7429
+ return "source-build";
7430
+ }
7431
+ function tccGuidance() {
7432
+ return "macOS permissions: the app that hosts Prowl (your terminal \u2014 Terminal, iTerm, VS Code, \u2026)\nmust be granted, in System Settings \u2192 Privacy & Security:\n \u2022 Accessibility \u2014 required to drive the target app\n \u2022 Screen Recording \u2014 required for screenshots / visual baselines\nGrant both to the terminal app, not to prowl-macdriver itself, then re-run your hunt.";
7433
+ }
7434
+
6104
7435
  export {
6105
7436
  interpolateHunt,
6106
7437
  WEB_ONLY_STEP_TYPES,
@@ -6118,12 +7449,43 @@ export {
6118
7449
  createPlaywrightDriver,
6119
7450
  parseMacSelector,
6120
7451
  createMacDriver,
7452
+ HELPER_BINARY,
7453
+ MACDRIVER_VERSION,
7454
+ MACDRIVER_REPO,
7455
+ MACDRIVER_SIGNING_IDENTIFIER,
7456
+ MACDRIVER_SIGNING_AUTHORITY_PREFIX,
7457
+ MACDRIVER_VERSION_PATTERN,
7458
+ validateMacdriverVersion,
7459
+ macdriverReleaseTag,
7460
+ macdriverAssetName,
7461
+ macdriverChecksumName,
7462
+ macdriverAssetUrl,
7463
+ macdriverInstallRoot,
7464
+ macdriverVersionDir,
7465
+ macdriverInstalledBinary,
6121
7466
  macdriverBuildInstructions,
6122
7467
  resolveHelperBinary,
6123
7468
  DEFAULT_REQUEST_TIMEOUT_MS,
6124
7469
  SpawnMacHelperClient,
6125
7470
  launchMacSession,
6126
7471
  closeMacSession,
7472
+ decodeXmlEntities,
7473
+ parseXml,
7474
+ unquoteSelectorValue,
7475
+ quoteSelectorValue,
7476
+ parseNativeSelector,
7477
+ unwrapNativeTextSelector,
7478
+ qualifyResourceId,
7479
+ normalizeXcuiClassName,
7480
+ shortIosType,
7481
+ rankNativeSelectors,
7482
+ NATIVE_ATTRIBUTE_MAP,
7483
+ ANDROID_MATCH_DIALECT,
7484
+ IOS_MATCH_DIALECT,
7485
+ MACOS_MATCH_DIALECT,
7486
+ nodeMatchesSelector,
7487
+ matchNativeTree,
7488
+ parseSnapshot,
6127
7489
  ANDROID_KEYCODES,
6128
7490
  escapeUiSelectorArg,
6129
7491
  parseAndroidSelector,
@@ -6150,7 +7512,6 @@ export {
6150
7512
  closeAndroidSession,
6151
7513
  IOS_PRESS_KEYS,
6152
7514
  escapePredicateArg,
6153
- normalizeXcuiClassName,
6154
7515
  parseIosSelector,
6155
7516
  iosQueryToLocator,
6156
7517
  unwrapIosTextSelector,
@@ -6170,11 +7531,14 @@ export {
6170
7531
  waitForWdaReady,
6171
7532
  createWdaAgentClient,
6172
7533
  WDA_RUNNER_BUNDLE_ID,
6173
- WDA_RUNNER_APP_NAME,
7534
+ WDA_USE_PORT_ENV,
6174
7535
  defaultIosAgentConnector,
6175
7536
  resolveWdaProject,
6176
7537
  wdaCacheDir,
6177
- resolveWdaRunner,
7538
+ resolveWdaTestRun,
7539
+ injectUsePortIntoXctestrun,
7540
+ defaultWdaTestRunPreparer,
7541
+ wdaTestRunArgs,
6178
7542
  launchIosSession,
6179
7543
  closeIosSession,
6180
7544
  extractSelectorIntent,
@@ -6197,6 +7561,34 @@ export {
6197
7561
  DEFAULT_ANALYZE_TREE_DEPTH,
6198
7562
  rankMacSelectors,
6199
7563
  analyzeMacApp,
6200
- generateHunt
7564
+ ANDROID_INTERACTIVE_CLASSES,
7565
+ parseAndroidHierarchy,
7566
+ isAndroidInteractive,
7567
+ rankAndroidSelectors,
7568
+ androidNodeToNative,
7569
+ matchAndroidSelector,
7570
+ analyzeAndroidApp,
7571
+ IOS_INTERACTIVE_TYPES,
7572
+ IOS_WINDOW_TYPE,
7573
+ parseIosHierarchy,
7574
+ rankIosSelectors,
7575
+ iosNodeToNative,
7576
+ matchIosSelector,
7577
+ isIosInteractive,
7578
+ analyzeIosApp,
7579
+ generateHunt,
7580
+ parseChecksumFile,
7581
+ sha256Hex,
7582
+ zipinfoArchiveLister,
7583
+ validateMacdriverArchiveEntries,
7584
+ dittoExtractor,
7585
+ parseCodesignDetails,
7586
+ verifyMacdriverSignature,
7587
+ codesignVerifier,
7588
+ downloadAndVerify,
7589
+ installMacdriver,
7590
+ runVersionProbe,
7591
+ collectMacdriverStatus,
7592
+ tccGuidance
6201
7593
  };
6202
- //# sourceMappingURL=chunk-2KD2XCTH.js.map
7594
+ //# sourceMappingURL=chunk-R7NUH44M.js.map