prowl-tools 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ import {
7
7
  loadHunt,
8
8
  loadHuntTags,
9
9
  resolveViewport
10
- } from "./chunk-ITOSUJCN.js";
10
+ } from "./chunk-WHAMB4TY.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: {
@@ -958,6 +963,287 @@ async function closeMacSession(session) {
958
963
  }
959
964
  }
960
965
 
966
+ // src/analyzer/xml.ts
967
+ var ENTITIES = {
968
+ amp: "&",
969
+ lt: "<",
970
+ gt: ">",
971
+ quot: '"',
972
+ apos: "'"
973
+ };
974
+ function decodeXmlEntities(value) {
975
+ if (!value.includes("&")) {
976
+ return value;
977
+ }
978
+ return value.replace(/&(#(?:[xX][0-9a-fA-F]+|[0-9]+)|[a-zA-Z]+);/g, (match, code) => {
979
+ if (code[0] === "#") {
980
+ const hex = code[1] === "x" || code[1] === "X";
981
+ const num2 = Number.parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10);
982
+ return Number.isSafeInteger(num2) && num2 <= 1114111 ? String.fromCodePoint(num2) : match;
983
+ }
984
+ const named = ENTITIES[code];
985
+ return named ?? match;
986
+ });
987
+ }
988
+ var ATTR_RE = /([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
989
+ function parseTagBody(body) {
990
+ const trimmed = body.trim();
991
+ const nameMatch = /^([^\s/>]+)/.exec(trimmed);
992
+ const tag = nameMatch ? nameMatch[1] : "";
993
+ const attrs = {};
994
+ const rest = trimmed.slice(tag.length);
995
+ ATTR_RE.lastIndex = 0;
996
+ let m;
997
+ while ((m = ATTR_RE.exec(rest)) !== null) {
998
+ const rawValue = m[3] !== void 0 ? m[3] : m[4] ?? "";
999
+ attrs[m[1]] = decodeXmlEntities(rawValue);
1000
+ }
1001
+ return { tag, attrs };
1002
+ }
1003
+ function parseXml(input) {
1004
+ const n = input.length;
1005
+ const stack = [];
1006
+ let root = null;
1007
+ let i = 0;
1008
+ while (i < n) {
1009
+ const lt = input.indexOf("<", i);
1010
+ if (lt < 0) {
1011
+ break;
1012
+ }
1013
+ i = lt + 1;
1014
+ const ch = input[i];
1015
+ if (ch === "?") {
1016
+ const end = input.indexOf("?>", i);
1017
+ i = end < 0 ? n : end + 2;
1018
+ continue;
1019
+ }
1020
+ if (ch === "!") {
1021
+ if (input.startsWith("!--", i)) {
1022
+ const end = input.indexOf("-->", i);
1023
+ i = end < 0 ? n : end + 3;
1024
+ } else {
1025
+ const end = input.indexOf(">", i);
1026
+ i = end < 0 ? n : end + 1;
1027
+ }
1028
+ continue;
1029
+ }
1030
+ if (ch === "/") {
1031
+ const gt = input.indexOf(">", i);
1032
+ i = gt < 0 ? n : gt + 1;
1033
+ stack.pop();
1034
+ continue;
1035
+ }
1036
+ let j = i;
1037
+ let quote2 = null;
1038
+ while (j < n) {
1039
+ const c = input[j];
1040
+ if (quote2 !== null) {
1041
+ if (c === quote2) {
1042
+ quote2 = null;
1043
+ }
1044
+ } else if (c === '"' || c === "'") {
1045
+ quote2 = c;
1046
+ } else if (c === ">") {
1047
+ break;
1048
+ }
1049
+ j += 1;
1050
+ }
1051
+ const inner = input.slice(i, j);
1052
+ i = j + 1;
1053
+ const selfClose = inner.endsWith("/");
1054
+ const body = selfClose ? inner.slice(0, -1) : inner;
1055
+ const { tag, attrs } = parseTagBody(body);
1056
+ if (tag.length === 0) {
1057
+ continue;
1058
+ }
1059
+ const element = { tag, attrs, children: [] };
1060
+ const parent = stack[stack.length - 1];
1061
+ if (parent) {
1062
+ parent.children.push(element);
1063
+ }
1064
+ if (root === null) {
1065
+ root = element;
1066
+ }
1067
+ if (!selfClose) {
1068
+ stack.push(element);
1069
+ }
1070
+ }
1071
+ return root;
1072
+ }
1073
+
1074
+ // src/selector/native.ts
1075
+ function unquoteSelectorValue(value) {
1076
+ const trimmed = value.trim();
1077
+ const first = trimmed[0];
1078
+ if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
1079
+ return trimmed.slice(1, -1);
1080
+ }
1081
+ return trimmed;
1082
+ }
1083
+ function quoteSelectorValue(value) {
1084
+ return `"${value}"`;
1085
+ }
1086
+ var ROLE_SELECTOR_RE = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s;
1087
+ var NATIVE_SELECTOR_PREFIX_RE = /^(id|label|text|role)=/;
1088
+ function invalidSelectorMessage(selector, reason) {
1089
+ return `Invalid native selector ${JSON.stringify(selector)}: ${reason}.`;
1090
+ }
1091
+ function parseNativeSelector(selector) {
1092
+ const trimmed = selector.trim();
1093
+ if (trimmed.length === 0) {
1094
+ throw new Error(
1095
+ invalidSelectorMessage(
1096
+ selector,
1097
+ "selector is empty; use id=, label=, text=, role=, :focus, or a bare text value"
1098
+ )
1099
+ );
1100
+ }
1101
+ if (trimmed === ":focus") {
1102
+ return { kind: "focused" };
1103
+ }
1104
+ const idMatch = /^id=(.+)$/s.exec(trimmed);
1105
+ if (idMatch) {
1106
+ return { kind: "id", value: unquoteSelectorValue(idMatch[1]) };
1107
+ }
1108
+ const roleMatch = ROLE_SELECTOR_RE.exec(trimmed);
1109
+ if (roleMatch) {
1110
+ const name = roleMatch[2] !== void 0 ? unquoteSelectorValue(roleMatch[2]) : void 0;
1111
+ return name !== void 0 && name.length > 0 ? { kind: "role", role: roleMatch[1], name } : { kind: "role", role: roleMatch[1] };
1112
+ }
1113
+ const labelMatch = /^label=(.+)$/s.exec(trimmed);
1114
+ if (labelMatch) {
1115
+ return { kind: "label", value: unquoteSelectorValue(labelMatch[1]) };
1116
+ }
1117
+ const textMatch = /^text=(.+)$/s.exec(trimmed);
1118
+ if (textMatch) {
1119
+ return { kind: "text", value: unquoteSelectorValue(textMatch[1]) };
1120
+ }
1121
+ const prefix = NATIVE_SELECTOR_PREFIX_RE.exec(trimmed);
1122
+ if (prefix) {
1123
+ throw new Error(
1124
+ invalidSelectorMessage(
1125
+ selector,
1126
+ `malformed ${prefix[1]}= selector; expected id=<value>, label=<value>, text=<value>, or role=<Type>[name=<value>]`
1127
+ )
1128
+ );
1129
+ }
1130
+ return { kind: "text", value: trimmed };
1131
+ }
1132
+ function unwrapNativeTextSelector(selector) {
1133
+ const trimmed = selector.trim();
1134
+ if (!trimmed.startsWith("text=")) {
1135
+ return null;
1136
+ }
1137
+ return unquoteSelectorValue(trimmed.slice("text=".length));
1138
+ }
1139
+ function qualifyResourceId(value, appPackage) {
1140
+ if (value.includes(":") || !appPackage) {
1141
+ return value;
1142
+ }
1143
+ return `${appPackage}:id/${value}`;
1144
+ }
1145
+ function normalizeXcuiClassName(role) {
1146
+ return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
1147
+ }
1148
+ function shortIosType(type) {
1149
+ return type.startsWith("XCUIElementType") ? type.slice("XCUIElementType".length) : type;
1150
+ }
1151
+ function rankNativeSelectors(fields) {
1152
+ const selectors = [];
1153
+ if (fields.id) {
1154
+ selectors.push(`id=${fields.id}`);
1155
+ }
1156
+ if (fields.label) {
1157
+ selectors.push(`label=${quoteSelectorValue(fields.label)}`);
1158
+ }
1159
+ if (fields.role && fields.name) {
1160
+ selectors.push(`role=${fields.role}[name=${quoteSelectorValue(fields.name)}]`);
1161
+ }
1162
+ if (fields.name) {
1163
+ selectors.push(`text=${quoteSelectorValue(fields.name)}`);
1164
+ }
1165
+ if (selectors.length === 0 && fields.role) {
1166
+ selectors.push(`role=${fields.role}`);
1167
+ }
1168
+ return selectors;
1169
+ }
1170
+ var NATIVE_ATTRIBUTE_MAP = {
1171
+ android: {
1172
+ id: { attribute: "resource-id", match: "exact (package-qualified)" },
1173
+ label: { attribute: "content-desc", match: "exact" },
1174
+ text: { attribute: "text", match: "substring" },
1175
+ role: { attribute: "class", match: "exact" }
1176
+ },
1177
+ ios: {
1178
+ id: { attribute: "accessibility id (name)", match: "exact" },
1179
+ label: { attribute: "label", match: "exact" },
1180
+ text: { attribute: "label | value", match: "substring" },
1181
+ role: { attribute: "type (XCUIElementType\u2026)", match: "exact" }
1182
+ },
1183
+ macos: {
1184
+ id: { attribute: "AXIdentifier", match: "exact" },
1185
+ label: { attribute: "title | description", match: "exact" },
1186
+ text: { attribute: "title | description | value", match: "substring" },
1187
+ role: { attribute: "AXRole", match: "exact" }
1188
+ }
1189
+ };
1190
+ var ANDROID_MATCH_DIALECT = {
1191
+ platform: "android",
1192
+ normalizeRole: (role) => role,
1193
+ normalizeId: (value, options) => qualifyResourceId(value, options.appPackage)
1194
+ };
1195
+ var IOS_MATCH_DIALECT = {
1196
+ platform: "ios",
1197
+ normalizeRole: (role) => normalizeXcuiClassName(role),
1198
+ normalizeId: (value) => value
1199
+ };
1200
+ var MACOS_MATCH_DIALECT = {
1201
+ platform: "macos",
1202
+ normalizeRole: (role) => role,
1203
+ normalizeId: (value) => value
1204
+ };
1205
+ function nodeMatchesSelector(dialect, selector, node, options = {}) {
1206
+ switch (selector.kind) {
1207
+ case "id":
1208
+ return node.id !== void 0 && node.id === dialect.normalizeId(selector.value, options);
1209
+ case "label":
1210
+ return node.label !== void 0 && node.label === selector.value;
1211
+ case "text":
1212
+ return node.textValues.some((t) => t.includes(selector.value));
1213
+ case "focused":
1214
+ return node.focused === true;
1215
+ case "role": {
1216
+ if (node.role === void 0) {
1217
+ return false;
1218
+ }
1219
+ if (dialect.normalizeRole(node.role) !== dialect.normalizeRole(selector.role)) {
1220
+ return false;
1221
+ }
1222
+ if (selector.name === void 0 || selector.name.length === 0) {
1223
+ return true;
1224
+ }
1225
+ const name = selector.name;
1226
+ return node.textValues.some((t) => t.includes(name));
1227
+ }
1228
+ }
1229
+ }
1230
+ function matchNativeTree(dialect, selector, root, project, children, options = {}) {
1231
+ const out = [];
1232
+ const visit = (node) => {
1233
+ if (nodeMatchesSelector(dialect, selector, project(node), options)) {
1234
+ out.push(node);
1235
+ }
1236
+ for (const child of children(node)) {
1237
+ visit(child);
1238
+ }
1239
+ };
1240
+ visit(root);
1241
+ return out;
1242
+ }
1243
+ function parseSnapshot(xml) {
1244
+ return parseXml(xml);
1245
+ }
1246
+
961
1247
  // src/browser/android-driver.ts
962
1248
  import fs3 from "fs";
963
1249
  var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
@@ -993,50 +1279,29 @@ var ANDROID_KEYCODES = {
993
1279
  pageup: 92,
994
1280
  pagedown: 93
995
1281
  };
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
1282
  function escapeUiSelectorArg(value) {
1005
1283
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1006
1284
  }
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]) };
1285
+ function toAndroidQuery(selector) {
1286
+ switch (selector.kind) {
1287
+ case "focused":
1288
+ return { by: "focused" };
1289
+ case "id":
1290
+ return { by: "id", value: selector.value };
1291
+ case "role":
1292
+ return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
1293
+ case "label":
1294
+ return { by: "accessibilityId", value: selector.value };
1295
+ case "text":
1296
+ return { by: "text", value: selector.value };
1028
1297
  }
1029
- return { by: "text", value: trimmed };
1298
+ }
1299
+ function parseAndroidSelector(selector) {
1300
+ return toAndroidQuery(parseNativeSelector(selector));
1030
1301
  }
1031
1302
  function locator(strategy, selector) {
1032
1303
  return { strategy, selector, context: "" };
1033
1304
  }
1034
- function qualifyResourceId(value, appPackage) {
1035
- if (value.includes(":") || !appPackage) {
1036
- return value;
1037
- }
1038
- return `${appPackage}:id/${value}`;
1039
- }
1040
1305
  function androidQueryToLocator(query, options = {}) {
1041
1306
  switch (query.by) {
1042
1307
  case "id":
@@ -1063,11 +1328,7 @@ function androidQueryToLocator(query, options = {}) {
1063
1328
  }
1064
1329
  }
1065
1330
  function unwrapAndroidTextSelector(selector) {
1066
- const trimmed = selector.trim();
1067
- if (!trimmed.startsWith("text=")) {
1068
- return null;
1069
- }
1070
- return unquote2(trimmed.slice(5));
1331
+ return unwrapNativeTextSelector(selector);
1071
1332
  }
1072
1333
  function keyCodeFor(key) {
1073
1334
  const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
@@ -1342,22 +1603,63 @@ async function installApk(runner, serial, apkPath) {
1342
1603
  );
1343
1604
  }
1344
1605
  }
1606
+ function formatUnknownError(error) {
1607
+ return error instanceof Error ? error.message : String(error);
1608
+ }
1609
+ function runnerPhaseError(message, error) {
1610
+ return new Error(`${message}: ${formatUnknownError(error)}`, { cause: error });
1611
+ }
1612
+ function parseResolvedComponent(stdout, pkg) {
1613
+ const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
1614
+ for (let i = lines.length - 1; i >= 0; i--) {
1615
+ if (/^[\w.]+\/[\w.$]+$/.test(lines[i]) && lines[i].startsWith(`${pkg}/`)) {
1616
+ return lines[i];
1617
+ }
1618
+ }
1619
+ return null;
1620
+ }
1345
1621
  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)) {
1622
+ let resolved;
1623
+ try {
1624
+ resolved = await runner(
1625
+ withSerial(serial, [
1626
+ "shell",
1627
+ "cmd",
1628
+ "package",
1629
+ "resolve-activity",
1630
+ "--brief",
1631
+ "-c",
1632
+ "android.intent.category.LAUNCHER",
1633
+ pkg
1634
+ ]),
1635
+ { timeoutMs: 3e4 }
1636
+ );
1637
+ } catch (error) {
1638
+ throw runnerPhaseError(
1639
+ `Failed to resolve the launcher activity for Android package "${pkg}" via adb`,
1640
+ error
1641
+ );
1642
+ }
1643
+ const component = parseResolvedComponent(resolved.stdout, pkg);
1644
+ if (resolved.code !== 0 || !component) {
1645
+ throw new Error(
1646
+ `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?`
1647
+ );
1648
+ }
1649
+ let start;
1650
+ try {
1651
+ start = await runner(withSerial(serial, ["shell", "am", "start", "-n", component]), {
1652
+ timeoutMs: 3e4
1653
+ });
1654
+ } catch (error) {
1655
+ throw runnerPhaseError(
1656
+ `Failed to launch Android package "${pkg}" with \`am start\` (${component}) via adb`,
1657
+ error
1658
+ );
1659
+ }
1660
+ if (start.code !== 0 || /error|does not exist|cannot start/i.test(start.stdout + start.stderr)) {
1359
1661
  throw new Error(
1360
- `Failed to launch Android package "${pkg}" (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 400)}. Is it installed?`
1662
+ `Failed to launch Android package "${pkg}" (${component}, exit ${start.code}): ${(start.stdout + start.stderr).trim().slice(0, 400)}.`
1361
1663
  );
1362
1664
  }
1363
1665
  }
@@ -1575,6 +1877,13 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
1575
1877
  }
1576
1878
  return Buffer.from(value, "base64");
1577
1879
  },
1880
+ async source() {
1881
+ const value = await transport.request("GET", `${base}/source`);
1882
+ if (typeof value !== "string") {
1883
+ throw new Error("uiautomator2 /source did not return XML text; cannot analyze Android UI hierarchy");
1884
+ }
1885
+ return value;
1886
+ },
1578
1887
  async close() {
1579
1888
  await transport.request("DELETE", base).catch(() => void 0);
1580
1889
  }
@@ -1729,43 +2038,25 @@ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
1729
2038
  var WAIT_POLL_INTERVAL_MS2 = 250;
1730
2039
  var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
1731
2040
  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
2041
  function escapePredicateArg(value) {
1741
2042
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1742
2043
  }
1743
- function normalizeXcuiClassName(role) {
1744
- return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
2044
+ function toIosQuery(selector) {
2045
+ switch (selector.kind) {
2046
+ case "focused":
2047
+ return { by: "focused" };
2048
+ case "id":
2049
+ return { by: "accessibilityId", value: selector.value };
2050
+ case "role":
2051
+ return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
2052
+ case "label":
2053
+ return { by: "label", value: selector.value };
2054
+ case "text":
2055
+ return { by: "text", value: selector.value };
2056
+ }
1745
2057
  }
1746
2058
  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 };
2059
+ return toIosQuery(parseNativeSelector(selector));
1769
2060
  }
1770
2061
  function iosQueryToLocator(query) {
1771
2062
  switch (query.by) {
@@ -1797,11 +2088,7 @@ function iosQueryToLocator(query) {
1797
2088
  }
1798
2089
  }
1799
2090
  function unwrapIosTextSelector(selector) {
1800
- const trimmed = selector.trim();
1801
- if (!trimmed.startsWith("text=")) {
1802
- return null;
1803
- }
1804
- return unquote3(trimmed.slice(5));
2091
+ return unwrapNativeTextSelector(selector);
1805
2092
  }
1806
2093
  function delay2(ms) {
1807
2094
  return new Promise((resolve) => {
@@ -1960,11 +2247,24 @@ function createIosDriver(client, options) {
1960
2247
  }
1961
2248
 
1962
2249
  // src/browser/ios-simctl.ts
1963
- import { execFile as execFile3 } from "child_process";
2250
+ import { execFile as execFile3, spawn as spawn3 } from "child_process";
1964
2251
  import { mkdir, readFile, rm, writeFile } from "fs/promises";
1965
2252
  import net from "net";
1966
2253
  import os from "os";
1967
2254
  import path4 from "path";
2255
+ var spawnXcrunProcess = (args, options) => {
2256
+ const child = spawn3("xcrun", args, {
2257
+ stdio: "ignore",
2258
+ env: options?.env ? { ...process.env, ...options.env } : process.env
2259
+ });
2260
+ child.on("error", () => {
2261
+ });
2262
+ return {
2263
+ kill: () => {
2264
+ child.kill();
2265
+ }
2266
+ };
2267
+ };
1968
2268
  var DEFAULT_SIMULATOR_LOCK_ROOT = path4.join(os.tmpdir(), "prowl-ios-simulator-locks");
1969
2269
  var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
1970
2270
  var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
@@ -2401,6 +2701,13 @@ function createWdaAgentClient(transport, sessionId) {
2401
2701
  async homescreen() {
2402
2702
  await transport.request("POST", "/wda/homescreen", {});
2403
2703
  },
2704
+ async source() {
2705
+ const value = await transport.request("GET", "/source");
2706
+ if (typeof value !== "string") {
2707
+ throw new Error("WebDriverAgent /source did not return XML text; cannot analyze iOS UI hierarchy");
2708
+ }
2709
+ return value;
2710
+ },
2404
2711
  async close() {
2405
2712
  await transport.request("DELETE", base).catch(() => void 0);
2406
2713
  }
@@ -2409,12 +2716,13 @@ function createWdaAgentClient(transport, sessionId) {
2409
2716
 
2410
2717
  // src/browser/ios-helper.ts
2411
2718
  import { createRequire as createRequire2 } from "module";
2719
+ import { execFile as execFile4 } from "child_process";
2412
2720
  import fs5 from "fs";
2413
2721
  import os2 from "os";
2414
2722
  import path5 from "path";
2415
2723
  var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
2416
- var WDA_RUNNER_APP_NAME = "WebDriverAgentRunner-Runner.app";
2417
2724
  var WDA_USE_PORT_ENV = "USE_PORT";
2725
+ var PREPARED_XCTESTRUN_PREFIX = "prowl-wda-xctestrun-";
2418
2726
  var WDA_STARTUP_ATTEMPTS = 2;
2419
2727
  var defaultIosAgentConnector = async ({
2420
2728
  host,
@@ -2448,10 +2756,44 @@ function resolveWdaProject(requireFn = createRequire2(import.meta.url)) {
2448
2756
  function wdaCacheDir(wdaVersion, xcode, homeDir = os2.homedir()) {
2449
2757
  return path5.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
2450
2758
  }
2451
- function runnerAppPath(derivedDataPath) {
2452
- return path5.join(derivedDataPath, "Build", "Products", "Debug-iphonesimulator", WDA_RUNNER_APP_NAME);
2759
+ function productsDir(derivedDataPath) {
2760
+ return path5.join(derivedDataPath, "Build", "Products");
2761
+ }
2762
+ function findXctestrunIn(dir) {
2763
+ let entries;
2764
+ try {
2765
+ entries = fs5.readdirSync(dir);
2766
+ } catch {
2767
+ return null;
2768
+ }
2769
+ const match = entries.filter((name) => name.endsWith(".xctestrun") && !name.startsWith(PREPARED_XCTESTRUN_PREFIX)).sort()[0];
2770
+ return match ? path5.join(dir, match) : null;
2453
2771
  }
2454
- async function resolveWdaRunner(options = {}) {
2772
+ function resolveOverrideXctestrun(override) {
2773
+ if (!fs5.existsSync(override)) {
2774
+ throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
2775
+ }
2776
+ if (override.endsWith(".xctestrun")) {
2777
+ return override;
2778
+ }
2779
+ const candidates = [];
2780
+ const stat = fs5.statSync(override);
2781
+ if (override.endsWith(".app")) {
2782
+ candidates.push(path5.dirname(path5.dirname(override)));
2783
+ } else if (stat.isDirectory()) {
2784
+ candidates.push(override, productsDir(override));
2785
+ }
2786
+ for (const dir of candidates) {
2787
+ const found = findXctestrunIn(dir);
2788
+ if (found) {
2789
+ return found;
2790
+ }
2791
+ }
2792
+ throw new Error(
2793
+ `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\`.`
2794
+ );
2795
+ }
2796
+ async function resolveWdaTestRun(options = {}) {
2455
2797
  const runner = options.runner ?? execFileXcrunRunner;
2456
2798
  const env = options.env ?? process.env;
2457
2799
  const homeDir = options.homeDir ?? os2.homedir();
@@ -2459,10 +2801,7 @@ async function resolveWdaRunner(options = {}) {
2459
2801
  `));
2460
2802
  const override = env.PROWL_WDA_RUNNER;
2461
2803
  if (override) {
2462
- if (!fs5.existsSync(override)) {
2463
- throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
2464
- }
2465
- return override;
2804
+ return resolveOverrideXctestrun(override);
2466
2805
  }
2467
2806
  const { projectPath, version } = resolveWdaProject(options.requireFn);
2468
2807
  const xcode = await xcodeVersion(runner);
@@ -2472,9 +2811,9 @@ async function resolveWdaRunner(options = {}) {
2472
2811
  );
2473
2812
  }
2474
2813
  const cacheDir = wdaCacheDir(version, xcode, homeDir);
2475
- const cachedRunner = runnerAppPath(cacheDir);
2476
- if (fs5.existsSync(cachedRunner)) {
2477
- return cachedRunner;
2814
+ const cached = findXctestrunIn(productsDir(cacheDir));
2815
+ if (cached) {
2816
+ return cached;
2478
2817
  }
2479
2818
  log(
2480
2819
  `Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
@@ -2501,12 +2840,106 @@ async function resolveWdaRunner(options = {}) {
2501
2840
  `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
2841
  );
2503
2842
  }
2504
- if (!fs5.existsSync(cachedRunner)) {
2843
+ const built = findXctestrunIn(productsDir(cacheDir));
2844
+ if (!built) {
2505
2845
  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.`
2846
+ `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.`
2507
2847
  );
2508
2848
  }
2509
- return cachedRunner;
2849
+ return built;
2850
+ }
2851
+ function injectUsePortIntoXctestrun(plist, port) {
2852
+ const clone = structuredClone(plist);
2853
+ let injected = 0;
2854
+ const visit = (node) => {
2855
+ if (Array.isArray(node)) {
2856
+ for (const entry of node) {
2857
+ visit(entry);
2858
+ }
2859
+ return;
2860
+ }
2861
+ if (!node || typeof node !== "object") {
2862
+ return;
2863
+ }
2864
+ const obj = node;
2865
+ const isTarget = typeof obj.TestBundlePath === "string" || typeof obj.TestHostPath === "string";
2866
+ const existingEnv = obj.EnvironmentVariables && typeof obj.EnvironmentVariables === "object" && !Array.isArray(obj.EnvironmentVariables) ? obj.EnvironmentVariables : void 0;
2867
+ if (isTarget || existingEnv) {
2868
+ const env = existingEnv ?? {};
2869
+ env[WDA_USE_PORT_ENV] = String(port);
2870
+ obj.EnvironmentVariables = env;
2871
+ injected += 1;
2872
+ }
2873
+ for (const value of Object.values(obj)) {
2874
+ visit(value);
2875
+ }
2876
+ };
2877
+ visit(clone);
2878
+ if (injected === 0) {
2879
+ throw new Error(
2880
+ "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."
2881
+ );
2882
+ }
2883
+ return clone;
2884
+ }
2885
+ function runPlutil(args) {
2886
+ return new Promise((resolve, reject) => {
2887
+ execFile4(
2888
+ "plutil",
2889
+ args,
2890
+ { encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 },
2891
+ (error, stdout, stderr) => {
2892
+ if (error) {
2893
+ reject(
2894
+ new Error(
2895
+ `\`plutil ${args.join(" ")}\` failed: ${(stderr || error.message).slice(0, 400)}`
2896
+ )
2897
+ );
2898
+ return;
2899
+ }
2900
+ resolve(stdout ?? "");
2901
+ }
2902
+ );
2903
+ });
2904
+ }
2905
+ var defaultWdaTestRunPreparer = async ({ xctestrunPath, port }) => {
2906
+ const json = await runPlutil(["-convert", "json", "-o", "-", xctestrunPath]);
2907
+ let parsed;
2908
+ try {
2909
+ parsed = JSON.parse(json);
2910
+ } catch {
2911
+ throw new Error(`Could not parse the WebDriverAgent .xctestrun as JSON: ${xctestrunPath}`);
2912
+ }
2913
+ const injected = injectUsePortIntoXctestrun(parsed, port);
2914
+ const stem = `${PREPARED_XCTESTRUN_PREFIX}${port}-${process.pid}`;
2915
+ const outPath = path5.join(path5.dirname(xctestrunPath), `${stem}.xctestrun`);
2916
+ const jsonPath = path5.join(os2.tmpdir(), `${stem}.json`);
2917
+ fs5.writeFileSync(jsonPath, JSON.stringify(injected));
2918
+ try {
2919
+ await runPlutil(["-convert", "xml1", jsonPath, "-o", outPath]);
2920
+ } finally {
2921
+ fs5.rmSync(jsonPath, { force: true });
2922
+ }
2923
+ return outPath;
2924
+ };
2925
+ function cleanupPreparedTestRun(preparedPath) {
2926
+ if (!preparedPath) {
2927
+ return;
2928
+ }
2929
+ if (!path5.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
2930
+ return;
2931
+ }
2932
+ fs5.rmSync(preparedPath, { force: true });
2933
+ }
2934
+ function wdaTestRunArgs(xctestrunPath, udid) {
2935
+ return [
2936
+ "xcodebuild",
2937
+ "test-without-building",
2938
+ "-xctestrun",
2939
+ xctestrunPath,
2940
+ "-destination",
2941
+ `id=${udid}`
2942
+ ];
2510
2943
  }
2511
2944
  async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
2512
2945
  if (!looksLikeIosAppPath(app)) {
@@ -2537,6 +2970,8 @@ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
2537
2970
  }
2538
2971
  async function launchIosSession(options) {
2539
2972
  const runner = options.runner ?? execFileXcrunRunner;
2973
+ const spawner = options.spawner ?? spawnXcrunProcess;
2974
+ const preparer = options.testRunPreparer ?? defaultWdaTestRunPreparer;
2540
2975
  const portAllocator = options.portAllocator ?? findFreePort;
2541
2976
  const connector = options.agentConnector ?? defaultIosAgentConnector;
2542
2977
  const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
@@ -2560,14 +2995,14 @@ async function launchIosSession(options) {
2560
2995
  options.coldStart ?? false,
2561
2996
  options.allowedApps ?? []
2562
2997
  );
2563
- const wdaRunnerApp = options.wdaRunnerApp ?? await resolveWdaRunner({ runner, logger: options.logger });
2564
- await installApp(runner, udid, wdaRunnerApp);
2998
+ const baseTestRun = options.wdaTestRun ?? await resolveWdaTestRun({ runner, logger: options.logger });
2565
2999
  for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
2566
3000
  let client;
3001
+ let wdaProcess;
3002
+ let preparedTestRun;
2567
3003
  let tornDown = false;
2568
- let terminateWda = false;
2569
3004
  let terminateTarget = false;
2570
- let stage = "port";
3005
+ let stage = "prepare";
2571
3006
  const teardownAttempt = async () => {
2572
3007
  if (tornDown) {
2573
3008
  return;
@@ -2576,18 +3011,18 @@ async function launchIosSession(options) {
2576
3011
  if (client) {
2577
3012
  await client.close().catch(() => void 0);
2578
3013
  }
2579
- if (terminateWda) {
2580
- await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
2581
- }
3014
+ wdaProcess?.kill();
3015
+ await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
2582
3016
  if (terminateTarget) {
2583
3017
  await terminateApp(runner, udid, bundleId);
2584
3018
  }
3019
+ cleanupPreparedTestRun(preparedTestRun);
2585
3020
  };
2586
3021
  try {
2587
3022
  const port = await portAllocator();
3023
+ preparedTestRun = await preparer({ xctestrunPath: baseTestRun, port });
2588
3024
  stage = "wda-launch";
2589
- terminateWda = true;
2590
- await launchApp(runner, udid, WDA_RUNNER_BUNDLE_ID, { [WDA_USE_PORT_ENV]: String(port) });
3025
+ wdaProcess = spawner(wdaTestRunArgs(preparedTestRun, udid));
2591
3026
  stage = "target-launch";
2592
3027
  terminateTarget = true;
2593
3028
  await launchApp(runner, udid, bundleId);
@@ -3053,52 +3488,292 @@ function createRunPolicy(driver, options) {
3053
3488
  throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
3054
3489
  }
3055
3490
  }
3056
- function ensureAppAllowed(app) {
3057
- if (!allowedApps.includes(app)) {
3058
- throw new Error(`Interaction with disallowed app: ${app}`);
3059
- }
3491
+ function ensureAppAllowed(app) {
3492
+ if (!allowedApps.includes(app)) {
3493
+ throw new Error(`Interaction with disallowed app: ${app}`);
3494
+ }
3495
+ }
3496
+ function ensureLocationAllowed(activeDriver) {
3497
+ if (activeDriver.capabilities.has("navigate")) {
3498
+ ensureUrlAllowed(activeDriver.currentUrl());
3499
+ }
3500
+ }
3501
+ const healProbe = {
3502
+ locator: (selector) => ({ count: () => driver.count(selector) })
3503
+ };
3504
+ async function resolveActionSelector(selector) {
3505
+ assertAllowedSelector(selector);
3506
+ if (!selfHealing) {
3507
+ return { selector };
3508
+ }
3509
+ let matched = false;
3510
+ try {
3511
+ matched = await driver.count(selector) > 0;
3512
+ } catch {
3513
+ return { selector };
3514
+ }
3515
+ if (matched) {
3516
+ return { selector };
3517
+ }
3518
+ const healed = await healSelector(healProbe, selector, { enabled: true });
3519
+ if (!healed) {
3520
+ return { selector };
3521
+ }
3522
+ assertAllowedSelector(healed.selector);
3523
+ console.warn(
3524
+ `Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
3525
+ );
3526
+ return { selector: healed.selector, healedFrom: healed.healedFrom };
3527
+ }
3528
+ return {
3529
+ assertWithinMaxSteps,
3530
+ ensureUrlAllowed,
3531
+ ensureAppAllowed,
3532
+ ensureLocationAllowed,
3533
+ assertAllowedSelector,
3534
+ resolveActionSelector
3535
+ };
3536
+ }
3537
+
3538
+ // src/generator/ai.ts
3539
+ var DEFAULT_BASE_URL = {
3540
+ anthropic: "https://api.anthropic.com",
3541
+ openai: "https://api.openai.com"
3542
+ };
3543
+ var AI_REQUEST_TIMEOUT_MS = 3e4;
3544
+ function apiRoot(config) {
3545
+ return config.baseUrl ?? DEFAULT_BASE_URL[config.provider];
3546
+ }
3547
+ function defaultModelFor(provider) {
3548
+ return provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
3549
+ }
3550
+ function resolveAiEnv() {
3551
+ const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
3552
+ if (provider !== "anthropic" && provider !== "openai") {
3553
+ throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
3554
+ }
3555
+ const model = process.env.PROWL_AI_MODEL ?? defaultModelFor(provider);
3556
+ const baseUrl = normalizeBaseUrl(process.env.PROWL_AI_BASE_URL) ?? DEFAULT_BASE_URL[provider];
3557
+ return { provider, model, baseUrl, apiKey: process.env.PROWL_AI_KEY };
3558
+ }
3559
+ function normalizeBaseUrl(value) {
3560
+ if (value === void 0) return void 0;
3561
+ const trimmed = value.trim().replace(/\/+$/, "");
3562
+ return trimmed.length > 0 ? trimmed : void 0;
3563
+ }
3564
+ function resolveAiConfig() {
3565
+ const env = resolveAiEnv();
3566
+ if (!env.apiKey) {
3567
+ throw new Error(
3568
+ "PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
3569
+ );
3570
+ }
3571
+ return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
3572
+ }
3573
+ function tryResolveAiConfig() {
3574
+ const env = resolveAiEnv();
3575
+ if (!env.apiKey) {
3576
+ return null;
3577
+ }
3578
+ return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
3579
+ }
3580
+ async function generateWithAi(prompt, config) {
3581
+ if (config.provider === "anthropic") {
3582
+ return generateWithAnthropic(prompt, config);
3583
+ }
3584
+ return generateWithOpenAi(prompt, config);
3585
+ }
3586
+ async function generateWithAnthropic(prompt, config) {
3587
+ const data = await postJson(
3588
+ "Anthropic",
3589
+ `${apiRoot(config)}/v1/messages`,
3590
+ anthropicHeaders(config),
3591
+ {
3592
+ model: config.model,
3593
+ max_tokens: 4096,
3594
+ messages: [
3595
+ { role: "user", content: prompt }
3596
+ ]
3597
+ }
3598
+ );
3599
+ return extractAnthropicText(data);
3600
+ }
3601
+ async function generateWithOpenAi(prompt, config) {
3602
+ const data = await postJson(
3603
+ "OpenAI",
3604
+ `${apiRoot(config)}/v1/chat/completions`,
3605
+ openAiHeaders(config),
3606
+ {
3607
+ model: config.model,
3608
+ messages: [
3609
+ { role: "user", content: prompt }
3610
+ ],
3611
+ max_tokens: 4096
3612
+ }
3613
+ );
3614
+ return extractOpenAiText(data);
3615
+ }
3616
+ function buildVisionPrompt(assertion) {
3617
+ return [
3618
+ "You are a meticulous QA reviewer. You are given a screenshot of an application",
3619
+ "and a single assertion describing what should be true about it.",
3620
+ "",
3621
+ "Assertion:",
3622
+ assertion,
3623
+ "",
3624
+ "Decide whether the assertion holds for the screenshot. Judge ONLY what is",
3625
+ "visible; do not assume behavior you cannot see. Be strict: if the assertion",
3626
+ "is not clearly satisfied, it fails.",
3627
+ "",
3628
+ "Reply with ONLY a single JSON object on one line, no markdown, no code fences:",
3629
+ '{"pass": <true|false>, "reason": "<one concise sentence explaining the verdict>"}'
3630
+ ].join("\n");
3631
+ }
3632
+ function parseVisionVerdict(raw) {
3633
+ const cleaned = stripCodeFences(raw).trim();
3634
+ const candidate = extractJsonObject(cleaned);
3635
+ if (candidate === null) {
3636
+ throw new Error(
3637
+ `Could not parse a JSON verdict from the AI response: ${truncateForError(raw)}`
3638
+ );
3639
+ }
3640
+ let parsed;
3641
+ try {
3642
+ parsed = JSON.parse(candidate);
3643
+ } catch {
3644
+ throw new Error(
3645
+ `AI verdict was not valid JSON: ${truncateForError(raw)}`
3646
+ );
3060
3647
  }
3061
- function ensureLocationAllowed(activeDriver) {
3062
- if (activeDriver.capabilities.has("navigate")) {
3063
- ensureUrlAllowed(activeDriver.currentUrl());
3064
- }
3648
+ if (typeof parsed !== "object" || parsed === null) {
3649
+ throw new Error(`AI verdict was not a JSON object: ${truncateForError(raw)}`);
3065
3650
  }
3066
- const healProbe = {
3067
- locator: (selector) => ({ count: () => driver.count(selector) })
3068
- };
3069
- async function resolveActionSelector(selector) {
3070
- assertAllowedSelector(selector);
3071
- if (!selfHealing) {
3072
- return { selector };
3073
- }
3074
- let matched = false;
3075
- try {
3076
- matched = await driver.count(selector) > 0;
3077
- } catch {
3078
- return { selector };
3079
- }
3080
- if (matched) {
3081
- return { selector };
3651
+ const record = parsed;
3652
+ if (typeof record.pass !== "boolean") {
3653
+ throw new Error(
3654
+ `AI verdict is missing a boolean "pass" field: ${truncateForError(raw)}`
3655
+ );
3656
+ }
3657
+ const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : record.pass ? "Assertion satisfied." : "Assertion not satisfied.";
3658
+ return { pass: record.pass, reason };
3659
+ }
3660
+ function stripCodeFences(text) {
3661
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
3662
+ return fence ? fence[1] : text;
3663
+ }
3664
+ function extractJsonObject(text) {
3665
+ const trimmed = text.trim();
3666
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
3667
+ return trimmed;
3668
+ }
3669
+ const first = trimmed.indexOf("{");
3670
+ const last = trimmed.lastIndexOf("}");
3671
+ if (first === -1 || last === -1 || last <= first) {
3672
+ return null;
3673
+ }
3674
+ return trimmed.slice(first, last + 1);
3675
+ }
3676
+ function truncateForError(text) {
3677
+ const collapsed = text.replace(/\s+/g, " ").trim();
3678
+ return collapsed.length > 200 ? `${collapsed.slice(0, 199)}\u2026` : collapsed;
3679
+ }
3680
+ async function assertWithAiVision(input, config) {
3681
+ const raw = config.provider === "anthropic" ? await visionWithAnthropic(input, config) : await visionWithOpenAi(input, config);
3682
+ return parseVisionVerdict(raw);
3683
+ }
3684
+ async function visionWithAnthropic(input, config) {
3685
+ const data = await postJson(
3686
+ "Anthropic",
3687
+ `${apiRoot(config)}/v1/messages`,
3688
+ anthropicHeaders(config),
3689
+ {
3690
+ model: config.model,
3691
+ max_tokens: 1024,
3692
+ temperature: 0,
3693
+ messages: [
3694
+ {
3695
+ role: "user",
3696
+ content: [
3697
+ {
3698
+ type: "image",
3699
+ source: {
3700
+ type: "base64",
3701
+ media_type: input.mediaType,
3702
+ data: input.imageBase64
3703
+ }
3704
+ },
3705
+ { type: "text", text: buildVisionPrompt(input.assertion) }
3706
+ ]
3707
+ }
3708
+ ]
3082
3709
  }
3083
- const healed = await healSelector(healProbe, selector, { enabled: true });
3084
- if (!healed) {
3085
- return { selector };
3710
+ );
3711
+ return extractAnthropicText(data);
3712
+ }
3713
+ async function visionWithOpenAi(input, config) {
3714
+ const data = await postJson(
3715
+ "OpenAI",
3716
+ `${apiRoot(config)}/v1/chat/completions`,
3717
+ openAiHeaders(config),
3718
+ {
3719
+ model: config.model,
3720
+ max_tokens: 1024,
3721
+ temperature: 0,
3722
+ messages: [
3723
+ {
3724
+ role: "user",
3725
+ content: [
3726
+ { type: "text", text: buildVisionPrompt(input.assertion) },
3727
+ {
3728
+ type: "image_url",
3729
+ image_url: { url: `data:${input.mediaType};base64,${input.imageBase64}` }
3730
+ }
3731
+ ]
3732
+ }
3733
+ ]
3086
3734
  }
3087
- assertAllowedSelector(healed.selector);
3088
- console.warn(
3089
- `Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
3090
- );
3091
- return { selector: healed.selector, healedFrom: healed.healedFrom };
3735
+ );
3736
+ return extractOpenAiText(data);
3737
+ }
3738
+ async function postJson(provider, url, headers, body) {
3739
+ const response = await fetch(url, {
3740
+ method: "POST",
3741
+ headers,
3742
+ body: JSON.stringify(body),
3743
+ signal: AbortSignal.timeout(AI_REQUEST_TIMEOUT_MS)
3744
+ });
3745
+ if (!response.ok) {
3746
+ const responseBody = await response.text();
3747
+ throw new Error(`${provider} API error (${response.status}): ${responseBody}`);
3092
3748
  }
3749
+ return await response.json();
3750
+ }
3751
+ function anthropicHeaders(config) {
3093
3752
  return {
3094
- assertWithinMaxSteps,
3095
- ensureUrlAllowed,
3096
- ensureAppAllowed,
3097
- ensureLocationAllowed,
3098
- assertAllowedSelector,
3099
- resolveActionSelector
3753
+ "Content-Type": "application/json",
3754
+ "x-api-key": config.apiKey,
3755
+ "anthropic-version": "2023-06-01"
3100
3756
  };
3101
3757
  }
3758
+ function openAiHeaders(config) {
3759
+ return {
3760
+ "Content-Type": "application/json",
3761
+ "Authorization": `Bearer ${config.apiKey}`
3762
+ };
3763
+ }
3764
+ function extractAnthropicText(data) {
3765
+ const textBlock = data.content?.find((c) => c.type === "text");
3766
+ if (!textBlock?.text) {
3767
+ throw new Error("Anthropic API returned no text content");
3768
+ }
3769
+ return textBlock.text;
3770
+ }
3771
+ function extractOpenAiText(data) {
3772
+ if (!data.choices?.[0]?.message?.content) {
3773
+ throw new Error("OpenAI API returned no content");
3774
+ }
3775
+ return data.choices[0].message.content;
3776
+ }
3102
3777
 
3103
3778
  // src/runner/steps.ts
3104
3779
  function getStepType(step) {
@@ -3128,6 +3803,7 @@ function getStepType(step) {
3128
3803
  if ("evalScript" in step) return "evalScript";
3129
3804
  if ("runScript" in step) return "runScript";
3130
3805
  if ("assertScreenshot" in step) return "assertScreenshot";
3806
+ if ("assertWithAI" in step) return "assertWithAI";
3131
3807
  if ("copyText" in step) return "copyText";
3132
3808
  if ("waitForDownload" in step) return "waitForDownload";
3133
3809
  return "step";
@@ -3187,6 +3863,9 @@ function applyRuntimeVars(step, vars) {
3187
3863
  }
3188
3864
  };
3189
3865
  }
3866
+ if ("assertWithAI" in step) {
3867
+ return { assertWithAI: sub(step.assertWithAI) };
3868
+ }
3190
3869
  if ("copyText" in step) {
3191
3870
  return { copyText: { selector: sub(step.copyText.selector), as: step.copyText.as } };
3192
3871
  }
@@ -4120,6 +4799,48 @@ var STEP_HANDLERS = {
4120
4799
  );
4121
4800
  }
4122
4801
  },
4802
+ assertWithAI: {
4803
+ capabilities: ["screenshot"],
4804
+ run: async (h) => {
4805
+ if (!("assertWithAI" in h.step)) unknownStep();
4806
+ const assertion = h.step.assertWithAI;
4807
+ const resolve = h.context.resolveAiConfig ?? tryResolveAiConfig;
4808
+ const aiConfig = resolve();
4809
+ if (!aiConfig) {
4810
+ return {
4811
+ kind: "result",
4812
+ result: {
4813
+ type: "assertWithAI",
4814
+ status: "warn",
4815
+ durationMs: Date.now() - h.stepStart,
4816
+ value: `skipped: no AI provider configured (set PROWL_AI_KEY to enable) \u2014 "${assertion}"`
4817
+ }
4818
+ };
4819
+ }
4820
+ const fileName = `assertWithAI_step_${h.index + 1}.png`;
4821
+ const relative = await h.addScreenshot(fileName);
4822
+ const screenshotFullPath = path8.join(h.context.runDir, relative);
4823
+ const imageBase64 = fs8.readFileSync(screenshotFullPath).toString("base64");
4824
+ const assertVision = h.context.assertVision ?? assertWithAiVision;
4825
+ const verdict = await assertVision(
4826
+ { imageBase64, mediaType: "image/png", assertion },
4827
+ aiConfig
4828
+ );
4829
+ if (verdict.pass) {
4830
+ return {
4831
+ kind: "result",
4832
+ result: {
4833
+ type: "assertWithAI",
4834
+ status: "pass",
4835
+ durationMs: Date.now() - h.stepStart,
4836
+ value: verdict.reason,
4837
+ screenshot: relative
4838
+ }
4839
+ };
4840
+ }
4841
+ throw new Error(`AI assertion failed: ${verdict.reason}`);
4842
+ }
4843
+ },
4123
4844
  copyText: {
4124
4845
  capabilities: ["query"],
4125
4846
  run: async (h) => {
@@ -5882,6 +6603,273 @@ async function readStatusMenu(client, menuTimeoutSeconds) {
5882
6603
  }
5883
6604
  }
5884
6605
 
6606
+ // src/analyzer/android.ts
6607
+ var ANDROID_INTERACTIVE_CLASSES = /* @__PURE__ */ new Set([
6608
+ "android.widget.Button",
6609
+ "android.widget.ImageButton",
6610
+ "android.widget.EditText",
6611
+ "android.widget.CheckBox",
6612
+ "android.widget.RadioButton",
6613
+ "android.widget.Switch",
6614
+ "android.widget.ToggleButton",
6615
+ "android.widget.Spinner",
6616
+ "android.widget.SeekBar",
6617
+ "android.widget.RatingBar",
6618
+ "android.widget.CompoundButton",
6619
+ "android.widget.AutoCompleteTextView",
6620
+ "android.widget.MultiAutoCompleteTextView",
6621
+ "android.widget.CheckedTextView",
6622
+ "androidx.appcompat.widget.SwitchCompat",
6623
+ "androidx.appcompat.widget.AppCompatButton",
6624
+ "androidx.appcompat.widget.AppCompatEditText"
6625
+ ]);
6626
+ function str2(value) {
6627
+ if (value === void 0) {
6628
+ return void 0;
6629
+ }
6630
+ const trimmed = value.trim();
6631
+ return trimmed.length > 0 ? trimmed : void 0;
6632
+ }
6633
+ function bool(value) {
6634
+ if (value === void 0) {
6635
+ return void 0;
6636
+ }
6637
+ return value === "true";
6638
+ }
6639
+ function toAndroidNode(element) {
6640
+ const a = element.attrs;
6641
+ return {
6642
+ className: str2(a["class"]),
6643
+ resourceId: str2(a["resource-id"]),
6644
+ contentDesc: str2(a["content-desc"]),
6645
+ text: str2(a.text),
6646
+ package: str2(a.package),
6647
+ clickable: bool(a.clickable),
6648
+ longClickable: bool(a["long-clickable"]),
6649
+ checkable: bool(a.checkable),
6650
+ checked: bool(a.checked),
6651
+ scrollable: bool(a.scrollable),
6652
+ focusable: bool(a.focusable),
6653
+ focused: bool(a.focused),
6654
+ enabled: bool(a.enabled),
6655
+ children: element.children.map(toAndroidNode)
6656
+ };
6657
+ }
6658
+ function parseAndroidHierarchy(xml) {
6659
+ const root = parseXml(xml);
6660
+ return root ? toAndroidNode(root) : null;
6661
+ }
6662
+ function isAndroidInteractive(node) {
6663
+ if (node.clickable || node.longClickable || node.checkable || node.scrollable) {
6664
+ return true;
6665
+ }
6666
+ return node.className !== void 0 && ANDROID_INTERACTIVE_CLASSES.has(node.className);
6667
+ }
6668
+ function rankAndroidSelectors(node) {
6669
+ return rankNativeSelectors({
6670
+ id: node.resourceId,
6671
+ label: node.contentDesc,
6672
+ role: node.className,
6673
+ name: node.text
6674
+ });
6675
+ }
6676
+ function androidNodeToNative(node) {
6677
+ return {
6678
+ ...node.resourceId !== void 0 ? { id: node.resourceId } : {},
6679
+ ...node.contentDesc !== void 0 ? { label: node.contentDesc } : {},
6680
+ ...node.className !== void 0 ? { role: node.className } : {},
6681
+ textValues: node.text !== void 0 ? [node.text] : [],
6682
+ ...node.focused !== void 0 ? { focused: node.focused } : {}
6683
+ };
6684
+ }
6685
+ function matchAndroidSelector(xml, selector, options = {}) {
6686
+ const parsedSelector = parseNativeSelector(selector);
6687
+ const root = parseAndroidHierarchy(xml);
6688
+ if (!root) {
6689
+ return [];
6690
+ }
6691
+ return matchNativeTree(
6692
+ ANDROID_MATCH_DIALECT,
6693
+ parsedSelector,
6694
+ root,
6695
+ androidNodeToNative,
6696
+ (node) => node.children,
6697
+ options
6698
+ );
6699
+ }
6700
+ function toElement2(node) {
6701
+ return {
6702
+ className: node.className ?? "?",
6703
+ ...node.resourceId ? { resourceId: node.resourceId } : {},
6704
+ ...node.contentDesc ? { contentDesc: node.contentDesc } : {},
6705
+ ...node.text ? { text: node.text } : {},
6706
+ ...node.clickable !== void 0 ? { clickable: node.clickable } : {},
6707
+ ...node.checkable !== void 0 ? { checkable: node.checkable } : {},
6708
+ ...node.scrollable !== void 0 ? { scrollable: node.scrollable } : {},
6709
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
6710
+ selectors: rankAndroidSelectors(node)
6711
+ };
6712
+ }
6713
+ function collectInteractive2(root) {
6714
+ const out = [];
6715
+ const visit = (node) => {
6716
+ if (isAndroidInteractive(node)) {
6717
+ out.push(toElement2(node));
6718
+ }
6719
+ for (const child of node.children) {
6720
+ visit(child);
6721
+ }
6722
+ };
6723
+ visit(root);
6724
+ return out;
6725
+ }
6726
+ async function analyzeAndroidApp(client, options) {
6727
+ const xml = await client.source();
6728
+ const root = parseAndroidHierarchy(xml);
6729
+ const elements = root ? collectInteractive2(root) : [];
6730
+ return { app: options.app, elements };
6731
+ }
6732
+
6733
+ // src/analyzer/ios.ts
6734
+ var IOS_INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
6735
+ "XCUIElementTypeButton",
6736
+ "XCUIElementTypeCell",
6737
+ "XCUIElementTypeTextField",
6738
+ "XCUIElementTypeSecureTextField",
6739
+ "XCUIElementTypeSearchField",
6740
+ "XCUIElementTypeSwitch",
6741
+ "XCUIElementTypeToggle",
6742
+ "XCUIElementTypeLink",
6743
+ "XCUIElementTypeMenuItem",
6744
+ "XCUIElementTypeSlider",
6745
+ "XCUIElementTypeStepper",
6746
+ "XCUIElementTypeTextView",
6747
+ "XCUIElementTypePickerWheel",
6748
+ "XCUIElementTypeTab",
6749
+ "XCUIElementTypeSegmentedControl",
6750
+ "XCUIElementTypeCheckBox",
6751
+ "XCUIElementTypeRadioButton",
6752
+ "XCUIElementTypeKey"
6753
+ ]);
6754
+ var IOS_WINDOW_TYPE = "XCUIElementTypeWindow";
6755
+ function str3(value) {
6756
+ if (value === void 0) {
6757
+ return void 0;
6758
+ }
6759
+ const trimmed = value.trim();
6760
+ return trimmed.length > 0 ? trimmed : void 0;
6761
+ }
6762
+ function bool2(value) {
6763
+ if (value === void 0) {
6764
+ return void 0;
6765
+ }
6766
+ return value === "true" || value === "1";
6767
+ }
6768
+ function toIosNode(element) {
6769
+ const a = element.attrs;
6770
+ return {
6771
+ type: str3(a.type) ?? str3(element.tag),
6772
+ name: str3(a.name),
6773
+ label: str3(a.label),
6774
+ value: str3(a.value),
6775
+ enabled: bool2(a.enabled),
6776
+ visible: bool2(a.visible),
6777
+ children: element.children.map(toIosNode)
6778
+ };
6779
+ }
6780
+ function parseIosHierarchy(xml) {
6781
+ const root = parseXml(xml);
6782
+ return root ? toIosNode(root) : null;
6783
+ }
6784
+ function hasAccessibilityId(node) {
6785
+ return node.name !== void 0 && node.name !== node.label;
6786
+ }
6787
+ function rankIosSelectors(node) {
6788
+ return rankNativeSelectors({
6789
+ id: hasAccessibilityId(node) ? node.name : void 0,
6790
+ label: node.label,
6791
+ role: node.type ? shortIosType(node.type) : void 0,
6792
+ name: node.label ?? node.value
6793
+ });
6794
+ }
6795
+ function iosNodeToNative(node) {
6796
+ const textValues = [];
6797
+ if (node.label !== void 0) {
6798
+ textValues.push(node.label);
6799
+ }
6800
+ if (node.value !== void 0) {
6801
+ textValues.push(node.value);
6802
+ }
6803
+ return {
6804
+ ...node.name !== void 0 ? { id: node.name } : {},
6805
+ ...node.label !== void 0 ? { label: node.label } : {},
6806
+ ...node.type !== void 0 ? { role: node.type } : {},
6807
+ textValues
6808
+ };
6809
+ }
6810
+ function matchIosSelector(xml, selector) {
6811
+ const parsedSelector = parseNativeSelector(selector);
6812
+ const root = parseIosHierarchy(xml);
6813
+ if (!root) {
6814
+ return [];
6815
+ }
6816
+ return matchNativeTree(
6817
+ IOS_MATCH_DIALECT,
6818
+ parsedSelector,
6819
+ root,
6820
+ iosNodeToNative,
6821
+ (node) => node.children
6822
+ );
6823
+ }
6824
+ function toElement3(node) {
6825
+ return {
6826
+ type: node.type ?? "?",
6827
+ ...node.name ? { name: node.name } : {},
6828
+ ...node.label ? { label: node.label } : {},
6829
+ ...node.value ? { value: node.value } : {},
6830
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
6831
+ ...node.visible !== void 0 ? { visible: node.visible } : {},
6832
+ selectors: rankIosSelectors(node)
6833
+ };
6834
+ }
6835
+ function toWindow2(node) {
6836
+ const [best] = rankIosSelectors(node);
6837
+ return {
6838
+ ...node.name ? { name: node.name } : {},
6839
+ ...node.label ? { label: node.label } : {},
6840
+ selector: best ?? `role=${shortIosType(IOS_WINDOW_TYPE)}`
6841
+ };
6842
+ }
6843
+ function isIosInteractive(node) {
6844
+ return node.type !== void 0 && IOS_INTERACTIVE_TYPES.has(node.type);
6845
+ }
6846
+ function collect(root) {
6847
+ const elements = [];
6848
+ const windows = [];
6849
+ const visit = (node) => {
6850
+ if (node.type === IOS_WINDOW_TYPE) {
6851
+ windows.push(toWindow2(node));
6852
+ }
6853
+ if (isIosInteractive(node)) {
6854
+ elements.push(toElement3(node));
6855
+ }
6856
+ for (const child of node.children) {
6857
+ visit(child);
6858
+ }
6859
+ };
6860
+ visit(root);
6861
+ return { elements, windows };
6862
+ }
6863
+ async function analyzeIosApp(client, options) {
6864
+ const xml = await client.source();
6865
+ const root = parseIosHierarchy(xml);
6866
+ if (!root) {
6867
+ return { app: options.app, elements: [], windows: [] };
6868
+ }
6869
+ const { elements, windows } = collect(root);
6870
+ return { app: options.app, elements, windows };
6871
+ }
6872
+
5885
6873
  // src/generator/index.ts
5886
6874
  import yaml from "yaml";
5887
6875
 
@@ -5942,6 +6930,7 @@ var STEP_REFERENCE = `
5942
6930
 
5943
6931
  ### Visual Regression
5944
6932
  - assertScreenshot: { name: "baseline-name", threshold: 0.1 }
6933
+ - assertWithAI: "The login form should show email and password fields" \u2014 AI checks the screenshot against the claim
5945
6934
 
5946
6935
  ### Control Flow
5947
6936
  - if: { visible: ".banner", then: [steps...] }
@@ -5983,81 +6972,6 @@ function extractYamlFromResponse(response) {
5983
6972
  return response.trim();
5984
6973
  }
5985
6974
 
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
6975
  // src/generator/index.ts
6062
6976
  function parseViewportFlag2(value) {
6063
6977
  const match = /^(\d+)x(\d+)$/i.exec(value);
@@ -6124,6 +7038,23 @@ export {
6124
7038
  SpawnMacHelperClient,
6125
7039
  launchMacSession,
6126
7040
  closeMacSession,
7041
+ decodeXmlEntities,
7042
+ parseXml,
7043
+ unquoteSelectorValue,
7044
+ quoteSelectorValue,
7045
+ parseNativeSelector,
7046
+ unwrapNativeTextSelector,
7047
+ qualifyResourceId,
7048
+ normalizeXcuiClassName,
7049
+ shortIosType,
7050
+ rankNativeSelectors,
7051
+ NATIVE_ATTRIBUTE_MAP,
7052
+ ANDROID_MATCH_DIALECT,
7053
+ IOS_MATCH_DIALECT,
7054
+ MACOS_MATCH_DIALECT,
7055
+ nodeMatchesSelector,
7056
+ matchNativeTree,
7057
+ parseSnapshot,
6127
7058
  ANDROID_KEYCODES,
6128
7059
  escapeUiSelectorArg,
6129
7060
  parseAndroidSelector,
@@ -6150,7 +7081,6 @@ export {
6150
7081
  closeAndroidSession,
6151
7082
  IOS_PRESS_KEYS,
6152
7083
  escapePredicateArg,
6153
- normalizeXcuiClassName,
6154
7084
  parseIosSelector,
6155
7085
  iosQueryToLocator,
6156
7086
  unwrapIosTextSelector,
@@ -6170,11 +7100,14 @@ export {
6170
7100
  waitForWdaReady,
6171
7101
  createWdaAgentClient,
6172
7102
  WDA_RUNNER_BUNDLE_ID,
6173
- WDA_RUNNER_APP_NAME,
7103
+ WDA_USE_PORT_ENV,
6174
7104
  defaultIosAgentConnector,
6175
7105
  resolveWdaProject,
6176
7106
  wdaCacheDir,
6177
- resolveWdaRunner,
7107
+ resolveWdaTestRun,
7108
+ injectUsePortIntoXctestrun,
7109
+ defaultWdaTestRunPreparer,
7110
+ wdaTestRunArgs,
6178
7111
  launchIosSession,
6179
7112
  closeIosSession,
6180
7113
  extractSelectorIntent,
@@ -6197,6 +7130,21 @@ export {
6197
7130
  DEFAULT_ANALYZE_TREE_DEPTH,
6198
7131
  rankMacSelectors,
6199
7132
  analyzeMacApp,
7133
+ ANDROID_INTERACTIVE_CLASSES,
7134
+ parseAndroidHierarchy,
7135
+ isAndroidInteractive,
7136
+ rankAndroidSelectors,
7137
+ androidNodeToNative,
7138
+ matchAndroidSelector,
7139
+ analyzeAndroidApp,
7140
+ IOS_INTERACTIVE_TYPES,
7141
+ IOS_WINDOW_TYPE,
7142
+ parseIosHierarchy,
7143
+ rankIosSelectors,
7144
+ iosNodeToNative,
7145
+ matchIosSelector,
7146
+ isIosInteractive,
7147
+ analyzeIosApp,
6200
7148
  generateHunt
6201
7149
  };
6202
- //# sourceMappingURL=chunk-2KD2XCTH.js.map
7150
+ //# sourceMappingURL=chunk-5KQR3IR3.js.map