prowl-tools 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ import {
7
7
  loadHunt,
8
8
  loadHuntTags,
9
9
  resolveViewport
10
- } from "./chunk-MBAIGNVO.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: {
@@ -409,15 +414,25 @@ function webOnlyReason(step) {
409
414
  }
410
415
  return null;
411
416
  }
417
+ function nativeTargetLabel(target) {
418
+ if (target === "android") {
419
+ return "Android";
420
+ }
421
+ if (target === "ios") {
422
+ return "iOS";
423
+ }
424
+ return "macOS";
425
+ }
412
426
  function assertStepsSupportedByTarget(steps, target) {
413
- if (target !== "macos") {
427
+ if (target === "web") {
414
428
  return;
415
429
  }
430
+ const label = nativeTargetLabel(target);
416
431
  for (const step of steps) {
417
432
  const reason = webOnlyReason(step);
418
433
  if (reason) {
419
434
  throw new Error(
420
- `Step "${reason}" is not supported by the macOS target. It is web-only; use a portable step (click, fill, type, press, wait, assert visible, screenshot, etc.).`
435
+ `Step "${reason}" is not supported by the ${label} target. It is web-only; use a portable step (click, fill, type, press, wait, assert visible, screenshot, etc.).`
421
436
  );
422
437
  }
423
438
  if ("if" in step) {
@@ -432,20 +447,34 @@ function assertStepsSupportedByTarget(steps, target) {
432
447
  }
433
448
  }
434
449
  function assertHuntAssertionsSupportedByTarget(assertions, target) {
435
- if (target !== "macos" || !assertions || assertions.length === 0) {
450
+ if (target === "web" || !assertions || assertions.length === 0) {
436
451
  return;
437
452
  }
438
453
  throw new Error(
439
- "Hunt-level assertions are not supported by the macOS target. Use inline assert visible/notVisible steps instead."
454
+ `Hunt-level assertions are not supported by the ${nativeTargetLabel(target)} target. Use inline assert visible/notVisible steps instead.`
440
455
  );
441
456
  }
442
457
  function trimTrailingPathSeparators(value) {
443
458
  return value.replace(/[\\/]+$/g, "");
444
459
  }
445
- function looksLikeMacosAppPath(app) {
460
+ function looksLikeAppBundlePath(app) {
446
461
  const trimmed = trimTrailingPathSeparators(app);
447
462
  return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
448
463
  }
464
+ function looksLikeIosAppPath(app) {
465
+ const trimmed = trimTrailingPathSeparators(app);
466
+ if (trimmed.includes("/") || trimmed.includes("\\")) {
467
+ return true;
468
+ }
469
+ if (trimmed.toLowerCase().endsWith(".app")) {
470
+ try {
471
+ return fs.statSync(normalizeAppPath(app)).isDirectory();
472
+ } catch {
473
+ return false;
474
+ }
475
+ }
476
+ return false;
477
+ }
449
478
  function normalizeAppPath(app) {
450
479
  return path.resolve(trimTrailingPathSeparators(app));
451
480
  }
@@ -453,8 +482,8 @@ function parseBundleIdentifier(plist) {
453
482
  const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
454
483
  return match?.[1]?.trim() || null;
455
484
  }
456
- function readBundleIdentifier(appPath) {
457
- const infoPlistPath = path.join(normalizeAppPath(appPath), "Contents", "Info.plist");
485
+ function readBundleIdentifier(appPath, ...plistSubPath) {
486
+ const infoPlistPath = path.join(normalizeAppPath(appPath), ...plistSubPath);
458
487
  if (!fs.existsSync(infoPlistPath)) {
459
488
  return null;
460
489
  }
@@ -479,9 +508,15 @@ function readBundleIdentifier(appPath) {
479
508
  return null;
480
509
  }
481
510
  }
482
- function macosAppAllowedIdentities(app) {
511
+ function readMacosBundleIdentifier(appPath) {
512
+ return readBundleIdentifier(appPath, "Contents", "Info.plist");
513
+ }
514
+ function readIosBundleIdentifier(appPath) {
515
+ return readBundleIdentifier(appPath, "Info.plist");
516
+ }
517
+ function appBundleAllowedIdentities(app, readBundleId, isAppPath = looksLikeAppBundlePath) {
483
518
  const identities = /* @__PURE__ */ new Set([app]);
484
- if (looksLikeMacosAppPath(app)) {
519
+ if (isAppPath(app)) {
485
520
  const normalizedPath = normalizeAppPath(app);
486
521
  identities.add(trimTrailingPathSeparators(app));
487
522
  identities.add(normalizedPath);
@@ -489,19 +524,56 @@ function macosAppAllowedIdentities(app) {
489
524
  if (bundleName) {
490
525
  identities.add(bundleName);
491
526
  }
492
- const bundleId = readBundleIdentifier(app);
527
+ const bundleId = readBundleId(app);
493
528
  if (bundleId) {
494
529
  identities.add(bundleId);
495
530
  }
496
531
  }
497
532
  return [...identities];
498
533
  }
534
+ function macosAppAllowedIdentities(app) {
535
+ return appBundleAllowedIdentities(app, readMacosBundleIdentifier);
536
+ }
537
+ function iosAppAllowedIdentities(app) {
538
+ return appBundleAllowedIdentities(app, readIosBundleIdentifier, looksLikeIosAppPath);
539
+ }
499
540
  function assertTargetAppAllowed(allowedApps, app) {
541
+ assertNativeAppAllowed(allowedApps, app, macosAppAllowedIdentities);
542
+ }
543
+ function assertIosAppAllowed(allowedApps, app) {
544
+ assertNativeAppAllowed(allowedApps, app, iosAppAllowedIdentities);
545
+ }
546
+ function looksLikeApkPath(app) {
547
+ const trimmed = trimTrailingPathSeparators(app);
548
+ return trimmed.includes("/") || trimmed.includes("\\") || trimmed.toLowerCase().endsWith(".apk");
549
+ }
550
+ function normalizeAndroidApkPath(app) {
551
+ const resolved = path.resolve(trimTrailingPathSeparators(app));
552
+ try {
553
+ return fs.realpathSync.native(resolved);
554
+ } catch {
555
+ return resolved;
556
+ }
557
+ }
558
+ function androidAppAllowedIdentities(app, resolvedPackage) {
559
+ if (looksLikeApkPath(app)) {
560
+ return resolvedPackage ? [normalizeAndroidApkPath(app), resolvedPackage] : [normalizeAndroidApkPath(app)];
561
+ }
562
+ return [app];
563
+ }
564
+ function assertAndroidAppAllowed(allowedApps, app, resolvedPackage) {
565
+ assertNativeAppAllowed(
566
+ allowedApps,
567
+ app,
568
+ (value) => androidAppAllowedIdentities(value, value === app ? resolvedPackage : void 0)
569
+ );
570
+ }
571
+ function assertNativeAppAllowed(allowedApps, app, resolveIdentities) {
500
572
  if (allowedApps.length === 0) {
501
573
  return;
502
574
  }
503
- const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => macosAppAllowedIdentities(allowedApp)));
504
- if (macosAppAllowedIdentities(app).some((identity) => allowedIdentities.has(identity))) {
575
+ const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => resolveIdentities(allowedApp)));
576
+ if (resolveIdentities(app).some((identity) => allowedIdentities.has(identity))) {
505
577
  return;
506
578
  }
507
579
  throw new Error(
@@ -657,7 +729,11 @@ function createMacDriver(client, options = {}) {
657
729
  return rejectUnsupported("evalScript");
658
730
  },
659
731
  async screenshot(screenshotOptions) {
660
- await client.request("screenshot", { path: screenshotOptions.path });
732
+ const result = await client.request("screenshot", { path: screenshotOptions.path });
733
+ const warning = result.warning;
734
+ if (typeof warning === "string" && warning.length > 0) {
735
+ console.warn(`macOS screenshot fell back to full screen: ${warning}`);
736
+ }
661
737
  },
662
738
  // network / dialogs / downloads (all web-only) -------------------------
663
739
  onResponse(_handler) {
@@ -887,187 +963,2278 @@ async function closeMacSession(session) {
887
963
  }
888
964
  }
889
965
 
890
- // src/runner/healing.ts
891
- var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
892
- function extractSelectorIntent(selector) {
893
- const raw = [];
894
- for (const match of selector.matchAll(/[#.]([A-Za-z_][\w-]*)/g)) {
895
- raw.push(match[1]);
896
- }
897
- for (const match of selector.matchAll(/\[[A-Za-z_:-]+\s*[~|^$*]?=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\]/g)) {
898
- const value = match[1] ?? match[2] ?? match[3];
899
- if (value) raw.push(value);
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;
900
977
  }
901
- const words = [];
902
- for (const token of raw) {
903
- for (const part of splitToken(token)) {
904
- const lower = part.toLowerCase();
905
- if (lower.length > 0 && !words.includes(lower)) {
906
- words.push(lower);
907
- }
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;
908
983
  }
909
- }
910
- return { words, label: words.join(" ") };
911
- }
912
- function splitToken(token) {
913
- return token.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[\s\-_.:]+/).filter((part) => part.length > 0);
914
- }
915
- function buildHealCandidates(selector) {
916
- const { words, label } = extractSelectorIntent(selector);
917
- if (words.length === 0) return [];
918
- const escaped = label.replace(/"/g, '\\"');
919
- const candidates = [];
920
- candidates.push({ selector: `text=${label}`, strategy: "text" });
921
- candidates.push({ selector: `[aria-label*="${escaped}" i]`, strategy: "aria" });
922
- for (const tag of INTERACTIVE_TAGS) {
923
- candidates.push({ selector: `${tag}:has-text("${escaped}")`, strategy: "structural" });
924
- }
925
- return candidates;
984
+ const named = ENTITIES[code];
985
+ return named ?? match;
986
+ });
926
987
  }
927
- async function healSelector(probe, selector, options) {
928
- if (!options.enabled) return null;
929
- for (const candidate of buildHealCandidates(selector)) {
930
- let count;
931
- try {
932
- const locator = probe.locator(candidate.selector);
933
- count = await locator.count();
934
- } catch {
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;
935
1018
  continue;
936
1019
  }
937
- if (count === 1) {
938
- return { selector: candidate.selector, healedFrom: selector, strategy: candidate.strategy };
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);
939
1069
  }
940
1070
  }
941
- return null;
1071
+ return root;
942
1072
  }
943
1073
 
944
- // src/runner/history.ts
945
- import fs3 from "fs";
946
- import path3 from "path";
947
- var HISTORY_FILE = "history.json";
948
- var LOCK_FILE_SUFFIX = ".lock";
949
- var LOCK_RETRY_MS = 10;
950
- var LOCK_TIMEOUT_MS = 5e3;
951
- var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
952
- function historyPath(configDir) {
953
- return path3.join(configDir, HISTORY_FILE);
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;
954
1082
  }
955
- function isHistoryEntry(value) {
956
- if (!value || typeof value !== "object") {
957
- return false;
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
+ );
958
1100
  }
959
- const entry = value;
960
- return typeof entry.hunt === "string" && (entry.status === "pass" || entry.status === "fail") && typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && typeof entry.startedAt === "string" && (entry.runDir === void 0 || typeof entry.runDir === "string");
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 };
961
1131
  }
962
- function readHistory(configDir) {
963
- const filePath = historyPath(configDir);
964
- if (!fs3.existsSync(filePath)) {
965
- return { entries: [] };
1132
+ function unwrapNativeTextSelector(selector) {
1133
+ const trimmed = selector.trim();
1134
+ if (!trimmed.startsWith("text=")) {
1135
+ return null;
966
1136
  }
967
- try {
968
- const raw = fs3.readFileSync(filePath, "utf-8");
969
- const parsed = JSON.parse(raw);
970
- if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
971
- const validatedEntries = parsed.entries.filter(isHistoryEntry);
972
- return { entries: validatedEntries };
973
- }
974
- return { entries: [] };
975
- } catch (error) {
976
- const message = error instanceof Error ? error.message : String(error);
977
- console.warn(`Failed to read history file at ${filePath}: ${message}`);
978
- return { entries: [] };
1137
+ return unquoteSelectorValue(trimmed.slice("text=".length));
1138
+ }
1139
+ function qualifyResourceId(value, appPackage) {
1140
+ if (value.includes(":") || !appPackage) {
1141
+ return value;
979
1142
  }
1143
+ return `${appPackage}:id/${value}`;
980
1144
  }
981
- function readHuntHistory(configDir, huntName) {
982
- const { entries } = readHistory(configDir);
983
- return entries.filter((entry) => entry.hunt === huntName);
1145
+ function normalizeXcuiClassName(role) {
1146
+ return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
984
1147
  }
985
- function pruneEntries(entries, maxRuns) {
986
- const perHunt = /* @__PURE__ */ new Map();
987
- for (const entry of entries) {
988
- const list = perHunt.get(entry.hunt) ?? [];
989
- list.push(entry);
990
- perHunt.set(entry.hunt, list);
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}`);
991
1155
  }
992
- const keptEntries = /* @__PURE__ */ new Set();
993
- for (const list of perHunt.values()) {
994
- const kept = list.length > maxRuns ? list.slice(list.length - maxRuns) : list;
995
- for (const entry of kept) {
996
- keptEntries.add(entry);
997
- }
1156
+ if (fields.label) {
1157
+ selectors.push(`label=${quoteSelectorValue(fields.label)}`);
998
1158
  }
999
- return entries.filter((entry) => keptEntries.has(entry));
1000
- }
1001
- function sleepSync(ms) {
1002
- Atomics.wait(SLEEP_BUFFER, 0, 0, ms);
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;
1003
1169
  }
1004
- function withHistoryLock(configDir, fn) {
1005
- const filePath = historyPath(configDir);
1006
- const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
1007
- fs3.mkdirSync(path3.dirname(filePath), { recursive: true });
1008
- const startedAt = Date.now();
1009
- while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
1010
- let fd;
1011
- try {
1012
- fd = fs3.openSync(lockPath, "wx");
1013
- } catch (error) {
1014
- if (error.code === "EEXIST") {
1015
- sleepSync(LOCK_RETRY_MS);
1016
- continue;
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;
1017
1218
  }
1018
- throw error;
1019
- }
1020
- try {
1021
- return fn();
1022
- } finally {
1023
- try {
1024
- fs3.closeSync(fd);
1025
- } catch {
1219
+ if (dialect.normalizeRole(node.role) !== dialect.normalizeRole(selector.role)) {
1220
+ return false;
1026
1221
  }
1027
- fs3.rmSync(lockPath, { force: true });
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));
1028
1227
  }
1029
1228
  }
1030
- throw new Error(
1031
- `Failed to acquire history lock before timeout (${LOCK_TIMEOUT_MS}ms): ${lockPath}; started waiting at ${new Date(startedAt).toISOString()}`
1032
- );
1033
1229
  }
1034
- function appendEntry(configDir, entry, maxRuns) {
1035
- const filePath = historyPath(configDir);
1036
- withHistoryLock(configDir, () => {
1037
- const current = readHistory(configDir);
1038
- const next = pruneEntries([...current.entries, entry], maxRuns);
1039
- const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
1040
- fs3.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
1041
- `);
1042
- fs3.renameSync(tempPath, filePath);
1043
- });
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);
1044
1245
  }
1045
1246
 
1046
- // src/runner/index.ts
1047
- import fs9 from "fs";
1048
- import path9 from "path";
1049
-
1050
- // src/browser/playwright-driver.ts
1051
- import fs4 from "fs";
1052
- import path4 from "path";
1053
- import {
1054
- chromium,
1055
- firefox,
1056
- webkit
1057
- } from "playwright";
1058
- var ENGINES = { chromium, firefox, webkit };
1059
- async function launchBrowser(options) {
1060
- const engineName = options.engine ?? "chromium";
1061
- const engine = Object.prototype.hasOwnProperty.call(ENGINES, engineName) ? ENGINES[engineName] : void 0;
1062
- if (!engine) {
1247
+ // src/browser/android-driver.ts
1248
+ import fs3 from "fs";
1249
+ var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
1250
+ "query",
1251
+ "interact",
1252
+ "wait",
1253
+ "screenshot"
1254
+ ]);
1255
+ var WAIT_POLL_INTERVAL_MS = 250;
1256
+ var DEFAULT_WAIT_TIMEOUT_MS = 5e3;
1257
+ var ANDROID_KEYCODES = {
1258
+ enter: 66,
1259
+ return: 66,
1260
+ tab: 61,
1261
+ space: 62,
1262
+ backspace: 67,
1263
+ delete: 67,
1264
+ del: 67,
1265
+ escape: 111,
1266
+ esc: 111,
1267
+ back: 4,
1268
+ home: 3,
1269
+ menu: 82,
1270
+ search: 84,
1271
+ up: 19,
1272
+ arrowup: 19,
1273
+ down: 20,
1274
+ arrowdown: 20,
1275
+ left: 21,
1276
+ arrowleft: 21,
1277
+ right: 22,
1278
+ arrowright: 22,
1279
+ pageup: 92,
1280
+ pagedown: 93
1281
+ };
1282
+ function escapeUiSelectorArg(value) {
1283
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1284
+ }
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 };
1297
+ }
1298
+ }
1299
+ function parseAndroidSelector(selector) {
1300
+ return toAndroidQuery(parseNativeSelector(selector));
1301
+ }
1302
+ function locator(strategy, selector) {
1303
+ return { strategy, selector, context: "" };
1304
+ }
1305
+ function androidQueryToLocator(query, options = {}) {
1306
+ switch (query.by) {
1307
+ case "id":
1308
+ return locator("id", qualifyResourceId(query.value, options.appPackage));
1309
+ case "accessibilityId":
1310
+ return locator("accessibility id", query.value);
1311
+ case "text":
1312
+ return locator(
1313
+ "-android uiautomator",
1314
+ `new UiSelector().textContains("${escapeUiSelectorArg(query.value)}")`
1315
+ );
1316
+ case "focused":
1317
+ return locator("-android uiautomator", "new UiSelector().focused(true)");
1318
+ case "role": {
1319
+ const className = escapeUiSelectorArg(query.role);
1320
+ if (query.name === void 0 || query.name.length === 0) {
1321
+ return locator("class name", query.role);
1322
+ }
1323
+ return locator(
1324
+ "-android uiautomator",
1325
+ `new UiSelector().className("${className}").textContains("${escapeUiSelectorArg(query.name)}")`
1326
+ );
1327
+ }
1328
+ }
1329
+ }
1330
+ function unwrapAndroidTextSelector(selector) {
1331
+ return unwrapNativeTextSelector(selector);
1332
+ }
1333
+ function keyCodeFor(key) {
1334
+ const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
1335
+ if (code === void 0) {
1063
1336
  throw new Error(
1064
- `Unsupported browser engine "${String(engineName)}". Available engines: ${Object.keys(ENGINES).join(", ")}.`
1337
+ `Unsupported key "${key}" for the Android target. Supported keys: ${Object.keys(ANDROID_KEYCODES).sort().join(", ")}.`
1065
1338
  );
1066
1339
  }
1067
- const browser = await engine.launch({
1068
- headless: options.headless,
1069
- slowMo: options.slowMo,
1070
- channel: options.channel
1340
+ return code;
1341
+ }
1342
+ function delay(ms) {
1343
+ return new Promise((resolve) => {
1344
+ setTimeout(resolve, ms);
1345
+ });
1346
+ }
1347
+ function createAndroidDriver(client, options = {}) {
1348
+ const unsupported = (verb) => new Error(`${verb} is not supported by the Android target`);
1349
+ const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
1350
+ async function resolveOne(selector) {
1351
+ const id = await client.findElement(parseAndroidSelector(selector));
1352
+ if (id === null) {
1353
+ throw new Error(`No element matched selector: ${selector}`);
1354
+ }
1355
+ return id;
1356
+ }
1357
+ async function clickSelector(selector) {
1358
+ await client.click(await resolveOne(selector));
1359
+ }
1360
+ async function fillSelector(selector, value) {
1361
+ await client.setValue(await resolveOne(selector), value);
1362
+ }
1363
+ return {
1364
+ capabilities: ANDROID_CAPABILITIES,
1365
+ // navigation -----------------------------------------------------------
1366
+ goto(_url, _options) {
1367
+ return rejectUnsupported("navigate");
1368
+ },
1369
+ currentUrl() {
1370
+ return `android:${options.appLabel ?? ""}`;
1371
+ },
1372
+ // queries --------------------------------------------------------------
1373
+ async count(selector) {
1374
+ return (await client.findElements(parseAndroidSelector(selector))).length;
1375
+ },
1376
+ async textContent(selector) {
1377
+ const id = await client.findElement(parseAndroidSelector(selector));
1378
+ if (id === null) {
1379
+ return null;
1380
+ }
1381
+ return client.getText(id);
1382
+ },
1383
+ // interactions ---------------------------------------------------------
1384
+ click: clickSelector,
1385
+ clickFirst: clickSelector,
1386
+ fill: fillSelector,
1387
+ fillFirst: fillSelector,
1388
+ async press(_selector, key) {
1389
+ await client.pressKeyCode(keyCodeFor(key));
1390
+ },
1391
+ selectOption() {
1392
+ return rejectUnsupported("select");
1393
+ },
1394
+ selectOptionFirst() {
1395
+ return rejectUnsupported("select");
1396
+ },
1397
+ hover() {
1398
+ return rejectUnsupported("hover");
1399
+ },
1400
+ scrollIntoView() {
1401
+ return rejectUnsupported("scrollTo");
1402
+ },
1403
+ setInputFiles() {
1404
+ return rejectUnsupported("setInputFiles");
1405
+ },
1406
+ // semantic locators ----------------------------------------------------
1407
+ async countByRole(role, name) {
1408
+ return (await client.findElements({ by: "role", role, name })).length;
1409
+ },
1410
+ async clickFirstByRole(role, name) {
1411
+ const id = await client.findElement({ by: "role", role, name });
1412
+ if (id === null) {
1413
+ throw new Error(`No element matched role=${role}[name="${name}"]`);
1414
+ }
1415
+ await client.click(id);
1416
+ },
1417
+ async countByLabel(label) {
1418
+ return (await client.findElements({ by: "accessibilityId", value: label })).length;
1419
+ },
1420
+ async fillFirstByLabel(label, value) {
1421
+ const id = await client.findElement({ by: "accessibilityId", value: label });
1422
+ if (id === null) {
1423
+ throw new Error(`No element matched label="${label}"`);
1424
+ }
1425
+ await client.setValue(id, value);
1426
+ },
1427
+ selectOptionFirstByLabel() {
1428
+ return rejectUnsupported("select");
1429
+ },
1430
+ // waiting --------------------------------------------------------------
1431
+ async waitForSelector(selector, waitOptions) {
1432
+ const query = parseAndroidSelector(selector);
1433
+ const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS;
1434
+ const deadline = Date.now() + timeoutMs;
1435
+ for (; ; ) {
1436
+ if ((await client.findElements(query)).length > 0) {
1437
+ return;
1438
+ }
1439
+ if (Date.now() >= deadline) {
1440
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
1441
+ }
1442
+ await delay(Math.min(WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
1443
+ }
1444
+ },
1445
+ waitForUrl() {
1446
+ return rejectUnsupported("waitForUrl");
1447
+ },
1448
+ waitForNetworkIdle() {
1449
+ return rejectUnsupported("waitForNetworkIdle");
1450
+ },
1451
+ // scripting & artifacts ------------------------------------------------
1452
+ evaluate() {
1453
+ return rejectUnsupported("evalScript");
1454
+ },
1455
+ async screenshot(screenshotOptions) {
1456
+ const png = await client.screenshotPng();
1457
+ fs3.writeFileSync(screenshotOptions.path, png);
1458
+ },
1459
+ // network / dialogs / downloads (all web-only) -------------------------
1460
+ onResponse(_handler) {
1461
+ throw unsupported("onResponse");
1462
+ },
1463
+ route(_url, _handler) {
1464
+ return rejectUnsupported("mockRoute");
1465
+ },
1466
+ unroute() {
1467
+ return rejectUnsupported("unmockRoute");
1468
+ },
1469
+ onDialog(_action) {
1470
+ throw unsupported("onDialog");
1471
+ },
1472
+ waitForDownloadEvent() {
1473
+ return rejectUnsupported("waitForDownload");
1474
+ },
1475
+ parseTextSelector(selector) {
1476
+ return unwrapAndroidTextSelector(selector);
1477
+ }
1478
+ };
1479
+ }
1480
+
1481
+ // src/browser/android-adb.ts
1482
+ import { execFile, spawn as spawn2 } from "child_process";
1483
+ var execFileAdbRunner = (args, options) => new Promise((resolve) => {
1484
+ execFile(
1485
+ "adb",
1486
+ args,
1487
+ { encoding: "utf-8", timeout: options?.timeoutMs, maxBuffer: 16 * 1024 * 1024 },
1488
+ (error, stdout, stderr) => {
1489
+ const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
1490
+ const capturedStderr = stderr ?? "";
1491
+ resolve({
1492
+ stdout: stdout ?? "",
1493
+ stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
1494
+ code
1495
+ });
1496
+ }
1497
+ );
1498
+ });
1499
+ var spawnAdbProcess = (args) => {
1500
+ const child = spawn2("adb", args, { stdio: "ignore" });
1501
+ child.on("error", () => {
1502
+ });
1503
+ return { kill: () => child.kill() };
1504
+ };
1505
+ function withSerial(serial, args) {
1506
+ return serial ? ["-s", serial, ...args] : args;
1507
+ }
1508
+ function parseAdbDevices(stdout) {
1509
+ const devices = [];
1510
+ for (const rawLine of stdout.split(/\r?\n/)) {
1511
+ const line = rawLine.trim();
1512
+ if (!line || /^list of devices attached/i.test(line)) {
1513
+ continue;
1514
+ }
1515
+ const [serial, state, ...rest] = line.split(/\s+/);
1516
+ if (!serial || !state) {
1517
+ continue;
1518
+ }
1519
+ const description = {};
1520
+ for (const token of rest) {
1521
+ const eq = token.indexOf(":");
1522
+ if (eq > 0) {
1523
+ description[token.slice(0, eq)] = token.slice(eq + 1);
1524
+ }
1525
+ }
1526
+ devices.push({ serial, state, description });
1527
+ }
1528
+ return devices;
1529
+ }
1530
+ function bootedDevices(devices) {
1531
+ return devices.filter((device) => device.state === "device");
1532
+ }
1533
+ function selectDeviceSerial(devices, requested) {
1534
+ const booted = bootedDevices(devices);
1535
+ if (requested) {
1536
+ const match = devices.find((device) => device.serial === requested);
1537
+ if (!match) {
1538
+ const attached = devices.length > 0 ? devices.map((d) => d.serial).join(", ") : "none";
1539
+ throw new Error(
1540
+ `Android device "${requested}" is not attached. Attached devices: ${attached}. Check \`adb devices -l\`.`
1541
+ );
1542
+ }
1543
+ if (match.state !== "device") {
1544
+ throw new Error(
1545
+ `Android device "${requested}" is present but not ready (state: ${match.state}). Boot or authorize it, then retry.`
1546
+ );
1547
+ }
1548
+ return requested;
1549
+ }
1550
+ if (booted.length === 0) {
1551
+ throw new Error(
1552
+ "No booted Android device found. Start an emulator or connect a device with USB debugging, then confirm it appears in `adb devices -l`."
1553
+ );
1554
+ }
1555
+ if (booted.length > 1) {
1556
+ throw new Error(
1557
+ `Multiple Android devices attached (${booted.map((d) => d.serial).join(", ")}). Set target.deviceSerial to pick one.`
1558
+ );
1559
+ }
1560
+ return booted[0].serial;
1561
+ }
1562
+ async function listDevices(runner) {
1563
+ const result = await runner(["devices", "-l"], { timeoutMs: 1e4 });
1564
+ if (result.code !== 0) {
1565
+ throw new Error(
1566
+ `\`adb devices\` failed (exit ${result.code}). Is adb on PATH and the server running? ` + (result.stderr.trim() || "").slice(0, 400)
1567
+ );
1568
+ }
1569
+ return parseAdbDevices(result.stdout);
1570
+ }
1571
+ function parseForwardPort(stdout) {
1572
+ const port = Number.parseInt(stdout.trim(), 10);
1573
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
1574
+ throw new Error(`Could not parse a forwarded port from adb output: "${stdout.trim()}"`);
1575
+ }
1576
+ return port;
1577
+ }
1578
+ function parseAaptPackage(stdout) {
1579
+ const match = /package:\s*name='([^']+)'/.exec(stdout);
1580
+ return match?.[1] ?? null;
1581
+ }
1582
+ async function forwardDynamicPort(runner, serial, remotePort) {
1583
+ const result = await runner(withSerial(serial, ["forward", "tcp:0", `tcp:${remotePort}`]), {
1584
+ timeoutMs: 1e4
1585
+ });
1586
+ if (result.code !== 0) {
1587
+ throw new Error(`adb forward failed (exit ${result.code}): ${result.stderr.trim()}`);
1588
+ }
1589
+ return parseForwardPort(result.stdout);
1590
+ }
1591
+ async function removeForward(runner, serial, localPort) {
1592
+ await runner(withSerial(serial, ["forward", "--remove", `tcp:${localPort}`]), { timeoutMs: 1e4 }).catch(
1593
+ () => void 0
1594
+ );
1595
+ }
1596
+ async function installApk(runner, serial, apkPath) {
1597
+ const result = await runner(withSerial(serial, ["install", "-r", "-t", "-g", apkPath]), {
1598
+ timeoutMs: 12e4
1599
+ });
1600
+ if (result.code !== 0 || /failure/i.test(result.stdout)) {
1601
+ throw new Error(
1602
+ `Failed to install APK "${apkPath}" (exit ${result.code}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
1603
+ );
1604
+ }
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
+ }
1621
+ async function launchPackage(runner, serial, pkg) {
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)) {
1661
+ throw new Error(
1662
+ `Failed to launch Android package "${pkg}" (${component}, exit ${start.code}): ${(start.stdout + start.stderr).trim().slice(0, 400)}.`
1663
+ );
1664
+ }
1665
+ }
1666
+ async function forceStop(runner, serial, pkg) {
1667
+ await runner(withSerial(serial, ["shell", "am", "force-stop", pkg]), { timeoutMs: 3e4 });
1668
+ }
1669
+ async function clearPackage(runner, serial, pkg) {
1670
+ const result = await runner(withSerial(serial, ["shell", "pm", "clear", pkg]), { timeoutMs: 3e4 });
1671
+ if (result.code !== 0 || !/success/i.test(result.stdout)) {
1672
+ throw new Error(
1673
+ `Failed to clear Android package "${pkg}" for cold start (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 300)}`
1674
+ );
1675
+ }
1676
+ }
1677
+ function startInstrumentation(spawner, serial) {
1678
+ return spawner(
1679
+ withSerial(serial, [
1680
+ "shell",
1681
+ "am",
1682
+ "instrument",
1683
+ "-w",
1684
+ "-e",
1685
+ "disableAnalytics",
1686
+ "true",
1687
+ "io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner"
1688
+ ])
1689
+ );
1690
+ }
1691
+
1692
+ // src/browser/android-agent.ts
1693
+ var DEFAULT_AGENT_REQUEST_TIMEOUT_MS = 3e4;
1694
+ var W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
1695
+ var Uia2HttpError = class extends Error {
1696
+ constructor(message, status, webdriverError) {
1697
+ super(message);
1698
+ this.status = status;
1699
+ this.webdriverError = webdriverError;
1700
+ this.name = "Uia2HttpError";
1701
+ }
1702
+ };
1703
+ var Uia2Transport = class {
1704
+ baseUrl;
1705
+ requestTimeoutMs;
1706
+ fetchImpl;
1707
+ constructor(options) {
1708
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
1709
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_AGENT_REQUEST_TIMEOUT_MS;
1710
+ const injected = options.fetchImpl;
1711
+ if (injected) {
1712
+ this.fetchImpl = injected;
1713
+ } else if (typeof fetch === "function") {
1714
+ this.fetchImpl = (url, init) => fetch(url, init);
1715
+ } else {
1716
+ throw new Error("global fetch is unavailable; Node 20+ is required for the Android target");
1717
+ }
1718
+ }
1719
+ /**
1720
+ * Send one request and return the parsed `value` field. Rejects with a
1721
+ * {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
1722
+ * per-request deadline elapses.
1723
+ */
1724
+ async request(method, path16, body, timeoutMs) {
1725
+ const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
1726
+ const controller = new AbortController();
1727
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
1728
+ timer.unref?.();
1729
+ const url = `${this.baseUrl}${path16}`;
1730
+ let response;
1731
+ try {
1732
+ response = await this.fetchImpl(url, {
1733
+ method,
1734
+ signal: controller.signal,
1735
+ headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
1736
+ body: body !== void 0 ? JSON.stringify(body) : void 0
1737
+ });
1738
+ } catch (error) {
1739
+ if (controller.signal.aborted) {
1740
+ const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
1741
+ throw new Error(`uiautomator2 request ${method} ${path16} timed out after ${shown}`);
1742
+ }
1743
+ throw error instanceof Error ? error : new Error(String(error));
1744
+ } finally {
1745
+ clearTimeout(timer);
1746
+ }
1747
+ const text = await response.text();
1748
+ const parsed = parseJson(text);
1749
+ if (!response.ok) {
1750
+ const wdError = extractWebdriverError(parsed);
1751
+ throw new Uia2HttpError(
1752
+ `uiautomator2 ${method} ${path16} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
1753
+ response.status,
1754
+ wdError
1755
+ );
1756
+ }
1757
+ return parsed?.value;
1758
+ }
1759
+ };
1760
+ function parseJson(text) {
1761
+ if (!text) {
1762
+ return void 0;
1763
+ }
1764
+ try {
1765
+ return JSON.parse(text);
1766
+ } catch {
1767
+ return void 0;
1768
+ }
1769
+ }
1770
+ function extractWebdriverError(parsed) {
1771
+ const value = parsed?.value;
1772
+ if (value && typeof value === "object") {
1773
+ const record = value;
1774
+ const error = typeof record.error === "string" ? record.error : void 0;
1775
+ const message = typeof record.message === "string" ? record.message : void 0;
1776
+ return error ?? message;
1777
+ }
1778
+ return void 0;
1779
+ }
1780
+ function extractElementId(value) {
1781
+ if (!value || typeof value !== "object") {
1782
+ return null;
1783
+ }
1784
+ const record = value;
1785
+ const id = record[W3C_ELEMENT_KEY] ?? record.ELEMENT;
1786
+ return typeof id === "string" ? id : null;
1787
+ }
1788
+ function isNoSuchElement(error) {
1789
+ if (error instanceof Uia2HttpError) {
1790
+ return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
1791
+ }
1792
+ return false;
1793
+ }
1794
+ async function createUia2Session(transport) {
1795
+ const value = await transport.request("POST", "/session", {
1796
+ capabilities: { alwaysMatch: {}, firstMatch: [{}] }
1797
+ });
1798
+ const record = value ?? {};
1799
+ const sessionId = record.sessionId;
1800
+ if (typeof sessionId === "string" && sessionId.length > 0) {
1801
+ return sessionId;
1802
+ }
1803
+ throw new Error("uiautomator2 did not return a session id");
1804
+ }
1805
+ async function waitForAgentReady(transport, options = { deadlineMs: 3e4 }) {
1806
+ const interval = options.intervalMs ?? 300;
1807
+ const deadline = Date.now() + options.deadlineMs;
1808
+ let lastError;
1809
+ for (; ; ) {
1810
+ try {
1811
+ const remainingMs = Math.max(1, deadline - Date.now());
1812
+ const value = await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
1813
+ const ready = value?.ready;
1814
+ if (ready === void 0 || ready === true) {
1815
+ return;
1816
+ }
1817
+ } catch (error) {
1818
+ lastError = error;
1819
+ }
1820
+ if (Date.now() >= deadline) {
1821
+ const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
1822
+ throw new Error(`uiautomator2 agent did not become ready within ${options.deadlineMs}ms${detail}`);
1823
+ }
1824
+ await sleep(Math.min(interval, Math.max(0, deadline - Date.now())));
1825
+ }
1826
+ }
1827
+ function sleep(ms) {
1828
+ return new Promise((resolve) => {
1829
+ const timer = setTimeout(resolve, ms);
1830
+ timer.unref?.();
1831
+ });
1832
+ }
1833
+ function createUia2AgentClient(transport, sessionId, options = {}) {
1834
+ const base = `/session/${sessionId}`;
1835
+ async function locate(query, path16) {
1836
+ return transport.request(
1837
+ "POST",
1838
+ `${base}${path16}`,
1839
+ androidQueryToLocator(query, { appPackage: options.appPackage })
1840
+ );
1841
+ }
1842
+ return {
1843
+ async findElement(query) {
1844
+ try {
1845
+ return extractElementId(await locate(query, "/element"));
1846
+ } catch (error) {
1847
+ if (isNoSuchElement(error)) {
1848
+ return null;
1849
+ }
1850
+ throw error;
1851
+ }
1852
+ },
1853
+ async findElements(query) {
1854
+ const value = await locate(query, "/elements");
1855
+ if (!Array.isArray(value)) {
1856
+ return [];
1857
+ }
1858
+ return value.map((entry) => extractElementId(entry)).filter((id) => id !== null);
1859
+ },
1860
+ async click(elementId) {
1861
+ await transport.request("POST", `${base}/element/${elementId}/click`, {});
1862
+ },
1863
+ async setValue(elementId, text) {
1864
+ await transport.request("POST", `${base}/element/${elementId}/value`, { text });
1865
+ },
1866
+ async getText(elementId) {
1867
+ const value = await transport.request("GET", `${base}/element/${elementId}/text`);
1868
+ return typeof value === "string" ? value : value == null ? null : String(value);
1869
+ },
1870
+ async pressKeyCode(keyCode) {
1871
+ await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
1872
+ },
1873
+ async screenshotPng() {
1874
+ const value = await transport.request("GET", `${base}/screenshot`);
1875
+ if (typeof value !== "string") {
1876
+ throw new Error("uiautomator2 screenshot did not return base64 data");
1877
+ }
1878
+ return Buffer.from(value, "base64");
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
+ },
1887
+ async close() {
1888
+ await transport.request("DELETE", base).catch(() => void 0);
1889
+ }
1890
+ };
1891
+ }
1892
+
1893
+ // src/browser/android-helper.ts
1894
+ import { createRequire } from "module";
1895
+ import { execFile as execFile2 } from "child_process";
1896
+ import fs4 from "fs";
1897
+ import path3 from "path";
1898
+ var UIA2_REMOTE_PORT = 6790;
1899
+ function resolveAgentApks(requireFn = createRequire(import.meta.url)) {
1900
+ let pkgJsonPath;
1901
+ try {
1902
+ pkgJsonPath = requireFn.resolve("appium-uiautomator2-server/package.json");
1903
+ } catch {
1904
+ throw new Error(
1905
+ "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"
1906
+ );
1907
+ }
1908
+ const pkgDir = path3.dirname(pkgJsonPath);
1909
+ const version = requireFn(pkgJsonPath).version;
1910
+ const serverApk = path3.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
1911
+ const testApk = path3.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
1912
+ for (const apk of [serverApk, testApk]) {
1913
+ if (!fs4.existsSync(apk)) {
1914
+ throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
1915
+ }
1916
+ }
1917
+ return { serverApk, testApk };
1918
+ }
1919
+ function looksLikeApk(app) {
1920
+ return app.toLowerCase().endsWith(".apk");
1921
+ }
1922
+ var execFileAaptResolver = async (apkPath) => {
1923
+ for (const tool of ["aapt", "aapt2"]) {
1924
+ const output = await new Promise((resolve) => {
1925
+ execFile2(
1926
+ tool,
1927
+ ["dump", "badging", apkPath],
1928
+ { encoding: "utf-8", timeout: 2e4, maxBuffer: 8 * 1024 * 1024 },
1929
+ (error, stdout) => resolve(error ? null : stdout)
1930
+ );
1931
+ });
1932
+ const pkg = output ? parseAaptPackage(output) : null;
1933
+ if (pkg) {
1934
+ return pkg;
1935
+ }
1936
+ }
1937
+ return null;
1938
+ };
1939
+ var defaultAgentConnector = async ({
1940
+ host,
1941
+ port,
1942
+ requestTimeoutMs,
1943
+ readyDeadlineMs,
1944
+ appPackage
1945
+ }) => {
1946
+ const transport = new Uia2Transport({
1947
+ baseUrl: `http://${host}:${port}/wd/hub`,
1948
+ requestTimeoutMs
1949
+ });
1950
+ await waitForAgentReady(transport, { deadlineMs: readyDeadlineMs });
1951
+ const sessionId = await createUia2Session(transport);
1952
+ return createUia2AgentClient(transport, sessionId, { appPackage });
1953
+ };
1954
+ async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
1955
+ if (!looksLikeApk(app)) {
1956
+ assertAndroidAppAllowed(allowedApps, app);
1957
+ return app;
1958
+ }
1959
+ const apkPath = path3.resolve(app);
1960
+ if (!fs4.existsSync(apkPath)) {
1961
+ throw new Error(`APK not found: ${apkPath}`);
1962
+ }
1963
+ const pkg = await aaptResolver(apkPath);
1964
+ if (!pkg) {
1965
+ throw new Error(
1966
+ `Could not determine the package name for "${app}". Put Android build-tools \`aapt\`/\`aapt2\` on PATH, or set target.app to the package name instead of the .apk path.`
1967
+ );
1968
+ }
1969
+ assertAndroidAppAllowed(allowedApps, apkPath, pkg);
1970
+ await installApk(runner, serial, apkPath);
1971
+ return pkg;
1972
+ }
1973
+ async function launchAndroidSession(options) {
1974
+ const runner = options.runner ?? execFileAdbRunner;
1975
+ const spawner = options.spawner ?? spawnAdbProcess;
1976
+ const connector = options.agentConnector ?? defaultAgentConnector;
1977
+ const aaptResolver = options.aaptResolver ?? execFileAaptResolver;
1978
+ const apks = options.apks ?? resolveAgentApks();
1979
+ const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS) + 5e3;
1980
+ const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS);
1981
+ const devices = await listDevices(runner);
1982
+ const serial = selectDeviceSerial(devices, options.deviceSerial);
1983
+ const pkg = await resolvePackage(options.app, runner, serial, aaptResolver, options.allowedApps ?? []);
1984
+ await installApk(runner, serial, apks.serverApk);
1985
+ await installApk(runner, serial, apks.testApk);
1986
+ if (options.coldStart) {
1987
+ await clearPackage(runner, serial, pkg);
1988
+ }
1989
+ await launchPackage(runner, serial, pkg);
1990
+ let instrumentation;
1991
+ let localPort;
1992
+ let client;
1993
+ let tornDown = false;
1994
+ const teardown = async () => {
1995
+ if (tornDown) {
1996
+ return;
1997
+ }
1998
+ tornDown = true;
1999
+ if (client) {
2000
+ await client.close().catch(() => void 0);
2001
+ }
2002
+ instrumentation?.kill();
2003
+ const forwardedPort = localPort;
2004
+ localPort = void 0;
2005
+ if (forwardedPort !== void 0) {
2006
+ await removeForward(runner, serial, forwardedPort);
2007
+ }
2008
+ await forceStop(runner, serial, pkg).catch(() => void 0);
2009
+ };
2010
+ try {
2011
+ instrumentation = startInstrumentation(spawner, serial);
2012
+ localPort = await forwardDynamicPort(runner, serial, UIA2_REMOTE_PORT);
2013
+ client = await connector({
2014
+ host: "127.0.0.1",
2015
+ port: localPort,
2016
+ requestTimeoutMs,
2017
+ readyDeadlineMs,
2018
+ appPackage: pkg
2019
+ });
2020
+ const driver = createAndroidDriver(client, { appLabel: pkg });
2021
+ return { client, driver, package: pkg, serial, teardown };
2022
+ } catch (error) {
2023
+ await teardown();
2024
+ throw error;
2025
+ }
2026
+ }
2027
+ async function closeAndroidSession(session) {
2028
+ await session.teardown();
2029
+ }
2030
+
2031
+ // src/browser/ios-driver.ts
2032
+ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
2033
+ "query",
2034
+ "interact",
2035
+ "wait",
2036
+ "screenshot"
2037
+ ]);
2038
+ var WAIT_POLL_INTERVAL_MS2 = 250;
2039
+ var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
2040
+ var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
2041
+ function escapePredicateArg(value) {
2042
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2043
+ }
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
+ }
2057
+ }
2058
+ function parseIosSelector(selector) {
2059
+ return toIosQuery(parseNativeSelector(selector));
2060
+ }
2061
+ function iosQueryToLocator(query) {
2062
+ switch (query.by) {
2063
+ case "accessibilityId":
2064
+ return { using: "accessibility id", value: query.value };
2065
+ case "label":
2066
+ return { using: "predicate string", value: `label == "${escapePredicateArg(query.value)}"` };
2067
+ case "text": {
2068
+ const escaped = escapePredicateArg(query.value);
2069
+ return {
2070
+ using: "predicate string",
2071
+ value: `label CONTAINS "${escaped}" OR value CONTAINS "${escaped}"`
2072
+ };
2073
+ }
2074
+ case "focused":
2075
+ return { using: "predicate string", value: "hasKeyboardFocus == 1" };
2076
+ case "role": {
2077
+ const className = normalizeXcuiClassName(query.role);
2078
+ if (query.name === void 0 || query.name.length === 0) {
2079
+ return { using: "class name", value: className };
2080
+ }
2081
+ const escapedClass = escapePredicateArg(className);
2082
+ const escapedName = escapePredicateArg(query.name);
2083
+ return {
2084
+ using: "predicate string",
2085
+ value: `type == "${escapedClass}" AND (label CONTAINS "${escapedName}" OR value CONTAINS "${escapedName}")`
2086
+ };
2087
+ }
2088
+ }
2089
+ }
2090
+ function unwrapIosTextSelector(selector) {
2091
+ return unwrapNativeTextSelector(selector);
2092
+ }
2093
+ function delay2(ms) {
2094
+ return new Promise((resolve) => {
2095
+ setTimeout(resolve, ms);
2096
+ });
2097
+ }
2098
+ function createIosDriver(client, options) {
2099
+ const unsupported = (verb) => new Error(`${verb} is not supported by the iOS target`);
2100
+ const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
2101
+ async function resolveOne(selector) {
2102
+ const id = await client.findElement(parseIosSelector(selector));
2103
+ if (id === null) {
2104
+ throw new Error(`No element matched selector: ${selector}`);
2105
+ }
2106
+ return id;
2107
+ }
2108
+ async function clickSelector(selector) {
2109
+ await client.click(await resolveOne(selector));
2110
+ }
2111
+ async function fillSelector(selector, value) {
2112
+ await client.setValue(await resolveOne(selector), value);
2113
+ }
2114
+ async function pressKey(key) {
2115
+ const name = key.trim().toLowerCase();
2116
+ if (name === "enter" || name === "return") {
2117
+ await client.sendKeys(["\n"]);
2118
+ return;
2119
+ }
2120
+ if (name === "delete" || name === "backspace" || name === "del") {
2121
+ await client.sendKeys(["\b"]);
2122
+ return;
2123
+ }
2124
+ if (name === "home") {
2125
+ await client.homescreen();
2126
+ return;
2127
+ }
2128
+ throw new Error(
2129
+ `Unsupported key "${key}" for the iOS target. Supported keys: ${IOS_PRESS_KEYS.join(", ")}.`
2130
+ );
2131
+ }
2132
+ return {
2133
+ capabilities: IOS_CAPABILITIES,
2134
+ // navigation -----------------------------------------------------------
2135
+ goto(_url, _options) {
2136
+ return rejectUnsupported("navigate");
2137
+ },
2138
+ currentUrl() {
2139
+ return `ios:${options.appLabel ?? ""}`;
2140
+ },
2141
+ // queries --------------------------------------------------------------
2142
+ async count(selector) {
2143
+ return (await client.findElements(parseIosSelector(selector))).length;
2144
+ },
2145
+ async textContent(selector) {
2146
+ const id = await client.findElement(parseIosSelector(selector));
2147
+ if (id === null) {
2148
+ return null;
2149
+ }
2150
+ return client.getText(id);
2151
+ },
2152
+ // interactions ---------------------------------------------------------
2153
+ click: clickSelector,
2154
+ clickFirst: clickSelector,
2155
+ fill: fillSelector,
2156
+ fillFirst: fillSelector,
2157
+ async press(_selector, key) {
2158
+ await pressKey(key);
2159
+ },
2160
+ selectOption() {
2161
+ return rejectUnsupported("select");
2162
+ },
2163
+ selectOptionFirst() {
2164
+ return rejectUnsupported("select");
2165
+ },
2166
+ hover() {
2167
+ return rejectUnsupported("hover");
2168
+ },
2169
+ scrollIntoView() {
2170
+ return rejectUnsupported("scrollTo");
2171
+ },
2172
+ setInputFiles() {
2173
+ return rejectUnsupported("setInputFiles");
2174
+ },
2175
+ // semantic locators ----------------------------------------------------
2176
+ async countByRole(role, name) {
2177
+ return (await client.findElements({ by: "role", role, name })).length;
2178
+ },
2179
+ async clickFirstByRole(role, name) {
2180
+ const id = await client.findElement({ by: "role", role, name });
2181
+ if (id === null) {
2182
+ throw new Error(`No element matched role=${role}[name="${name}"]`);
2183
+ }
2184
+ await client.click(id);
2185
+ },
2186
+ async countByLabel(label) {
2187
+ return (await client.findElements({ by: "label", value: label })).length;
2188
+ },
2189
+ async fillFirstByLabel(label, value) {
2190
+ const id = await client.findElement({ by: "label", value: label });
2191
+ if (id === null) {
2192
+ throw new Error(`No element matched label="${label}"`);
2193
+ }
2194
+ await client.setValue(id, value);
2195
+ },
2196
+ selectOptionFirstByLabel() {
2197
+ return rejectUnsupported("select");
2198
+ },
2199
+ // waiting --------------------------------------------------------------
2200
+ async waitForSelector(selector, waitOptions) {
2201
+ const query = parseIosSelector(selector);
2202
+ const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
2203
+ const deadline = Date.now() + timeoutMs;
2204
+ for (; ; ) {
2205
+ if ((await client.findElements(query)).length > 0) {
2206
+ return;
2207
+ }
2208
+ if (Date.now() >= deadline) {
2209
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
2210
+ }
2211
+ await delay2(Math.min(WAIT_POLL_INTERVAL_MS2, Math.max(0, deadline - Date.now())));
2212
+ }
2213
+ },
2214
+ waitForUrl() {
2215
+ return rejectUnsupported("waitForUrl");
2216
+ },
2217
+ waitForNetworkIdle() {
2218
+ return rejectUnsupported("waitForNetworkIdle");
2219
+ },
2220
+ // scripting & artifacts ------------------------------------------------
2221
+ evaluate() {
2222
+ return rejectUnsupported("evalScript");
2223
+ },
2224
+ async screenshot(screenshotOptions) {
2225
+ await options.captureScreenshot(screenshotOptions.path);
2226
+ },
2227
+ // network / dialogs / downloads (all web-only) -------------------------
2228
+ onResponse(_handler) {
2229
+ throw unsupported("onResponse");
2230
+ },
2231
+ route(_url, _handler) {
2232
+ return rejectUnsupported("mockRoute");
2233
+ },
2234
+ unroute() {
2235
+ return rejectUnsupported("unmockRoute");
2236
+ },
2237
+ onDialog(_action) {
2238
+ throw unsupported("onDialog");
2239
+ },
2240
+ waitForDownloadEvent() {
2241
+ return rejectUnsupported("waitForDownload");
2242
+ },
2243
+ parseTextSelector(selector) {
2244
+ return unwrapIosTextSelector(selector);
2245
+ }
2246
+ };
2247
+ }
2248
+
2249
+ // src/browser/ios-simctl.ts
2250
+ import { execFile as execFile3, spawn as spawn3 } from "child_process";
2251
+ import { mkdir, readFile, rm, writeFile } from "fs/promises";
2252
+ import net from "net";
2253
+ import os from "os";
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
+ };
2268
+ var DEFAULT_SIMULATOR_LOCK_ROOT = path4.join(os.tmpdir(), "prowl-ios-simulator-locks");
2269
+ var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
2270
+ var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
2271
+ execFile3(
2272
+ "xcrun",
2273
+ args,
2274
+ {
2275
+ encoding: "utf-8",
2276
+ timeout: options?.timeoutMs,
2277
+ maxBuffer: 32 * 1024 * 1024,
2278
+ env: options?.env ? { ...process.env, ...options.env } : process.env
2279
+ },
2280
+ (error, stdout, stderr) => {
2281
+ const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
2282
+ const capturedStderr = stderr ?? "";
2283
+ resolve({
2284
+ stdout: stdout ?? "",
2285
+ stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
2286
+ code
2287
+ });
2288
+ }
2289
+ );
2290
+ });
2291
+ function simulatorLockName(udid) {
2292
+ const safe = udid.replace(/[^A-Za-z0-9_.-]/g, "_");
2293
+ return `${safe || "simulator"}.lock`;
2294
+ }
2295
+ function isErrno(error, code) {
2296
+ return error?.code === code;
2297
+ }
2298
+ function isProcessAlive(pid) {
2299
+ try {
2300
+ process.kill(pid, 0);
2301
+ return true;
2302
+ } catch (error) {
2303
+ return isErrno(error, "EPERM");
2304
+ }
2305
+ }
2306
+ async function removeStaleSimulatorLock(lockPath) {
2307
+ try {
2308
+ const ownerText = await readFile(path4.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
2309
+ const owner = JSON.parse(ownerText);
2310
+ if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
2311
+ await rm(lockPath, { recursive: true, force: true });
2312
+ return true;
2313
+ }
2314
+ } catch {
2315
+ return false;
2316
+ }
2317
+ return false;
2318
+ }
2319
+ function simulatorReservedError(udid) {
2320
+ return new Error(
2321
+ `iOS simulator "${udid}" is already reserved by another Prowl process. Wait for that run to finish, or boot/select a different simulator with target.udid.`
2322
+ );
2323
+ }
2324
+ async function reserveSimulatorUdid(udid, options = {}) {
2325
+ const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
2326
+ const lockPath = path4.join(lockRoot, simulatorLockName(udid));
2327
+ await mkdir(lockRoot, { recursive: true });
2328
+ for (let attempt = 0; attempt < 2; attempt += 1) {
2329
+ try {
2330
+ await mkdir(lockPath);
2331
+ } catch (error) {
2332
+ if (!isErrno(error, "EEXIST")) {
2333
+ throw error instanceof Error ? error : new Error(String(error));
2334
+ }
2335
+ if (attempt === 0 && await removeStaleSimulatorLock(lockPath)) {
2336
+ continue;
2337
+ }
2338
+ throw simulatorReservedError(udid);
2339
+ }
2340
+ let released = false;
2341
+ const release = async () => {
2342
+ if (released) {
2343
+ return;
2344
+ }
2345
+ released = true;
2346
+ await rm(lockPath, { recursive: true, force: true });
2347
+ };
2348
+ try {
2349
+ await writeFile(
2350
+ path4.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
2351
+ `${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
2352
+ `,
2353
+ { flag: "wx" }
2354
+ );
2355
+ } catch (error) {
2356
+ await release().catch(() => void 0);
2357
+ throw error instanceof Error ? error : new Error(String(error));
2358
+ }
2359
+ return { udid, release };
2360
+ }
2361
+ throw simulatorReservedError(udid);
2362
+ }
2363
+ function findFreePort() {
2364
+ return new Promise((resolve, reject) => {
2365
+ const server = net.createServer();
2366
+ server.on("error", reject);
2367
+ server.listen(0, "127.0.0.1", () => {
2368
+ const address = server.address();
2369
+ if (address && typeof address === "object") {
2370
+ const { port } = address;
2371
+ server.close(() => resolve(port));
2372
+ } else {
2373
+ server.close(() => reject(new Error("Could not allocate a local port")));
2374
+ }
2375
+ });
2376
+ });
2377
+ }
2378
+ function parseSimctlDevices(json) {
2379
+ let parsed;
2380
+ try {
2381
+ parsed = JSON.parse(json);
2382
+ } catch {
2383
+ throw new Error("Could not parse `simctl list devices --json` output.");
2384
+ }
2385
+ const byRuntime = parsed?.devices;
2386
+ if (!byRuntime || typeof byRuntime !== "object") {
2387
+ return [];
2388
+ }
2389
+ const devices = [];
2390
+ for (const [runtime, entries] of Object.entries(byRuntime)) {
2391
+ if (!Array.isArray(entries)) {
2392
+ continue;
2393
+ }
2394
+ for (const entry of entries) {
2395
+ if (!entry || typeof entry !== "object") {
2396
+ continue;
2397
+ }
2398
+ const record = entry;
2399
+ const udid = typeof record.udid === "string" ? record.udid : void 0;
2400
+ const name = typeof record.name === "string" ? record.name : void 0;
2401
+ const state = typeof record.state === "string" ? record.state : "Unknown";
2402
+ if (!udid || !name) {
2403
+ continue;
2404
+ }
2405
+ devices.push({
2406
+ udid,
2407
+ name,
2408
+ state,
2409
+ runtime,
2410
+ isAvailable: record.isAvailable !== false
2411
+ });
2412
+ }
2413
+ }
2414
+ return devices;
2415
+ }
2416
+ function bootedSimulators(devices) {
2417
+ return devices.filter((device) => device.state === "Booted");
2418
+ }
2419
+ function describeSimulator(device) {
2420
+ const runtime = device.runtime.replace(/^com\.apple\.CoreSimulator\.SimRuntime\./, "");
2421
+ return `${device.name} [${runtime}] (${device.udid})`;
2422
+ }
2423
+ function selectSimulatorUdid(devices, requested) {
2424
+ const booted = bootedSimulators(devices);
2425
+ if (requested) {
2426
+ const match = devices.find((device) => device.udid === requested);
2427
+ if (!match) {
2428
+ const known = devices.length > 0 ? devices.map((d) => d.udid).join(", ") : "none";
2429
+ throw new Error(
2430
+ `iOS simulator "${requested}" was not found. Known simulators: ${known}. Check \`xcrun simctl list devices\`.`
2431
+ );
2432
+ }
2433
+ if (match.state !== "Booted") {
2434
+ throw new Error(
2435
+ `iOS simulator "${requested}" is not booted (state: ${match.state}). Boot it with \`xcrun simctl boot ${requested}\`, then retry.`
2436
+ );
2437
+ }
2438
+ return requested;
2439
+ }
2440
+ if (booted.length === 0) {
2441
+ throw new Error(
2442
+ "No booted iOS simulator found. Boot one from Xcode or with `xcrun simctl boot <udid>` (see `xcrun simctl list devices available`), then retry."
2443
+ );
2444
+ }
2445
+ if (booted.length > 1) {
2446
+ throw new Error(
2447
+ `Multiple iOS simulators are booted (${booted.map(describeSimulator).join("; ")}). Set target.udid to pick one.`
2448
+ );
2449
+ }
2450
+ return booted[0].udid;
2451
+ }
2452
+ async function listSimulators(runner) {
2453
+ const result = await runner(["simctl", "list", "devices", "--json"], { timeoutMs: 3e4 });
2454
+ if (result.code !== 0) {
2455
+ throw new Error(
2456
+ `\`xcrun simctl list devices\` failed. Is Xcode installed and are the command-line tools selected (\`xcode-select -p\`)? ${(result.stderr.trim() || "").slice(0, 400)}`
2457
+ );
2458
+ }
2459
+ return parseSimctlDevices(result.stdout);
2460
+ }
2461
+ async function installApp(runner, udid, appPath) {
2462
+ const result = await runner(["simctl", "install", udid, appPath], { timeoutMs: 12e4 });
2463
+ if (result.code !== 0) {
2464
+ throw new Error(
2465
+ `Failed to install "${appPath}" onto simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
2466
+ );
2467
+ }
2468
+ }
2469
+ async function uninstallApp(runner, udid, bundleId) {
2470
+ await runner(["simctl", "uninstall", udid, bundleId], { timeoutMs: 6e4 }).catch(() => void 0);
2471
+ }
2472
+ async function launchApp(runner, udid, bundleId, childEnv = {}) {
2473
+ const env = {};
2474
+ for (const [key, value] of Object.entries(childEnv)) {
2475
+ env[`SIMCTL_CHILD_${key}`] = value;
2476
+ }
2477
+ const result = await runner(["simctl", "launch", udid, bundleId], {
2478
+ timeoutMs: 6e4,
2479
+ env: Object.keys(env).length > 0 ? env : void 0
2480
+ });
2481
+ if (result.code !== 0) {
2482
+ throw new Error(
2483
+ `Failed to launch "${bundleId}" on simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}. Is it installed?`
2484
+ );
2485
+ }
2486
+ }
2487
+ async function terminateApp(runner, udid, bundleId) {
2488
+ await runner(["simctl", "terminate", udid, bundleId], { timeoutMs: 3e4 }).catch(() => void 0);
2489
+ }
2490
+ async function captureScreenshot(runner, udid, outPath) {
2491
+ const result = await runner(["simctl", "io", udid, "screenshot", outPath], { timeoutMs: 3e4 });
2492
+ if (result.code !== 0) {
2493
+ throw new Error(
2494
+ `Failed to capture a simulator screenshot (${udid}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 300)}`
2495
+ );
2496
+ }
2497
+ }
2498
+ function parseXcodeVersion(stdout) {
2499
+ const match = /Xcode\s+([\d.]+)/i.exec(stdout);
2500
+ return match?.[1] ?? null;
2501
+ }
2502
+ async function xcodeVersion(runner) {
2503
+ const result = await runner(["xcodebuild", "-version"], { timeoutMs: 3e4 });
2504
+ if (result.code !== 0) {
2505
+ return null;
2506
+ }
2507
+ return parseXcodeVersion(result.stdout);
2508
+ }
2509
+
2510
+ // src/browser/ios-agent.ts
2511
+ var DEFAULT_WDA_REQUEST_TIMEOUT_MS = 3e4;
2512
+ var W3C_ELEMENT_KEY2 = "element-6066-11e4-a52e-4f735466cecf";
2513
+ var WdaHttpError = class extends Error {
2514
+ constructor(message, status, webdriverError) {
2515
+ super(message);
2516
+ this.status = status;
2517
+ this.webdriverError = webdriverError;
2518
+ this.name = "WdaHttpError";
2519
+ }
2520
+ };
2521
+ var WdaTransport = class {
2522
+ baseUrl;
2523
+ requestTimeoutMs;
2524
+ fetchImpl;
2525
+ constructor(options) {
2526
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
2527
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_WDA_REQUEST_TIMEOUT_MS;
2528
+ const injected = options.fetchImpl;
2529
+ if (injected) {
2530
+ this.fetchImpl = injected;
2531
+ } else if (typeof fetch === "function") {
2532
+ this.fetchImpl = (url, init) => fetch(url, init);
2533
+ } else {
2534
+ throw new Error("global fetch is unavailable; Node 20+ is required for the iOS target");
2535
+ }
2536
+ }
2537
+ /**
2538
+ * Send one request and return the full parsed JSON body. Rejects with a
2539
+ * {@link WdaHttpError} on a non-2xx response, or a timeout error when the
2540
+ * per-request deadline elapses.
2541
+ */
2542
+ async requestFull(method, path16, body, timeoutMs) {
2543
+ const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
2544
+ const controller = new AbortController();
2545
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
2546
+ timer.unref?.();
2547
+ const url = `${this.baseUrl}${path16}`;
2548
+ const timeoutError = () => {
2549
+ const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
2550
+ return new Error(`WebDriverAgent request ${method} ${path16} timed out after ${shown}`);
2551
+ };
2552
+ let response;
2553
+ try {
2554
+ response = await this.fetchImpl(url, {
2555
+ method,
2556
+ signal: controller.signal,
2557
+ headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
2558
+ body: body !== void 0 ? JSON.stringify(body) : void 0
2559
+ });
2560
+ } catch (error) {
2561
+ clearTimeout(timer);
2562
+ if (controller.signal.aborted) {
2563
+ throw timeoutError();
2564
+ }
2565
+ throw error instanceof Error ? error : new Error(String(error));
2566
+ }
2567
+ let text;
2568
+ try {
2569
+ text = await response.text();
2570
+ } catch (error) {
2571
+ if (controller.signal.aborted) {
2572
+ throw timeoutError();
2573
+ }
2574
+ throw error instanceof Error ? error : new Error(String(error));
2575
+ } finally {
2576
+ clearTimeout(timer);
2577
+ }
2578
+ const parsed = parseJson2(text);
2579
+ if (!response.ok) {
2580
+ const wdError = extractWebdriverError2(parsed);
2581
+ throw new WdaHttpError(
2582
+ `WebDriverAgent ${method} ${path16} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2583
+ response.status,
2584
+ wdError
2585
+ );
2586
+ }
2587
+ return parsed;
2588
+ }
2589
+ /** Like {@link requestFull} but returns just the `value` field. */
2590
+ async request(method, path16, body, timeoutMs) {
2591
+ const parsed = await this.requestFull(method, path16, body, timeoutMs);
2592
+ return parsed?.value;
2593
+ }
2594
+ };
2595
+ function parseJson2(text) {
2596
+ if (!text) {
2597
+ return void 0;
2598
+ }
2599
+ try {
2600
+ return JSON.parse(text);
2601
+ } catch {
2602
+ return void 0;
2603
+ }
2604
+ }
2605
+ function extractWebdriverError2(parsed) {
2606
+ const value = parsed?.value;
2607
+ if (value && typeof value === "object") {
2608
+ const record = value;
2609
+ const error = typeof record.error === "string" ? record.error : void 0;
2610
+ const message = typeof record.message === "string" ? record.message : void 0;
2611
+ return error ?? message;
2612
+ }
2613
+ return void 0;
2614
+ }
2615
+ function extractElementId2(value) {
2616
+ if (!value || typeof value !== "object") {
2617
+ return null;
2618
+ }
2619
+ const record = value;
2620
+ const id = record[W3C_ELEMENT_KEY2] ?? record.ELEMENT;
2621
+ return typeof id === "string" ? id : null;
2622
+ }
2623
+ function isNoSuchElement2(error) {
2624
+ if (error instanceof WdaHttpError) {
2625
+ return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
2626
+ }
2627
+ return false;
2628
+ }
2629
+ async function createWdaSession(transport, bundleId) {
2630
+ const body = await transport.requestFull("POST", "/session", {
2631
+ capabilities: { alwaysMatch: { bundleId }, firstMatch: [{}] }
2632
+ });
2633
+ const envelope = body ?? {};
2634
+ const value = envelope.value ?? {};
2635
+ const sessionId = typeof value.sessionId === "string" && value.sessionId || typeof envelope.sessionId === "string" && envelope.sessionId || "";
2636
+ if (sessionId.length > 0) {
2637
+ return sessionId;
2638
+ }
2639
+ throw new Error("WebDriverAgent did not return a session id");
2640
+ }
2641
+ async function waitForWdaReady(transport, options = { deadlineMs: 6e4 }) {
2642
+ const interval = options.intervalMs ?? 300;
2643
+ const deadline = Date.now() + options.deadlineMs;
2644
+ let lastError;
2645
+ for (; ; ) {
2646
+ try {
2647
+ const remainingMs = Math.max(1, deadline - Date.now());
2648
+ await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
2649
+ return;
2650
+ } catch (error) {
2651
+ lastError = error;
2652
+ }
2653
+ if (Date.now() >= deadline) {
2654
+ const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
2655
+ throw new Error(`WebDriverAgent did not become ready within ${options.deadlineMs}ms${detail}`);
2656
+ }
2657
+ await sleep2(Math.min(interval, Math.max(0, deadline - Date.now())));
2658
+ }
2659
+ }
2660
+ function sleep2(ms) {
2661
+ return new Promise((resolve) => {
2662
+ setTimeout(resolve, ms);
2663
+ });
2664
+ }
2665
+ function createWdaAgentClient(transport, sessionId) {
2666
+ const base = `/session/${sessionId}`;
2667
+ async function locate(query, path16) {
2668
+ return transport.request("POST", `${base}${path16}`, iosQueryToLocator(query));
2669
+ }
2670
+ return {
2671
+ async findElement(query) {
2672
+ try {
2673
+ return extractElementId2(await locate(query, "/element"));
2674
+ } catch (error) {
2675
+ if (isNoSuchElement2(error)) {
2676
+ return null;
2677
+ }
2678
+ throw error;
2679
+ }
2680
+ },
2681
+ async findElements(query) {
2682
+ const value = await locate(query, "/elements");
2683
+ if (!Array.isArray(value)) {
2684
+ return [];
2685
+ }
2686
+ return value.map((entry) => extractElementId2(entry)).filter((id) => id !== null);
2687
+ },
2688
+ async click(elementId) {
2689
+ await transport.request("POST", `${base}/element/${elementId}/click`, {});
2690
+ },
2691
+ async setValue(elementId, text) {
2692
+ await transport.request("POST", `${base}/element/${elementId}/value`, { text });
2693
+ },
2694
+ async getText(elementId) {
2695
+ const value = await transport.request("GET", `${base}/element/${elementId}/text`);
2696
+ return typeof value === "string" ? value : value == null ? null : String(value);
2697
+ },
2698
+ async sendKeys(keys) {
2699
+ await transport.request("POST", `${base}/wda/keys`, { value: keys });
2700
+ },
2701
+ async homescreen() {
2702
+ await transport.request("POST", "/wda/homescreen", {});
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
+ },
2711
+ async close() {
2712
+ await transport.request("DELETE", base).catch(() => void 0);
2713
+ }
2714
+ };
2715
+ }
2716
+
2717
+ // src/browser/ios-helper.ts
2718
+ import { createRequire as createRequire2 } from "module";
2719
+ import { execFile as execFile4 } from "child_process";
2720
+ import fs5 from "fs";
2721
+ import os2 from "os";
2722
+ import path5 from "path";
2723
+ var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
2724
+ var WDA_USE_PORT_ENV = "USE_PORT";
2725
+ var PREPARED_XCTESTRUN_PREFIX = "prowl-wda-xctestrun-";
2726
+ var WDA_STARTUP_ATTEMPTS = 2;
2727
+ var defaultIosAgentConnector = async ({
2728
+ host,
2729
+ port,
2730
+ bundleId,
2731
+ requestTimeoutMs,
2732
+ readyDeadlineMs
2733
+ }) => {
2734
+ const transport = new WdaTransport({ baseUrl: `http://${host}:${port}`, requestTimeoutMs });
2735
+ await waitForWdaReady(transport, { deadlineMs: readyDeadlineMs });
2736
+ const sessionId = await createWdaSession(transport, bundleId);
2737
+ return createWdaAgentClient(transport, sessionId);
2738
+ };
2739
+ function resolveWdaProject(requireFn = createRequire2(import.meta.url)) {
2740
+ let pkgJsonPath;
2741
+ try {
2742
+ pkgJsonPath = requireFn.resolve("appium-webdriveragent/package.json");
2743
+ } catch {
2744
+ throw new Error(
2745
+ "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"
2746
+ );
2747
+ }
2748
+ const pkgDir = path5.dirname(pkgJsonPath);
2749
+ const version = requireFn(pkgJsonPath).version;
2750
+ const projectPath = path5.join(pkgDir, "WebDriverAgent.xcodeproj");
2751
+ if (!fs5.existsSync(projectPath)) {
2752
+ throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
2753
+ }
2754
+ return { projectPath, version };
2755
+ }
2756
+ function wdaCacheDir(wdaVersion, xcode, homeDir = os2.homedir()) {
2757
+ return path5.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
2758
+ }
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;
2771
+ }
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 = {}) {
2797
+ const runner = options.runner ?? execFileXcrunRunner;
2798
+ const env = options.env ?? process.env;
2799
+ const homeDir = options.homeDir ?? os2.homedir();
2800
+ const log = options.logger ?? ((message) => process.stderr.write(`${message}
2801
+ `));
2802
+ const override = env.PROWL_WDA_RUNNER;
2803
+ if (override) {
2804
+ return resolveOverrideXctestrun(override);
2805
+ }
2806
+ const { projectPath, version } = resolveWdaProject(options.requireFn);
2807
+ const xcode = await xcodeVersion(runner);
2808
+ if (!xcode) {
2809
+ throw new Error(
2810
+ "Could not determine the Xcode version (`xcrun xcodebuild -version`). The iOS target requires Xcode on macOS. Install it and run `xcode-select --switch`, then retry."
2811
+ );
2812
+ }
2813
+ const cacheDir = wdaCacheDir(version, xcode, homeDir);
2814
+ const cached = findXctestrunIn(productsDir(cacheDir));
2815
+ if (cached) {
2816
+ return cached;
2817
+ }
2818
+ log(
2819
+ `Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
2820
+ );
2821
+ fs5.mkdirSync(cacheDir, { recursive: true });
2822
+ const result = await runner(
2823
+ [
2824
+ "xcodebuild",
2825
+ "build-for-testing",
2826
+ "-project",
2827
+ projectPath,
2828
+ "-scheme",
2829
+ "WebDriverAgentRunner",
2830
+ "-destination",
2831
+ "generic/platform=iOS Simulator",
2832
+ "-derivedDataPath",
2833
+ cacheDir,
2834
+ "CODE_SIGNING_ALLOWED=NO"
2835
+ ],
2836
+ { timeoutMs: 12e5 }
2837
+ );
2838
+ if (result.code !== 0) {
2839
+ throw new Error(
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)}`
2841
+ );
2842
+ }
2843
+ const built = findXctestrunIn(productsDir(cacheDir));
2844
+ if (!built) {
2845
+ throw new Error(
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.`
2847
+ );
2848
+ }
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
+ ];
2943
+ }
2944
+ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
2945
+ if (!looksLikeIosAppPath(app)) {
2946
+ assertIosAppAllowed(allowedApps, app);
2947
+ if (coldStart) {
2948
+ throw new Error(
2949
+ `coldStart requires target.app to be a built .app bundle path (a bare bundle id like "${app}" cannot be reinstalled). Point target.app at the .app, or drop coldStart.`
2950
+ );
2951
+ }
2952
+ return app;
2953
+ }
2954
+ const appPath = path5.resolve(app);
2955
+ if (!fs5.existsSync(appPath)) {
2956
+ throw new Error(`.app bundle not found: ${appPath}`);
2957
+ }
2958
+ const bundleId = readIosBundleIdentifier(appPath);
2959
+ if (!bundleId) {
2960
+ throw new Error(
2961
+ `Could not read CFBundleIdentifier from "${appPath}" (root Info.plist). Ensure target.app points at a built iOS .app bundle.`
2962
+ );
2963
+ }
2964
+ assertIosAppAllowed(allowedApps, appPath);
2965
+ if (coldStart) {
2966
+ await uninstallApp(runner, udid, bundleId);
2967
+ }
2968
+ await installApp(runner, udid, appPath);
2969
+ return bundleId;
2970
+ }
2971
+ async function launchIosSession(options) {
2972
+ const runner = options.runner ?? execFileXcrunRunner;
2973
+ const spawner = options.spawner ?? spawnXcrunProcess;
2974
+ const preparer = options.testRunPreparer ?? defaultWdaTestRunPreparer;
2975
+ const portAllocator = options.portAllocator ?? findFreePort;
2976
+ const connector = options.agentConnector ?? defaultIosAgentConnector;
2977
+ const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
2978
+ const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, 6e4);
2979
+ const devices = await listSimulators(runner);
2980
+ const udid = selectSimulatorUdid(devices, options.udid);
2981
+ const reservation = await reserveSimulatorUdid(udid, { lockRoot: options.simulatorLockRoot });
2982
+ let reservationReleased = false;
2983
+ const releaseReservation = async () => {
2984
+ if (reservationReleased) {
2985
+ return;
2986
+ }
2987
+ reservationReleased = true;
2988
+ await reservation.release();
2989
+ };
2990
+ try {
2991
+ const bundleId = await resolveBundleId(
2992
+ options.app,
2993
+ runner,
2994
+ udid,
2995
+ options.coldStart ?? false,
2996
+ options.allowedApps ?? []
2997
+ );
2998
+ const baseTestRun = options.wdaTestRun ?? await resolveWdaTestRun({ runner, logger: options.logger });
2999
+ for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
3000
+ let client;
3001
+ let wdaProcess;
3002
+ let preparedTestRun;
3003
+ let tornDown = false;
3004
+ let terminateTarget = false;
3005
+ let stage = "prepare";
3006
+ const teardownAttempt = async () => {
3007
+ if (tornDown) {
3008
+ return;
3009
+ }
3010
+ tornDown = true;
3011
+ if (client) {
3012
+ await client.close().catch(() => void 0);
3013
+ }
3014
+ wdaProcess?.kill();
3015
+ await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
3016
+ if (terminateTarget) {
3017
+ await terminateApp(runner, udid, bundleId);
3018
+ }
3019
+ cleanupPreparedTestRun(preparedTestRun);
3020
+ };
3021
+ try {
3022
+ const port = await portAllocator();
3023
+ preparedTestRun = await preparer({ xctestrunPath: baseTestRun, port });
3024
+ stage = "wda-launch";
3025
+ wdaProcess = spawner(wdaTestRunArgs(preparedTestRun, udid));
3026
+ stage = "target-launch";
3027
+ terminateTarget = true;
3028
+ await launchApp(runner, udid, bundleId);
3029
+ stage = "connect";
3030
+ client = await connector({ host: "127.0.0.1", port, bundleId, requestTimeoutMs, readyDeadlineMs });
3031
+ const driver = createIosDriver(client, {
3032
+ appLabel: bundleId,
3033
+ captureScreenshot: (outPath) => captureScreenshot(runner, udid, outPath)
3034
+ });
3035
+ const teardown = async () => {
3036
+ await teardownAttempt();
3037
+ await releaseReservation();
3038
+ };
3039
+ return { client, driver, bundleId, udid, teardown };
3040
+ } catch (error) {
3041
+ await teardownAttempt();
3042
+ if (stage === "target-launch" || attempt >= WDA_STARTUP_ATTEMPTS) {
3043
+ throw error instanceof Error ? error : new Error(String(error));
3044
+ }
3045
+ }
3046
+ }
3047
+ throw new Error("WebDriverAgent startup failed without an error.");
3048
+ } catch (error) {
3049
+ await releaseReservation().catch(() => void 0);
3050
+ throw error;
3051
+ }
3052
+ }
3053
+ async function closeIosSession(session) {
3054
+ await session.teardown();
3055
+ }
3056
+
3057
+ // src/runner/healing.ts
3058
+ var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
3059
+ function extractSelectorIntent(selector) {
3060
+ const raw = [];
3061
+ for (const match of selector.matchAll(/[#.]([A-Za-z_][\w-]*)/g)) {
3062
+ raw.push(match[1]);
3063
+ }
3064
+ for (const match of selector.matchAll(/\[[A-Za-z_:-]+\s*[~|^$*]?=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\]/g)) {
3065
+ const value = match[1] ?? match[2] ?? match[3];
3066
+ if (value) raw.push(value);
3067
+ }
3068
+ const words = [];
3069
+ for (const token of raw) {
3070
+ for (const part of splitToken(token)) {
3071
+ const lower = part.toLowerCase();
3072
+ if (lower.length > 0 && !words.includes(lower)) {
3073
+ words.push(lower);
3074
+ }
3075
+ }
3076
+ }
3077
+ return { words, label: words.join(" ") };
3078
+ }
3079
+ function splitToken(token) {
3080
+ return token.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[\s\-_.:]+/).filter((part) => part.length > 0);
3081
+ }
3082
+ function buildHealCandidates(selector) {
3083
+ const { words, label } = extractSelectorIntent(selector);
3084
+ if (words.length === 0) return [];
3085
+ const escaped = label.replace(/"/g, '\\"');
3086
+ const candidates = [];
3087
+ candidates.push({ selector: `text=${label}`, strategy: "text" });
3088
+ candidates.push({ selector: `[aria-label*="${escaped}" i]`, strategy: "aria" });
3089
+ for (const tag of INTERACTIVE_TAGS) {
3090
+ candidates.push({ selector: `${tag}:has-text("${escaped}")`, strategy: "structural" });
3091
+ }
3092
+ return candidates;
3093
+ }
3094
+ async function healSelector(probe, selector, options) {
3095
+ if (!options.enabled) return null;
3096
+ for (const candidate of buildHealCandidates(selector)) {
3097
+ let count;
3098
+ try {
3099
+ const locator2 = probe.locator(candidate.selector);
3100
+ count = await locator2.count();
3101
+ } catch {
3102
+ continue;
3103
+ }
3104
+ if (count === 1) {
3105
+ return { selector: candidate.selector, healedFrom: selector, strategy: candidate.strategy };
3106
+ }
3107
+ }
3108
+ return null;
3109
+ }
3110
+
3111
+ // src/runner/history.ts
3112
+ import fs6 from "fs";
3113
+ import path6 from "path";
3114
+ var HISTORY_FILE = "history.json";
3115
+ var LOCK_FILE_SUFFIX = ".lock";
3116
+ var LOCK_RETRY_MS = 10;
3117
+ var LOCK_TIMEOUT_MS = 5e3;
3118
+ var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
3119
+ function historyPath(configDir) {
3120
+ return path6.join(configDir, HISTORY_FILE);
3121
+ }
3122
+ function isHistoryEntry(value) {
3123
+ if (!value || typeof value !== "object") {
3124
+ return false;
3125
+ }
3126
+ const entry = value;
3127
+ return typeof entry.hunt === "string" && (entry.status === "pass" || entry.status === "fail") && typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && typeof entry.startedAt === "string" && (entry.runDir === void 0 || typeof entry.runDir === "string");
3128
+ }
3129
+ function readHistory(configDir) {
3130
+ const filePath = historyPath(configDir);
3131
+ if (!fs6.existsSync(filePath)) {
3132
+ return { entries: [] };
3133
+ }
3134
+ try {
3135
+ const raw = fs6.readFileSync(filePath, "utf-8");
3136
+ const parsed = JSON.parse(raw);
3137
+ if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
3138
+ const validatedEntries = parsed.entries.filter(isHistoryEntry);
3139
+ return { entries: validatedEntries };
3140
+ }
3141
+ return { entries: [] };
3142
+ } catch (error) {
3143
+ const message = error instanceof Error ? error.message : String(error);
3144
+ console.warn(`Failed to read history file at ${filePath}: ${message}`);
3145
+ return { entries: [] };
3146
+ }
3147
+ }
3148
+ function readHuntHistory(configDir, huntName) {
3149
+ const { entries } = readHistory(configDir);
3150
+ return entries.filter((entry) => entry.hunt === huntName);
3151
+ }
3152
+ function pruneEntries(entries, maxRuns) {
3153
+ const perHunt = /* @__PURE__ */ new Map();
3154
+ for (const entry of entries) {
3155
+ const list = perHunt.get(entry.hunt) ?? [];
3156
+ list.push(entry);
3157
+ perHunt.set(entry.hunt, list);
3158
+ }
3159
+ const keptEntries = /* @__PURE__ */ new Set();
3160
+ for (const list of perHunt.values()) {
3161
+ const kept = list.length > maxRuns ? list.slice(list.length - maxRuns) : list;
3162
+ for (const entry of kept) {
3163
+ keptEntries.add(entry);
3164
+ }
3165
+ }
3166
+ return entries.filter((entry) => keptEntries.has(entry));
3167
+ }
3168
+ function sleepSync(ms) {
3169
+ Atomics.wait(SLEEP_BUFFER, 0, 0, ms);
3170
+ }
3171
+ function withHistoryLock(configDir, fn) {
3172
+ const filePath = historyPath(configDir);
3173
+ const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
3174
+ fs6.mkdirSync(path6.dirname(filePath), { recursive: true });
3175
+ const startedAt = Date.now();
3176
+ while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
3177
+ let fd;
3178
+ try {
3179
+ fd = fs6.openSync(lockPath, "wx");
3180
+ } catch (error) {
3181
+ if (error.code === "EEXIST") {
3182
+ sleepSync(LOCK_RETRY_MS);
3183
+ continue;
3184
+ }
3185
+ throw error;
3186
+ }
3187
+ try {
3188
+ return fn();
3189
+ } finally {
3190
+ try {
3191
+ fs6.closeSync(fd);
3192
+ } catch {
3193
+ }
3194
+ fs6.rmSync(lockPath, { force: true });
3195
+ }
3196
+ }
3197
+ throw new Error(
3198
+ `Failed to acquire history lock before timeout (${LOCK_TIMEOUT_MS}ms): ${lockPath}; started waiting at ${new Date(startedAt).toISOString()}`
3199
+ );
3200
+ }
3201
+ function appendEntry(configDir, entry, maxRuns) {
3202
+ const filePath = historyPath(configDir);
3203
+ withHistoryLock(configDir, () => {
3204
+ const current = readHistory(configDir);
3205
+ const next = pruneEntries([...current.entries, entry], maxRuns);
3206
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
3207
+ fs6.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
3208
+ `);
3209
+ fs6.renameSync(tempPath, filePath);
3210
+ });
3211
+ }
3212
+
3213
+ // src/runner/index.ts
3214
+ import fs12 from "fs";
3215
+ import path12 from "path";
3216
+
3217
+ // src/browser/playwright-driver.ts
3218
+ import fs7 from "fs";
3219
+ import path7 from "path";
3220
+ import {
3221
+ chromium,
3222
+ firefox,
3223
+ webkit
3224
+ } from "playwright";
3225
+ var ENGINES = { chromium, firefox, webkit };
3226
+ async function launchBrowser(options) {
3227
+ const engineName = options.engine ?? "chromium";
3228
+ const engine = Object.prototype.hasOwnProperty.call(ENGINES, engineName) ? ENGINES[engineName] : void 0;
3229
+ if (!engine) {
3230
+ throw new Error(
3231
+ `Unsupported browser engine "${String(engineName)}". Available engines: ${Object.keys(ENGINES).join(", ")}.`
3232
+ );
3233
+ }
3234
+ const browser = await engine.launch({
3235
+ headless: options.headless,
3236
+ slowMo: options.slowMo,
3237
+ channel: options.channel
1071
3238
  });
1072
3239
  try {
1073
3240
  const contextOptions = {};
@@ -1075,14 +3242,14 @@ async function launchBrowser(options) {
1075
3242
  contextOptions.viewport = options.viewport;
1076
3243
  }
1077
3244
  if (options.storageStatePath) {
1078
- if (fs4.existsSync(options.storageStatePath)) {
3245
+ if (fs7.existsSync(options.storageStatePath)) {
1079
3246
  contextOptions.storageState = options.storageStatePath;
1080
3247
  } else {
1081
3248
  console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
1082
3249
  }
1083
3250
  }
1084
3251
  if (options.recordHar) {
1085
- contextOptions.recordHar = { path: path4.join(options.runDir, "network.har") };
3252
+ contextOptions.recordHar = { path: path7.join(options.runDir, "network.har") };
1086
3253
  }
1087
3254
  const context = await browser.newContext(contextOptions);
1088
3255
  const page = await context.newPage();
@@ -1090,7 +3257,7 @@ async function launchBrowser(options) {
1090
3257
  page.setDefaultNavigationTimeout(options.timeout);
1091
3258
  let tracePath;
1092
3259
  if (options.trace) {
1093
- tracePath = path4.join(options.runDir, "trace.zip");
3260
+ tracePath = path7.join(options.runDir, "trace.zip");
1094
3261
  await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
1095
3262
  }
1096
3263
  return { browser, context, page, tracePath };
@@ -1268,8 +3435,8 @@ function createPlaywrightDriver(page) {
1268
3435
  }
1269
3436
 
1270
3437
  // src/runner/steps.ts
1271
- import fs5 from "fs";
1272
- import path5 from "path";
3438
+ import fs8 from "fs";
3439
+ import path8 from "path";
1273
3440
 
1274
3441
  // src/runner/policy.ts
1275
3442
  var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
@@ -1348,25 +3515,265 @@ function createRunPolicy(driver, options) {
1348
3515
  if (matched) {
1349
3516
  return { selector };
1350
3517
  }
1351
- const healed = await healSelector(healProbe, selector, { enabled: true });
1352
- if (!healed) {
1353
- return { selector };
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
+ );
3647
+ }
3648
+ if (typeof parsed !== "object" || parsed === null) {
3649
+ throw new Error(`AI verdict was not a JSON object: ${truncateForError(raw)}`);
3650
+ }
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
+ ]
3709
+ }
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
+ ]
1354
3734
  }
1355
- assertAllowedSelector(healed.selector);
1356
- console.warn(
1357
- `Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
1358
- );
1359
- 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}`);
1360
3748
  }
3749
+ return await response.json();
3750
+ }
3751
+ function anthropicHeaders(config) {
1361
3752
  return {
1362
- assertWithinMaxSteps,
1363
- ensureUrlAllowed,
1364
- ensureAppAllowed,
1365
- ensureLocationAllowed,
1366
- assertAllowedSelector,
1367
- resolveActionSelector
3753
+ "Content-Type": "application/json",
3754
+ "x-api-key": config.apiKey,
3755
+ "anthropic-version": "2023-06-01"
3756
+ };
3757
+ }
3758
+ function openAiHeaders(config) {
3759
+ return {
3760
+ "Content-Type": "application/json",
3761
+ "Authorization": `Bearer ${config.apiKey}`
1368
3762
  };
1369
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
+ }
1370
3777
 
1371
3778
  // src/runner/steps.ts
1372
3779
  function getStepType(step) {
@@ -1396,6 +3803,7 @@ function getStepType(step) {
1396
3803
  if ("evalScript" in step) return "evalScript";
1397
3804
  if ("runScript" in step) return "runScript";
1398
3805
  if ("assertScreenshot" in step) return "assertScreenshot";
3806
+ if ("assertWithAI" in step) return "assertWithAI";
1399
3807
  if ("copyText" in step) return "copyText";
1400
3808
  if ("waitForDownload" in step) return "waitForDownload";
1401
3809
  return "step";
@@ -1455,6 +3863,9 @@ function applyRuntimeVars(step, vars) {
1455
3863
  }
1456
3864
  };
1457
3865
  }
3866
+ if ("assertWithAI" in step) {
3867
+ return { assertWithAI: sub(step.assertWithAI) };
3868
+ }
1458
3869
  if ("copyText" in step) {
1459
3870
  return { copyText: { selector: sub(step.copyText.selector), as: step.copyText.as } };
1460
3871
  }
@@ -1705,7 +4116,7 @@ async function runInlineAssert(driver, policy, assertion) {
1705
4116
  throw new Error("assert step is missing an assertion type");
1706
4117
  }
1707
4118
  function screenshotPath(screenshotsDir, fileName) {
1708
- return path5.join(screenshotsDir, fileName);
4119
+ return path8.join(screenshotsDir, fileName);
1709
4120
  }
1710
4121
  function stepPath(prefix, index) {
1711
4122
  return prefix ? `${prefix}.${index}` : `${index}`;
@@ -1722,12 +4133,12 @@ function validateDownloadFilename(suggestedFilename) {
1722
4133
  const safeFilename = suggestedFilename.trim();
1723
4134
  const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
1724
4135
  const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
1725
- if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== path5.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
4136
+ if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== path8.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
1726
4137
  throw new Error(`Invalid download filename: "${suggestedFilename}"`);
1727
4138
  }
1728
4139
  return safeFilename;
1729
4140
  }
1730
- async function captureScreenshot(taker, filePath) {
4141
+ async function captureScreenshot2(taker, filePath) {
1731
4142
  try {
1732
4143
  await taker.screenshot({ path: filePath, fullPage: true });
1733
4144
  } catch (error) {
@@ -1902,7 +4313,7 @@ var STEP_HANDLERS = {
1902
4313
  if (!("setInputFiles" in h.step)) unknownStep();
1903
4314
  const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
1904
4315
  const rawFiles = h.step.setInputFiles.files;
1905
- const resolveFile = (f) => path5.isAbsolute(f) ? f : path5.join(h.context.configDir, f);
4316
+ const resolveFile = (f) => path8.isAbsolute(f) ? f : path8.join(h.context.configDir, f);
1906
4317
  const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
1907
4318
  await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
1908
4319
  h.policy.ensureLocationAllowed(h.driver);
@@ -1939,7 +4350,7 @@ var STEP_HANDLERS = {
1939
4350
  redactedFillSteps: subRedacted,
1940
4351
  randomVars
1941
4352
  } = interpolateHunt(subHunt, process.env, h.context.randomVars);
1942
- const subTargetType = h.driver.capabilities.has("navigate") ? "web" : "macos";
4353
+ const subTargetType = h.context.targetType ?? (h.driver.capabilities.has("navigate") ? "web" : "macos");
1943
4354
  assertStepsSupportedByTarget(interpolatedSubHunt.steps, subTargetType);
1944
4355
  assertHuntAssertionsSupportedByTarget(interpolatedSubHunt.assertions, subTargetType);
1945
4356
  h.policy.assertWithinMaxSteps(interpolatedSubHunt.steps.length, huntName);
@@ -2264,15 +4675,15 @@ var STEP_HANDLERS = {
2264
4675
  if (!responseFile) {
2265
4676
  throw new Error("mock.response must include either body or file");
2266
4677
  }
2267
- const candidateFilePath = path5.isAbsolute(responseFile) ? responseFile : path5.join(h.context.configDir, responseFile);
2268
- const resolvedConfigDir = path5.resolve(h.context.configDir);
2269
- const resolvedFilePath = path5.resolve(candidateFilePath);
2270
- const relativePath = path5.relative(resolvedConfigDir, resolvedFilePath);
2271
- const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${path5.sep}`) && !path5.isAbsolute(relativePath);
4678
+ const candidateFilePath = path8.isAbsolute(responseFile) ? responseFile : path8.join(h.context.configDir, responseFile);
4679
+ const resolvedConfigDir = path8.resolve(h.context.configDir);
4680
+ const resolvedFilePath = path8.resolve(candidateFilePath);
4681
+ const relativePath = path8.relative(resolvedConfigDir, resolvedFilePath);
4682
+ const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${path8.sep}`) && !path8.isAbsolute(relativePath);
2272
4683
  if (!isWithinConfigDir) {
2273
4684
  throw new Error("mock.response.file must resolve within config directory");
2274
4685
  }
2275
- responseBody = await fs5.promises.readFile(resolvedFilePath, "utf-8");
4686
+ responseBody = await fs8.promises.readFile(resolvedFilePath, "utf-8");
2276
4687
  }
2277
4688
  const contentType = mock.response.contentType ?? "application/json";
2278
4689
  const status = mock.response.status;
@@ -2335,8 +4746,8 @@ var STEP_HANDLERS = {
2335
4746
  capabilities: ["evaluate"],
2336
4747
  run: async (h) => {
2337
4748
  if (!("runScript" in h.step)) unknownStep();
2338
- const filePath = path5.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : path5.join(h.context.configDir, h.step.runScript.file);
2339
- const fileContents = fs5.readFileSync(filePath, "utf-8");
4749
+ const filePath = path8.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : path8.join(h.context.configDir, h.step.runScript.file);
4750
+ const fileContents = fs8.readFileSync(filePath, "utf-8");
2340
4751
  await h.driver.evaluate(fileContents);
2341
4752
  return {
2342
4753
  kind: "result",
@@ -2357,19 +4768,19 @@ var STEP_HANDLERS = {
2357
4768
  const name = h.step.assertScreenshot.name;
2358
4769
  const threshold = h.step.assertScreenshot.threshold ?? 0.1;
2359
4770
  const baselineDir = ensureBaselineDir(h.context.configDir);
2360
- const baselinePath = path5.join(baselineDir, `${name}.png`);
2361
- const currentScreenshotPath = path5.join(h.context.runDir, "screenshots", `${name}-current.png`);
2362
- fs5.mkdirSync(path5.dirname(currentScreenshotPath), { recursive: true });
4771
+ const baselinePath = path8.join(baselineDir, `${name}.png`);
4772
+ const currentScreenshotPath = path8.join(h.context.runDir, "screenshots", `${name}-current.png`);
4773
+ fs8.mkdirSync(path8.dirname(currentScreenshotPath), { recursive: true });
2363
4774
  await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
2364
- h.screenshots.push(path5.join("screenshots", `${name}-current.png`));
2365
- if (!fs5.existsSync(baselinePath)) {
2366
- fs5.copyFileSync(currentScreenshotPath, baselinePath);
4775
+ h.screenshots.push(path8.join("screenshots", `${name}-current.png`));
4776
+ if (!fs8.existsSync(baselinePath)) {
4777
+ fs8.copyFileSync(currentScreenshotPath, baselinePath);
2367
4778
  return {
2368
4779
  kind: "result",
2369
4780
  result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
2370
4781
  };
2371
4782
  }
2372
- const diffPath = path5.join(h.context.runDir, "screenshots", `${name}-diff.png`);
4783
+ const diffPath = path8.join(h.context.runDir, "screenshots", `${name}-diff.png`);
2373
4784
  const comparison = await compareScreenshots(baselinePath, currentScreenshotPath, diffPath, threshold);
2374
4785
  if (comparison.match) {
2375
4786
  return {
@@ -2382,12 +4793,54 @@ var STEP_HANDLERS = {
2382
4793
  }
2383
4794
  };
2384
4795
  }
2385
- h.screenshots.push(path5.join("screenshots", `${name}-diff.png`));
4796
+ h.screenshots.push(path8.join("screenshots", `${name}-diff.png`));
2386
4797
  throw new Error(
2387
4798
  `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
2388
4799
  );
2389
4800
  }
2390
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
+ },
2391
4844
  copyText: {
2392
4845
  capabilities: ["query"],
2393
4846
  run: async (h) => {
@@ -2424,7 +4877,7 @@ var STEP_HANDLERS = {
2424
4877
  `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
2425
4878
  );
2426
4879
  }
2427
- const savePath = path5.join(h.context.runDir, suggestedFilename);
4880
+ const savePath = path8.join(h.context.runDir, suggestedFilename);
2428
4881
  await download.saveAs(savePath);
2429
4882
  return {
2430
4883
  kind: "result",
@@ -2453,8 +4906,8 @@ async function executeSteps(context) {
2453
4906
  maxSteps: context.maxSteps,
2454
4907
  selfHealing: context.selfHealing
2455
4908
  });
2456
- const screenshotsDir = path5.join(context.runDir, "screenshots");
2457
- fs5.mkdirSync(screenshotsDir, { recursive: true });
4909
+ const screenshotsDir = path8.join(context.runDir, "screenshots");
4910
+ fs8.mkdirSync(screenshotsDir, { recursive: true });
2458
4911
  const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
2459
4912
  policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
2460
4913
  const results = [];
@@ -2463,8 +4916,8 @@ async function executeSteps(context) {
2463
4916
  context.runStartedAtMs = runStartedAtMs;
2464
4917
  const addScreenshot = async (fileName) => {
2465
4918
  const fullPath = screenshotPath(screenshotsDir, fileName);
2466
- await captureScreenshot(driver, fullPath);
2467
- const relative = path5.join("screenshots", fileName);
4919
+ await captureScreenshot2(driver, fullPath);
4920
+ const relative = path8.join("screenshots", fileName);
2468
4921
  screenshots.push(relative);
2469
4922
  return relative;
2470
4923
  };
@@ -2552,12 +5005,12 @@ async function executeSteps(context) {
2552
5005
  return { results, screenshots, failed: false };
2553
5006
  }
2554
5007
  async function captureFinalScreenshot(page, runDir) {
2555
- const screenshotsDir = path5.join(runDir, "screenshots");
2556
- fs5.mkdirSync(screenshotsDir, { recursive: true });
5008
+ const screenshotsDir = path8.join(runDir, "screenshots");
5009
+ fs8.mkdirSync(screenshotsDir, { recursive: true });
2557
5010
  const fileName = "final.png";
2558
5011
  const filePath = screenshotPath(screenshotsDir, fileName);
2559
- await captureScreenshot(page, filePath);
2560
- return path5.join("screenshots", fileName);
5012
+ await captureScreenshot2(page, filePath);
5013
+ return path8.join("screenshots", fileName);
2561
5014
  }
2562
5015
 
2563
5016
  // src/runner/assertions.ts
@@ -2717,18 +5170,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
2717
5170
  }
2718
5171
 
2719
5172
  // src/reporter/result.ts
2720
- import fs6 from "fs";
2721
- import path6 from "path";
5173
+ import fs9 from "fs";
5174
+ import path9 from "path";
2722
5175
  function writeResult(runDir, result) {
2723
5176
  const fileName = "result.json";
2724
- const fullPath = path6.join(runDir, fileName);
2725
- fs6.writeFileSync(fullPath, JSON.stringify(result, null, 2));
5177
+ const fullPath = path9.join(runDir, fileName);
5178
+ fs9.writeFileSync(fullPath, JSON.stringify(result, null, 2));
2726
5179
  return fileName;
2727
5180
  }
2728
5181
 
2729
5182
  // src/reporter/summary.ts
2730
- import fs7 from "fs";
2731
- import path7 from "path";
5183
+ import fs10 from "fs";
5184
+ import path10 from "path";
2732
5185
  function escapeMd(text) {
2733
5186
  return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
2734
5187
  }
@@ -2806,15 +5259,15 @@ function writeSummary(runDir, result) {
2806
5259
  }
2807
5260
  }
2808
5261
  const fileName = "summary.md";
2809
- const fullPath = path7.join(runDir, fileName);
2810
- fs7.writeFileSync(fullPath, `${lines.join("\n")}
5262
+ const fullPath = path10.join(runDir, fileName);
5263
+ fs10.writeFileSync(fullPath, `${lines.join("\n")}
2811
5264
  `);
2812
5265
  return fileName;
2813
5266
  }
2814
5267
 
2815
5268
  // src/reporter/junit.ts
2816
- import fs8 from "fs";
2817
- import path8 from "path";
5269
+ import fs11 from "fs";
5270
+ import path11 from "path";
2818
5271
  function escapeXml(text) {
2819
5272
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
2820
5273
  }
@@ -2858,8 +5311,8 @@ function writeJunit(runDir, result) {
2858
5311
  lines.push(" </testsuite>");
2859
5312
  lines.push("</testsuites>");
2860
5313
  const fileName = "junit.xml";
2861
- const fullPath = path8.join(runDir, fileName);
2862
- fs8.writeFileSync(fullPath, `${lines.join("\n")}
5314
+ const fullPath = path11.join(runDir, fileName);
5315
+ fs11.writeFileSync(fullPath, `${lines.join("\n")}
2863
5316
  `);
2864
5317
  return fileName;
2865
5318
  }
@@ -2901,11 +5354,11 @@ function parseViewportFlag(value) {
2901
5354
  return value;
2902
5355
  }
2903
5356
  function resolvePath(configDir, inputPath) {
2904
- if (path9.isAbsolute(inputPath)) {
5357
+ if (path12.isAbsolute(inputPath)) {
2905
5358
  return inputPath;
2906
5359
  }
2907
- const projectRoot = path9.dirname(configDir);
2908
- return path9.join(projectRoot, inputPath);
5360
+ const projectRoot = path12.dirname(configDir);
5361
+ return path12.join(projectRoot, inputPath);
2909
5362
  }
2910
5363
  function buildRunResult(options) {
2911
5364
  return {
@@ -2924,12 +5377,12 @@ function buildRunResult(options) {
2924
5377
  }
2925
5378
  function writeConsoleLog(runDir, entries) {
2926
5379
  const fileName = "console.log";
2927
- const filePath = path9.join(runDir, fileName);
5380
+ const filePath = path12.join(runDir, fileName);
2928
5381
  const lines = entries.map((entry) => {
2929
5382
  const location = entry.location ? ` (${entry.location})` : "";
2930
5383
  return `[${entry.type}] ${entry.text}${location}`;
2931
5384
  });
2932
- fs9.writeFileSync(filePath, `${lines.join("\n")}
5385
+ fs12.writeFileSync(filePath, `${lines.join("\n")}
2933
5386
  `);
2934
5387
  return fileName;
2935
5388
  }
@@ -2937,8 +5390,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
2937
5390
  const headless = options.headed ? false : config.browser.headless;
2938
5391
  const slowMo = options.slowMo ?? config.browser.slowMo;
2939
5392
  const maxSteps = config.guardrails.maxSteps;
2940
- const runDir = path9.join(configDir, "runs", timestamp());
2941
- fs9.mkdirSync(runDir, { recursive: true });
5393
+ const runDir = path12.join(configDir, "runs", timestamp());
5394
+ fs12.mkdirSync(runDir, { recursive: true });
2942
5395
  const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
2943
5396
  const engine = options.browser ?? config.browser.engine;
2944
5397
  const channel = options.channel ?? config.browser.channel;
@@ -3055,7 +5508,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
3055
5508
  }
3056
5509
  return { result, runDir, steps: interpolatedHunt.steps };
3057
5510
  }
3058
- function delay(ms) {
5511
+ function delay3(ms) {
3059
5512
  return new Promise((resolve) => setTimeout(resolve, ms));
3060
5513
  }
3061
5514
  async function runHunt(options) {
@@ -3063,6 +5516,12 @@ async function runHunt(options) {
3063
5516
  if (config.target.type === "macos") {
3064
5517
  return runMacHunt(options, config, configDir, config.target);
3065
5518
  }
5519
+ if (config.target.type === "android") {
5520
+ return runAndroidHunt(options, config, configDir, config.target);
5521
+ }
5522
+ if (config.target.type === "ios") {
5523
+ return runIosHunt(options, config, configDir, config.target);
5524
+ }
3066
5525
  const hunt = loadHunt(options.huntName, configDir);
3067
5526
  const {
3068
5527
  hunt: interpolatedHunt,
@@ -3084,7 +5543,7 @@ async function runHunt(options) {
3084
5543
  let lastResult;
3085
5544
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
3086
5545
  if (attempt > 0 && retryDelay > 0) {
3087
- await delay(retryDelay);
5546
+ await delay3(retryDelay);
3088
5547
  }
3089
5548
  lastResult = await executeHuntAttempt(
3090
5549
  options,
@@ -3113,19 +5572,17 @@ async function runHunt(options) {
3113
5572
  }
3114
5573
  return lastResult;
3115
5574
  }
3116
- async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
5575
+ async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
3117
5576
  const maxSteps = config.guardrails.maxSteps;
3118
- const runDir = path9.join(configDir, "runs", timestamp());
3119
- fs9.mkdirSync(runDir, { recursive: true });
3120
- const session = await launchMacSession({
3121
- app: target.app,
3122
- timeoutMs: config.browser.timeout,
3123
- clientFactory: options.macClientFactory
3124
- });
5577
+ const runDir = path12.join(configDir, "runs", timestamp());
5578
+ fs12.mkdirSync(runDir, { recursive: true });
5579
+ const session = await native.launchSession();
3125
5580
  let result;
3126
5581
  try {
3127
- const targetLabel = `macos:${session.bundleId}`;
3128
- const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, target.app, session.bundleId])];
5582
+ const driver = native.sessionDriver(session);
5583
+ const appIdentity = native.sessionAppIdentity(session);
5584
+ const targetLabel = `${native.targetType}:${appIdentity}`;
5585
+ const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
3129
5586
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3130
5587
  const startTime = Date.now();
3131
5588
  let stepResults = [];
@@ -3133,7 +5590,8 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3133
5590
  let stepFailed = false;
3134
5591
  try {
3135
5592
  const stepExecution = await executeSteps({
3136
- driver: session.driver,
5593
+ driver,
5594
+ targetType: native.targetType,
3137
5595
  steps: interpolatedHunt.steps,
3138
5596
  targetUrl: targetLabel,
3139
5597
  runDir,
@@ -3160,7 +5618,7 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3160
5618
  }
3161
5619
  let finalScreenshot;
3162
5620
  try {
3163
- finalScreenshot = await captureFinalScreenshot(session.driver, runDir);
5621
+ finalScreenshot = await captureFinalScreenshot(driver, runDir);
3164
5622
  } catch {
3165
5623
  finalScreenshot = void 0;
3166
5624
  }
@@ -3181,16 +5639,116 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3181
5639
  });
3182
5640
  result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
3183
5641
  } finally {
3184
- await closeMacSession(session);
5642
+ await native.closeSession(session);
3185
5643
  }
3186
5644
  return { result, runDir, steps: interpolatedHunt.steps };
3187
5645
  }
5646
+ async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
5647
+ return executeNativeHuntAttempt(
5648
+ options,
5649
+ config,
5650
+ configDir,
5651
+ interpolatedHunt,
5652
+ redactedFillSteps,
5653
+ randomVars,
5654
+ allowedApps,
5655
+ {
5656
+ targetType: "macos",
5657
+ targetApp: target.app,
5658
+ launchSession: () => launchMacSession({
5659
+ app: target.app,
5660
+ timeoutMs: config.browser.timeout,
5661
+ clientFactory: options.macClientFactory
5662
+ }),
5663
+ closeSession: closeMacSession,
5664
+ sessionDriver: (session) => session.driver,
5665
+ sessionAppIdentity: (session) => session.bundleId
5666
+ }
5667
+ );
5668
+ }
3188
5669
  async function runMacHunt(options, config, configDir, target) {
5670
+ return runNativeHunt(options, config, configDir, target, {
5671
+ targetType: "macos",
5672
+ assertAppAllowed: (allowedApps, nativeTarget) => assertTargetAppAllowed(allowedApps, nativeTarget.app),
5673
+ attempt: executeMacHuntAttempt
5674
+ });
5675
+ }
5676
+ async function executeAndroidHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
5677
+ const launch = options.androidSessionFactory ?? launchAndroidSession;
5678
+ return executeNativeHuntAttempt(
5679
+ options,
5680
+ config,
5681
+ configDir,
5682
+ interpolatedHunt,
5683
+ redactedFillSteps,
5684
+ randomVars,
5685
+ allowedApps,
5686
+ {
5687
+ targetType: "android",
5688
+ targetApp: target.app,
5689
+ launchSession: () => launch({
5690
+ app: target.app,
5691
+ deviceSerial: target.deviceSerial,
5692
+ coldStart: target.coldStart,
5693
+ timeoutMs: config.browser.timeout,
5694
+ allowedApps
5695
+ }),
5696
+ closeSession: closeAndroidSession,
5697
+ sessionDriver: (session) => session.driver,
5698
+ sessionAppIdentity: (session) => session.package
5699
+ }
5700
+ );
5701
+ }
5702
+ async function runAndroidHunt(options, config, configDir, target) {
5703
+ return runNativeHunt(options, config, configDir, target, {
5704
+ targetType: "android",
5705
+ assertAppAllowed: (allowedApps, nativeTarget) => {
5706
+ if (!nativeTarget.app.toLowerCase().endsWith(".apk")) {
5707
+ assertAndroidAppAllowed(allowedApps, nativeTarget.app);
5708
+ }
5709
+ },
5710
+ attempt: executeAndroidHuntAttempt
5711
+ });
5712
+ }
5713
+ async function executeIosHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
5714
+ const launch = options.iosSessionFactory ?? launchIosSession;
5715
+ return executeNativeHuntAttempt(
5716
+ options,
5717
+ config,
5718
+ configDir,
5719
+ interpolatedHunt,
5720
+ redactedFillSteps,
5721
+ randomVars,
5722
+ allowedApps,
5723
+ {
5724
+ targetType: "ios",
5725
+ targetApp: target.app,
5726
+ launchSession: () => launch({
5727
+ app: target.app,
5728
+ udid: target.udid,
5729
+ coldStart: target.coldStart,
5730
+ timeoutMs: config.browser.timeout,
5731
+ allowedApps
5732
+ }),
5733
+ closeSession: closeIosSession,
5734
+ sessionDriver: (session) => session.driver,
5735
+ sessionAppIdentity: (session) => session.bundleId
5736
+ }
5737
+ );
5738
+ }
5739
+ async function runIosHunt(options, config, configDir, target) {
5740
+ return runNativeHunt(options, config, configDir, target, {
5741
+ targetType: "ios",
5742
+ assertAppAllowed: (allowedApps, nativeTarget) => assertIosAppAllowed(allowedApps, nativeTarget.app),
5743
+ attempt: executeIosHuntAttempt
5744
+ });
5745
+ }
5746
+ async function runNativeHunt(options, config, configDir, target, native) {
3189
5747
  const hunt = loadHunt(options.huntName, configDir);
3190
5748
  const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
3191
- assertStepsSupportedByTarget(interpolatedHunt.steps, "macos");
3192
- assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, "macos");
3193
- assertTargetAppAllowed(config.guardrails.allowedApps, target.app);
5749
+ assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
5750
+ assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
5751
+ native.assertAppAllowed(config.guardrails.allowedApps, target);
3194
5752
  const maxSteps = config.guardrails.maxSteps;
3195
5753
  if (interpolatedHunt.steps.length > maxSteps) {
3196
5754
  throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
@@ -3200,9 +5758,9 @@ async function runMacHunt(options, config, configDir, target) {
3200
5758
  let lastResult;
3201
5759
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
3202
5760
  if (attempt > 0 && retryDelay > 0) {
3203
- await delay(retryDelay);
5761
+ await delay3(retryDelay);
3204
5762
  }
3205
- lastResult = await executeMacHuntAttempt(
5763
+ lastResult = await native.attempt(
3206
5764
  options,
3207
5765
  config,
3208
5766
  configDir,
@@ -3230,7 +5788,7 @@ async function runMacHunt(options, config, configDir, target) {
3230
5788
  }
3231
5789
  function recordHistory(configDir, outcome, maxRuns) {
3232
5790
  try {
3233
- const relativeRunDir = path9.relative(configDir, outcome.runDir);
5791
+ const relativeRunDir = path12.relative(configDir, outcome.runDir);
3234
5792
  appendEntry(
3235
5793
  configDir,
3236
5794
  {
@@ -3351,8 +5909,8 @@ function clusterFailures(failures) {
3351
5909
  }
3352
5910
 
3353
5911
  // src/backlog/index.ts
3354
- import fs10 from "fs";
3355
- import path10 from "path";
5912
+ import fs13 from "fs";
5913
+ import path13 from "path";
3356
5914
 
3357
5915
  // src/backlog/parse.ts
3358
5916
  var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
@@ -3442,7 +6000,7 @@ ${after}`;
3442
6000
  // src/backlog/index.ts
3443
6001
  function readFileOrEmpty(filePath) {
3444
6002
  try {
3445
- return fs10.readFileSync(filePath, "utf-8");
6003
+ return fs13.readFileSync(filePath, "utf-8");
3446
6004
  } catch (error) {
3447
6005
  const err = error;
3448
6006
  if (err.code === "ENOENT") return "";
@@ -3458,7 +6016,7 @@ function buildFailure(hunt) {
3458
6016
  if (!hunt.runDir) return failure;
3459
6017
  let run;
3460
6018
  try {
3461
- const resultJson = readFileOrEmpty(path10.join(hunt.runDir, "result.json"));
6019
+ const resultJson = readFileOrEmpty(path13.join(hunt.runDir, "result.json"));
3462
6020
  if (!resultJson) return failure;
3463
6021
  run = JSON.parse(resultJson);
3464
6022
  } catch (error) {
@@ -3487,8 +6045,8 @@ function extractFailures(suiteResult) {
3487
6045
  }
3488
6046
  function updateBacklogFromSuite(suiteResult, options = {}) {
3489
6047
  const projectRoot = options.projectRoot ?? process.cwd();
3490
- const backlogPath = options.backlogPath ?? path10.join(projectRoot, "docs", "backlog.md");
3491
- const resolvedPath = options.resolvedPath ?? path10.join(projectRoot, "docs", "resolved.md");
6048
+ const backlogPath = options.backlogPath ?? path13.join(projectRoot, "docs", "backlog.md");
6049
+ const resolvedPath = options.resolvedPath ?? path13.join(projectRoot, "docs", "resolved.md");
3492
6050
  const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
3493
6051
  const summary = { created: [], regressions: [], skipped: [], backlogPath };
3494
6052
  const failures = extractFailures(suiteResult);
@@ -3520,18 +6078,18 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
3520
6078
  }
3521
6079
  }
3522
6080
  if (ticketsToAdd.length > 0) {
3523
- fs10.mkdirSync(path10.dirname(backlogPath), { recursive: true });
3524
- fs10.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
6081
+ fs13.mkdirSync(path13.dirname(backlogPath), { recursive: true });
6082
+ fs13.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
3525
6083
  }
3526
6084
  return summary;
3527
6085
  }
3528
6086
 
3529
6087
  // src/runner/suite.ts
3530
- import path12 from "path";
6088
+ import path15 from "path";
3531
6089
 
3532
6090
  // src/reporter/ci-summary.ts
3533
- import fs11 from "fs";
3534
- import path11 from "path";
6091
+ import fs14 from "fs";
6092
+ import path14 from "path";
3535
6093
  import chalk from "chalk";
3536
6094
  function countCiResults(results) {
3537
6095
  return {
@@ -3595,9 +6153,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
3595
6153
  ...flaky.length > 0 ? { flaky } : {},
3596
6154
  ...clusters.length > 0 ? { clusters } : {}
3597
6155
  };
3598
- fs11.mkdirSync(ciRunDir, { recursive: true });
3599
- const filePath = path11.join(ciRunDir, "ci-result.json");
3600
- fs11.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
6156
+ fs14.mkdirSync(ciRunDir, { recursive: true });
6157
+ const filePath = path14.join(ciRunDir, "ci-result.json");
6158
+ fs14.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
3601
6159
  return filePath;
3602
6160
  }
3603
6161
 
@@ -3782,7 +6340,7 @@ async function runSuite(options = {}) {
3782
6340
  const clusters = clusterFailures(
3783
6341
  extractFailures({ result: { hunts: results }, resultPath: null })
3784
6342
  ).filter((cluster) => cluster.count > 1);
3785
- const ciRunDir = path12.join(configDir, "runs", timestamp("ci"));
6343
+ const ciRunDir = path15.join(configDir, "runs", timestamp("ci"));
3786
6344
  const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
3787
6345
  const { passed, failed, skipped } = countCiResults(results);
3788
6346
  return {
@@ -3917,6 +6475,401 @@ async function analyzePage(page) {
3917
6475
  };
3918
6476
  }
3919
6477
 
6478
+ // src/analyzer/mac.ts
6479
+ var INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
6480
+ "AXButton",
6481
+ "AXTextField",
6482
+ "AXSecureTextField",
6483
+ "AXTextArea",
6484
+ "AXCheckBox",
6485
+ "AXRadioButton",
6486
+ "AXPopUpButton",
6487
+ "AXMenuButton",
6488
+ "AXLink",
6489
+ "AXMenuItem",
6490
+ "AXComboBox",
6491
+ "AXSlider",
6492
+ "AXDisclosureTriangle"
6493
+ ]);
6494
+ var DEFAULT_ANALYZE_TREE_DEPTH = 20;
6495
+ function str(value) {
6496
+ if (typeof value !== "string") {
6497
+ return void 0;
6498
+ }
6499
+ const trimmed = value.trim();
6500
+ return trimmed.length > 0 ? trimmed : void 0;
6501
+ }
6502
+ function toNode(raw) {
6503
+ const node = raw ?? {};
6504
+ const children = Array.isArray(node.children) ? node.children.map(toNode) : void 0;
6505
+ return {
6506
+ role: str(node.role),
6507
+ title: str(node.title),
6508
+ description: str(node.description),
6509
+ value: str(node.value),
6510
+ identifier: str(node.identifier),
6511
+ enabled: typeof node.enabled === "boolean" ? node.enabled : void 0,
6512
+ ...children ? { children } : {}
6513
+ };
6514
+ }
6515
+ function quote(value) {
6516
+ return `"${value}"`;
6517
+ }
6518
+ function rankMacSelectors(node) {
6519
+ const selectors = [];
6520
+ const identifier = node.identifier;
6521
+ const exactLabel = node.title ?? node.description;
6522
+ const name = node.title ?? node.description ?? node.value;
6523
+ if (identifier) {
6524
+ selectors.push(`id=${identifier}`);
6525
+ }
6526
+ if (exactLabel) {
6527
+ selectors.push(`label=${quote(exactLabel)}`);
6528
+ }
6529
+ if (node.role && name) {
6530
+ selectors.push(`role=${node.role}[name=${quote(name)}]`);
6531
+ }
6532
+ if (name) {
6533
+ selectors.push(`text=${quote(name)}`);
6534
+ }
6535
+ if (selectors.length === 0 && node.role) {
6536
+ selectors.push(`role=${node.role}`);
6537
+ }
6538
+ return selectors;
6539
+ }
6540
+ function toElement(node, source) {
6541
+ return {
6542
+ role: node.role ?? "?",
6543
+ ...node.title ? { title: node.title } : {},
6544
+ ...node.description ? { description: node.description } : {},
6545
+ ...node.value ? { value: node.value } : {},
6546
+ ...node.identifier ? { identifier: node.identifier } : {},
6547
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
6548
+ source,
6549
+ selectors: rankMacSelectors(node)
6550
+ };
6551
+ }
6552
+ function collectInteractive(root) {
6553
+ const out = [];
6554
+ const visit = (node) => {
6555
+ if (node.role && INTERACTIVE_ROLES.has(node.role)) {
6556
+ out.push(toElement(node, "window"));
6557
+ }
6558
+ for (const child of node.children ?? []) {
6559
+ visit(child);
6560
+ }
6561
+ };
6562
+ visit(root);
6563
+ return out;
6564
+ }
6565
+ function toWindow(node) {
6566
+ const [best] = rankMacSelectors(node);
6567
+ return {
6568
+ ...node.title ? { title: node.title } : {},
6569
+ ...node.identifier ? { identifier: node.identifier } : {},
6570
+ selector: best ?? "role=AXWindow"
6571
+ };
6572
+ }
6573
+ async function analyzeMacApp(client, options) {
6574
+ const depth = options.treeDepth ?? DEFAULT_ANALYZE_TREE_DEPTH;
6575
+ const treeResult = await client.request("tree", { depth });
6576
+ const elements = collectInteractive(toNode(treeResult.tree));
6577
+ const windowsResult = await client.request("windows");
6578
+ const rawWindows = Array.isArray(windowsResult.windows) ? windowsResult.windows : [];
6579
+ const windows = rawWindows.map((raw) => toWindow(toNode(raw)));
6580
+ const menuItems = await readStatusMenu(client, options.menuTimeoutSeconds);
6581
+ return { app: options.app, elements, windows, menuItems };
6582
+ }
6583
+ async function readStatusMenu(client, menuTimeoutSeconds) {
6584
+ let statusItems;
6585
+ try {
6586
+ const status = await client.request("statusItems");
6587
+ statusItems = Array.isArray(status.items) ? status.items : [];
6588
+ } catch {
6589
+ return [];
6590
+ }
6591
+ if (statusItems.length === 0) {
6592
+ return [];
6593
+ }
6594
+ const params = menuTimeoutSeconds !== void 0 ? { timeout: menuTimeoutSeconds } : {};
6595
+ try {
6596
+ const menu = await client.request("openMenu", params);
6597
+ const rawItems = Array.isArray(menu.items) ? menu.items : [];
6598
+ return rawItems.map((raw) => toNode(raw)).filter((node) => node.role !== "AXMenuItem" || node.title || node.identifier || node.description).map((node) => toElement(node, "menu"));
6599
+ } catch {
6600
+ return [];
6601
+ } finally {
6602
+ await client.request("closeMenu").catch(() => void 0);
6603
+ }
6604
+ }
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
+
3920
6873
  // src/generator/index.ts
3921
6874
  import yaml from "yaml";
3922
6875
 
@@ -3977,6 +6930,7 @@ var STEP_REFERENCE = `
3977
6930
 
3978
6931
  ### Visual Regression
3979
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
3980
6934
 
3981
6935
  ### Control Flow
3982
6936
  - if: { visible: ".banner", then: [steps...] }
@@ -4018,81 +6972,6 @@ function extractYamlFromResponse(response) {
4018
6972
  return response.trim();
4019
6973
  }
4020
6974
 
4021
- // src/generator/ai.ts
4022
- function resolveAiConfig() {
4023
- const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
4024
- if (provider !== "anthropic" && provider !== "openai") {
4025
- throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
4026
- }
4027
- const apiKey = process.env.PROWL_AI_KEY;
4028
- if (!apiKey) {
4029
- throw new Error(
4030
- "PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
4031
- );
4032
- }
4033
- const defaultModel = provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
4034
- const model = process.env.PROWL_AI_MODEL ?? defaultModel;
4035
- return { provider, model, apiKey };
4036
- }
4037
- async function generateWithAi(prompt, config) {
4038
- if (config.provider === "anthropic") {
4039
- return generateWithAnthropic(prompt, config);
4040
- }
4041
- return generateWithOpenAi(prompt, config);
4042
- }
4043
- async function generateWithAnthropic(prompt, config) {
4044
- const response = await fetch("https://api.anthropic.com/v1/messages", {
4045
- method: "POST",
4046
- headers: {
4047
- "Content-Type": "application/json",
4048
- "x-api-key": config.apiKey,
4049
- "anthropic-version": "2023-06-01"
4050
- },
4051
- body: JSON.stringify({
4052
- model: config.model,
4053
- max_tokens: 4096,
4054
- messages: [
4055
- { role: "user", content: prompt }
4056
- ]
4057
- })
4058
- });
4059
- if (!response.ok) {
4060
- const body = await response.text();
4061
- throw new Error(`Anthropic API error (${response.status}): ${body}`);
4062
- }
4063
- const data = await response.json();
4064
- const textBlock = data.content.find((c) => c.type === "text");
4065
- if (!textBlock?.text) {
4066
- throw new Error("Anthropic API returned no text content");
4067
- }
4068
- return textBlock.text;
4069
- }
4070
- async function generateWithOpenAi(prompt, config) {
4071
- const response = await fetch("https://api.openai.com/v1/chat/completions", {
4072
- method: "POST",
4073
- headers: {
4074
- "Content-Type": "application/json",
4075
- "Authorization": `Bearer ${config.apiKey}`
4076
- },
4077
- body: JSON.stringify({
4078
- model: config.model,
4079
- messages: [
4080
- { role: "user", content: prompt }
4081
- ],
4082
- max_tokens: 4096
4083
- })
4084
- });
4085
- if (!response.ok) {
4086
- const body = await response.text();
4087
- throw new Error(`OpenAI API error (${response.status}): ${body}`);
4088
- }
4089
- const data = await response.json();
4090
- if (!data.choices?.[0]?.message?.content) {
4091
- throw new Error("OpenAI API returned no content");
4092
- }
4093
- return data.choices[0].message.content;
4094
- }
4095
-
4096
6975
  // src/generator/index.ts
4097
6976
  function parseViewportFlag2(value) {
4098
6977
  const match = /^(\d+)x(\d+)$/i.exec(value);
@@ -4141,7 +7020,12 @@ export {
4141
7020
  WEB_ONLY_STEP_TYPES,
4142
7021
  webOnlyReason,
4143
7022
  assertStepsSupportedByTarget,
7023
+ readIosBundleIdentifier,
7024
+ iosAppAllowedIdentities,
4144
7025
  assertTargetAppAllowed,
7026
+ assertIosAppAllowed,
7027
+ androidAppAllowedIdentities,
7028
+ assertAndroidAppAllowed,
4145
7029
  launchBrowser,
4146
7030
  closeBrowser,
4147
7031
  saveStorageState,
@@ -4154,6 +7038,78 @@ export {
4154
7038
  SpawnMacHelperClient,
4155
7039
  launchMacSession,
4156
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,
7058
+ ANDROID_KEYCODES,
7059
+ escapeUiSelectorArg,
7060
+ parseAndroidSelector,
7061
+ androidQueryToLocator,
7062
+ unwrapAndroidTextSelector,
7063
+ createAndroidDriver,
7064
+ parseAdbDevices,
7065
+ bootedDevices,
7066
+ selectDeviceSerial,
7067
+ listDevices,
7068
+ parseForwardPort,
7069
+ parseAaptPackage,
7070
+ DEFAULT_AGENT_REQUEST_TIMEOUT_MS,
7071
+ Uia2HttpError,
7072
+ Uia2Transport,
7073
+ extractElementId,
7074
+ createUia2Session,
7075
+ waitForAgentReady,
7076
+ createUia2AgentClient,
7077
+ UIA2_REMOTE_PORT,
7078
+ resolveAgentApks,
7079
+ defaultAgentConnector,
7080
+ launchAndroidSession,
7081
+ closeAndroidSession,
7082
+ IOS_PRESS_KEYS,
7083
+ escapePredicateArg,
7084
+ parseIosSelector,
7085
+ iosQueryToLocator,
7086
+ unwrapIosTextSelector,
7087
+ createIosDriver,
7088
+ DEFAULT_SIMULATOR_LOCK_ROOT,
7089
+ reserveSimulatorUdid,
7090
+ findFreePort,
7091
+ parseSimctlDevices,
7092
+ bootedSimulators,
7093
+ selectSimulatorUdid,
7094
+ listSimulators,
7095
+ parseXcodeVersion,
7096
+ DEFAULT_WDA_REQUEST_TIMEOUT_MS,
7097
+ WdaHttpError,
7098
+ WdaTransport,
7099
+ createWdaSession,
7100
+ waitForWdaReady,
7101
+ createWdaAgentClient,
7102
+ WDA_RUNNER_BUNDLE_ID,
7103
+ WDA_USE_PORT_ENV,
7104
+ defaultIosAgentConnector,
7105
+ resolveWdaProject,
7106
+ wdaCacheDir,
7107
+ resolveWdaTestRun,
7108
+ injectUsePortIntoXctestrun,
7109
+ defaultWdaTestRunPreparer,
7110
+ wdaTestRunArgs,
7111
+ launchIosSession,
7112
+ closeIosSession,
4157
7113
  extractSelectorIntent,
4158
7114
  buildHealCandidates,
4159
7115
  healSelector,
@@ -4170,6 +7126,25 @@ export {
4170
7126
  runSuite,
4171
7127
  parseBrowserEngine,
4172
7128
  analyzePage,
7129
+ INTERACTIVE_ROLES,
7130
+ DEFAULT_ANALYZE_TREE_DEPTH,
7131
+ rankMacSelectors,
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,
4173
7148
  generateHunt
4174
7149
  };
4175
- //# sourceMappingURL=chunk-ZEFVTKQT.js.map
7150
+ //# sourceMappingURL=chunk-5KQR3IR3.js.map