prowl-tools 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ import {
7
7
  loadHunt,
8
8
  loadHuntTags,
9
9
  resolveViewport
10
- } from "./chunk-MBAIGNVO.js";
10
+ } from "./chunk-ITOSUJCN.js";
11
11
 
12
12
  // src/config/interpolate.ts
13
13
  import crypto from "crypto";
@@ -409,15 +409,25 @@ function webOnlyReason(step) {
409
409
  }
410
410
  return null;
411
411
  }
412
+ function nativeTargetLabel(target) {
413
+ if (target === "android") {
414
+ return "Android";
415
+ }
416
+ if (target === "ios") {
417
+ return "iOS";
418
+ }
419
+ return "macOS";
420
+ }
412
421
  function assertStepsSupportedByTarget(steps, target) {
413
- if (target !== "macos") {
422
+ if (target === "web") {
414
423
  return;
415
424
  }
425
+ const label = nativeTargetLabel(target);
416
426
  for (const step of steps) {
417
427
  const reason = webOnlyReason(step);
418
428
  if (reason) {
419
429
  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.).`
430
+ `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
431
  );
422
432
  }
423
433
  if ("if" in step) {
@@ -432,20 +442,34 @@ function assertStepsSupportedByTarget(steps, target) {
432
442
  }
433
443
  }
434
444
  function assertHuntAssertionsSupportedByTarget(assertions, target) {
435
- if (target !== "macos" || !assertions || assertions.length === 0) {
445
+ if (target === "web" || !assertions || assertions.length === 0) {
436
446
  return;
437
447
  }
438
448
  throw new Error(
439
- "Hunt-level assertions are not supported by the macOS target. Use inline assert visible/notVisible steps instead."
449
+ `Hunt-level assertions are not supported by the ${nativeTargetLabel(target)} target. Use inline assert visible/notVisible steps instead.`
440
450
  );
441
451
  }
442
452
  function trimTrailingPathSeparators(value) {
443
453
  return value.replace(/[\\/]+$/g, "");
444
454
  }
445
- function looksLikeMacosAppPath(app) {
455
+ function looksLikeAppBundlePath(app) {
446
456
  const trimmed = trimTrailingPathSeparators(app);
447
457
  return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
448
458
  }
459
+ function looksLikeIosAppPath(app) {
460
+ const trimmed = trimTrailingPathSeparators(app);
461
+ if (trimmed.includes("/") || trimmed.includes("\\")) {
462
+ return true;
463
+ }
464
+ if (trimmed.toLowerCase().endsWith(".app")) {
465
+ try {
466
+ return fs.statSync(normalizeAppPath(app)).isDirectory();
467
+ } catch {
468
+ return false;
469
+ }
470
+ }
471
+ return false;
472
+ }
449
473
  function normalizeAppPath(app) {
450
474
  return path.resolve(trimTrailingPathSeparators(app));
451
475
  }
@@ -453,8 +477,8 @@ function parseBundleIdentifier(plist) {
453
477
  const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
454
478
  return match?.[1]?.trim() || null;
455
479
  }
456
- function readBundleIdentifier(appPath) {
457
- const infoPlistPath = path.join(normalizeAppPath(appPath), "Contents", "Info.plist");
480
+ function readBundleIdentifier(appPath, ...plistSubPath) {
481
+ const infoPlistPath = path.join(normalizeAppPath(appPath), ...plistSubPath);
458
482
  if (!fs.existsSync(infoPlistPath)) {
459
483
  return null;
460
484
  }
@@ -479,9 +503,15 @@ function readBundleIdentifier(appPath) {
479
503
  return null;
480
504
  }
481
505
  }
482
- function macosAppAllowedIdentities(app) {
506
+ function readMacosBundleIdentifier(appPath) {
507
+ return readBundleIdentifier(appPath, "Contents", "Info.plist");
508
+ }
509
+ function readIosBundleIdentifier(appPath) {
510
+ return readBundleIdentifier(appPath, "Info.plist");
511
+ }
512
+ function appBundleAllowedIdentities(app, readBundleId, isAppPath = looksLikeAppBundlePath) {
483
513
  const identities = /* @__PURE__ */ new Set([app]);
484
- if (looksLikeMacosAppPath(app)) {
514
+ if (isAppPath(app)) {
485
515
  const normalizedPath = normalizeAppPath(app);
486
516
  identities.add(trimTrailingPathSeparators(app));
487
517
  identities.add(normalizedPath);
@@ -489,19 +519,56 @@ function macosAppAllowedIdentities(app) {
489
519
  if (bundleName) {
490
520
  identities.add(bundleName);
491
521
  }
492
- const bundleId = readBundleIdentifier(app);
522
+ const bundleId = readBundleId(app);
493
523
  if (bundleId) {
494
524
  identities.add(bundleId);
495
525
  }
496
526
  }
497
527
  return [...identities];
498
528
  }
529
+ function macosAppAllowedIdentities(app) {
530
+ return appBundleAllowedIdentities(app, readMacosBundleIdentifier);
531
+ }
532
+ function iosAppAllowedIdentities(app) {
533
+ return appBundleAllowedIdentities(app, readIosBundleIdentifier, looksLikeIosAppPath);
534
+ }
499
535
  function assertTargetAppAllowed(allowedApps, app) {
536
+ assertNativeAppAllowed(allowedApps, app, macosAppAllowedIdentities);
537
+ }
538
+ function assertIosAppAllowed(allowedApps, app) {
539
+ assertNativeAppAllowed(allowedApps, app, iosAppAllowedIdentities);
540
+ }
541
+ function looksLikeApkPath(app) {
542
+ const trimmed = trimTrailingPathSeparators(app);
543
+ return trimmed.includes("/") || trimmed.includes("\\") || trimmed.toLowerCase().endsWith(".apk");
544
+ }
545
+ function normalizeAndroidApkPath(app) {
546
+ const resolved = path.resolve(trimTrailingPathSeparators(app));
547
+ try {
548
+ return fs.realpathSync.native(resolved);
549
+ } catch {
550
+ return resolved;
551
+ }
552
+ }
553
+ function androidAppAllowedIdentities(app, resolvedPackage) {
554
+ if (looksLikeApkPath(app)) {
555
+ return resolvedPackage ? [normalizeAndroidApkPath(app), resolvedPackage] : [normalizeAndroidApkPath(app)];
556
+ }
557
+ return [app];
558
+ }
559
+ function assertAndroidAppAllowed(allowedApps, app, resolvedPackage) {
560
+ assertNativeAppAllowed(
561
+ allowedApps,
562
+ app,
563
+ (value) => androidAppAllowedIdentities(value, value === app ? resolvedPackage : void 0)
564
+ );
565
+ }
566
+ function assertNativeAppAllowed(allowedApps, app, resolveIdentities) {
500
567
  if (allowedApps.length === 0) {
501
568
  return;
502
569
  }
503
- const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => macosAppAllowedIdentities(allowedApp)));
504
- if (macosAppAllowedIdentities(app).some((identity) => allowedIdentities.has(identity))) {
570
+ const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => resolveIdentities(allowedApp)));
571
+ if (resolveIdentities(app).some((identity) => allowedIdentities.has(identity))) {
505
572
  return;
506
573
  }
507
574
  throw new Error(
@@ -657,7 +724,11 @@ function createMacDriver(client, options = {}) {
657
724
  return rejectUnsupported("evalScript");
658
725
  },
659
726
  async screenshot(screenshotOptions) {
660
- await client.request("screenshot", { path: screenshotOptions.path });
727
+ const result = await client.request("screenshot", { path: screenshotOptions.path });
728
+ const warning = result.warning;
729
+ if (typeof warning === "string" && warning.length > 0) {
730
+ console.warn(`macOS screenshot fell back to full screen: ${warning}`);
731
+ }
661
732
  },
662
733
  // network / dialogs / downloads (all web-only) -------------------------
663
734
  onResponse(_handler) {
@@ -777,114 +848,1775 @@ var SpawnMacHelperClient = class {
777
848
  if (id === void 0) {
778
849
  return;
779
850
  }
780
- const pending = this.pending.get(id);
781
- if (!pending) {
782
- return;
851
+ const pending = this.pending.get(id);
852
+ if (!pending) {
853
+ return;
854
+ }
855
+ this.pending.delete(id);
856
+ clearTimeout(pending.timer);
857
+ if (message.ok === true) {
858
+ pending.resolve(message.result ?? {});
859
+ } else {
860
+ pending.reject(new Error(typeof message.error === "string" ? message.error : "prowl-macdriver error"));
861
+ }
862
+ }
863
+ failAll(error) {
864
+ for (const pending of this.pending.values()) {
865
+ clearTimeout(pending.timer);
866
+ pending.reject(error);
867
+ }
868
+ this.pending.clear();
869
+ }
870
+ recordTerminalFailure(error) {
871
+ this.terminalError ??= error;
872
+ this.closed = true;
873
+ this.failAll(this.terminalError);
874
+ }
875
+ /** Number of in-flight requests awaiting a response (for teardown/tests). */
876
+ get pendingCount() {
877
+ return this.pending.size;
878
+ }
879
+ request(cmd, params = {}) {
880
+ if (this.terminalError) {
881
+ return Promise.reject(this.terminalError);
882
+ }
883
+ if (this.closed) {
884
+ return Promise.reject(new Error("prowl-macdriver client is closed"));
885
+ }
886
+ const id = this.nextId++;
887
+ const payload = JSON.stringify({ id, cmd, ...params });
888
+ return new Promise((resolve, reject) => {
889
+ const timer = setTimeout(() => {
890
+ if (this.pending.delete(id)) {
891
+ const shown = this.requestTimeoutMs >= 1e3 ? `${Math.round(this.requestTimeoutMs / 1e3)}s` : `${this.requestTimeoutMs}ms`;
892
+ reject(new Error(`prowl-macdriver request "${cmd}" timed out after ${shown}`));
893
+ }
894
+ }, this.requestTimeoutMs);
895
+ timer.unref?.();
896
+ this.pending.set(id, { cmd, resolve, reject, timer });
897
+ this.child.stdin?.write(payload + "\n", (error) => {
898
+ if (error && this.pending.delete(id)) {
899
+ clearTimeout(timer);
900
+ reject(error);
901
+ }
902
+ });
903
+ });
904
+ }
905
+ async close() {
906
+ if (this.closed) {
907
+ return;
908
+ }
909
+ this.closed = true;
910
+ try {
911
+ this.child.stdin?.write(JSON.stringify({ cmd: "shutdown" }) + "\n");
912
+ this.child.stdin?.end();
913
+ } catch {
914
+ }
915
+ await new Promise((resolve) => {
916
+ if (this.child.exitCode !== null || this.child.signalCode !== null) {
917
+ resolve();
918
+ return;
919
+ }
920
+ const timer = setTimeout(() => {
921
+ this.child.kill("SIGKILL");
922
+ resolve();
923
+ }, 2e3);
924
+ this.child.once("exit", () => {
925
+ clearTimeout(timer);
926
+ resolve();
927
+ });
928
+ });
929
+ this.failAll(new Error("prowl-macdriver client is closed"));
930
+ }
931
+ };
932
+ async function launchMacSession(options) {
933
+ const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_REQUEST_TIMEOUT_MS) + 5e3;
934
+ const client = options.clientFactory ? options.clientFactory() : new SpawnMacHelperClient(resolveHelperBinary(), { requestTimeoutMs });
935
+ const timeoutSeconds = (options.timeoutMs ?? 1e4) / 1e3;
936
+ try {
937
+ const trust = await client.request("check");
938
+ if (trust.trusted !== true) {
939
+ throw new Error(
940
+ "Prowl's macOS target is not trusted for Accessibility. Grant the hosting terminal/app permission in System Settings \u2192 Privacy & Security \u2192 Accessibility, then retry."
941
+ );
942
+ }
943
+ const launched = await client.request("launch", { app: options.app, timeout: timeoutSeconds });
944
+ const bundleId = String(launched.bundleId ?? options.app);
945
+ const driver = createMacDriver(client, { appLabel: bundleId });
946
+ return { client, driver, bundleId };
947
+ } catch (error) {
948
+ await client.close().catch(() => void 0);
949
+ throw error;
950
+ }
951
+ }
952
+ async function closeMacSession(session) {
953
+ try {
954
+ await session.client.request("quit");
955
+ } catch {
956
+ } finally {
957
+ await session.client.close();
958
+ }
959
+ }
960
+
961
+ // src/browser/android-driver.ts
962
+ import fs3 from "fs";
963
+ var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
964
+ "query",
965
+ "interact",
966
+ "wait",
967
+ "screenshot"
968
+ ]);
969
+ var WAIT_POLL_INTERVAL_MS = 250;
970
+ var DEFAULT_WAIT_TIMEOUT_MS = 5e3;
971
+ var ANDROID_KEYCODES = {
972
+ enter: 66,
973
+ return: 66,
974
+ tab: 61,
975
+ space: 62,
976
+ backspace: 67,
977
+ delete: 67,
978
+ del: 67,
979
+ escape: 111,
980
+ esc: 111,
981
+ back: 4,
982
+ home: 3,
983
+ menu: 82,
984
+ search: 84,
985
+ up: 19,
986
+ arrowup: 19,
987
+ down: 20,
988
+ arrowdown: 20,
989
+ left: 21,
990
+ arrowleft: 21,
991
+ right: 22,
992
+ arrowright: 22,
993
+ pageup: 92,
994
+ pagedown: 93
995
+ };
996
+ function unquote2(value) {
997
+ const trimmed = value.trim();
998
+ const first = trimmed[0];
999
+ if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
1000
+ return trimmed.slice(1, -1);
1001
+ }
1002
+ return trimmed;
1003
+ }
1004
+ function escapeUiSelectorArg(value) {
1005
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1006
+ }
1007
+ function parseAndroidSelector(selector) {
1008
+ const trimmed = selector.trim();
1009
+ if (trimmed === ":focus") {
1010
+ return { by: "focused" };
1011
+ }
1012
+ const idMatch = /^id=(.+)$/s.exec(trimmed);
1013
+ if (idMatch) {
1014
+ return { by: "id", value: unquote2(idMatch[1]) };
1015
+ }
1016
+ const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
1017
+ if (roleMatch) {
1018
+ const name = roleMatch[2] !== void 0 ? unquote2(roleMatch[2]) : void 0;
1019
+ return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
1020
+ }
1021
+ const labelMatch = /^label=(.+)$/s.exec(trimmed);
1022
+ if (labelMatch) {
1023
+ return { by: "accessibilityId", value: unquote2(labelMatch[1]) };
1024
+ }
1025
+ const textMatch = /^text=(.+)$/s.exec(trimmed);
1026
+ if (textMatch) {
1027
+ return { by: "text", value: unquote2(textMatch[1]) };
1028
+ }
1029
+ return { by: "text", value: trimmed };
1030
+ }
1031
+ function locator(strategy, selector) {
1032
+ return { strategy, selector, context: "" };
1033
+ }
1034
+ function qualifyResourceId(value, appPackage) {
1035
+ if (value.includes(":") || !appPackage) {
1036
+ return value;
1037
+ }
1038
+ return `${appPackage}:id/${value}`;
1039
+ }
1040
+ function androidQueryToLocator(query, options = {}) {
1041
+ switch (query.by) {
1042
+ case "id":
1043
+ return locator("id", qualifyResourceId(query.value, options.appPackage));
1044
+ case "accessibilityId":
1045
+ return locator("accessibility id", query.value);
1046
+ case "text":
1047
+ return locator(
1048
+ "-android uiautomator",
1049
+ `new UiSelector().textContains("${escapeUiSelectorArg(query.value)}")`
1050
+ );
1051
+ case "focused":
1052
+ return locator("-android uiautomator", "new UiSelector().focused(true)");
1053
+ case "role": {
1054
+ const className = escapeUiSelectorArg(query.role);
1055
+ if (query.name === void 0 || query.name.length === 0) {
1056
+ return locator("class name", query.role);
1057
+ }
1058
+ return locator(
1059
+ "-android uiautomator",
1060
+ `new UiSelector().className("${className}").textContains("${escapeUiSelectorArg(query.name)}")`
1061
+ );
1062
+ }
1063
+ }
1064
+ }
1065
+ function unwrapAndroidTextSelector(selector) {
1066
+ const trimmed = selector.trim();
1067
+ if (!trimmed.startsWith("text=")) {
1068
+ return null;
1069
+ }
1070
+ return unquote2(trimmed.slice(5));
1071
+ }
1072
+ function keyCodeFor(key) {
1073
+ const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
1074
+ if (code === void 0) {
1075
+ throw new Error(
1076
+ `Unsupported key "${key}" for the Android target. Supported keys: ${Object.keys(ANDROID_KEYCODES).sort().join(", ")}.`
1077
+ );
1078
+ }
1079
+ return code;
1080
+ }
1081
+ function delay(ms) {
1082
+ return new Promise((resolve) => {
1083
+ setTimeout(resolve, ms);
1084
+ });
1085
+ }
1086
+ function createAndroidDriver(client, options = {}) {
1087
+ const unsupported = (verb) => new Error(`${verb} is not supported by the Android target`);
1088
+ const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
1089
+ async function resolveOne(selector) {
1090
+ const id = await client.findElement(parseAndroidSelector(selector));
1091
+ if (id === null) {
1092
+ throw new Error(`No element matched selector: ${selector}`);
1093
+ }
1094
+ return id;
1095
+ }
1096
+ async function clickSelector(selector) {
1097
+ await client.click(await resolveOne(selector));
1098
+ }
1099
+ async function fillSelector(selector, value) {
1100
+ await client.setValue(await resolveOne(selector), value);
1101
+ }
1102
+ return {
1103
+ capabilities: ANDROID_CAPABILITIES,
1104
+ // navigation -----------------------------------------------------------
1105
+ goto(_url, _options) {
1106
+ return rejectUnsupported("navigate");
1107
+ },
1108
+ currentUrl() {
1109
+ return `android:${options.appLabel ?? ""}`;
1110
+ },
1111
+ // queries --------------------------------------------------------------
1112
+ async count(selector) {
1113
+ return (await client.findElements(parseAndroidSelector(selector))).length;
1114
+ },
1115
+ async textContent(selector) {
1116
+ const id = await client.findElement(parseAndroidSelector(selector));
1117
+ if (id === null) {
1118
+ return null;
1119
+ }
1120
+ return client.getText(id);
1121
+ },
1122
+ // interactions ---------------------------------------------------------
1123
+ click: clickSelector,
1124
+ clickFirst: clickSelector,
1125
+ fill: fillSelector,
1126
+ fillFirst: fillSelector,
1127
+ async press(_selector, key) {
1128
+ await client.pressKeyCode(keyCodeFor(key));
1129
+ },
1130
+ selectOption() {
1131
+ return rejectUnsupported("select");
1132
+ },
1133
+ selectOptionFirst() {
1134
+ return rejectUnsupported("select");
1135
+ },
1136
+ hover() {
1137
+ return rejectUnsupported("hover");
1138
+ },
1139
+ scrollIntoView() {
1140
+ return rejectUnsupported("scrollTo");
1141
+ },
1142
+ setInputFiles() {
1143
+ return rejectUnsupported("setInputFiles");
1144
+ },
1145
+ // semantic locators ----------------------------------------------------
1146
+ async countByRole(role, name) {
1147
+ return (await client.findElements({ by: "role", role, name })).length;
1148
+ },
1149
+ async clickFirstByRole(role, name) {
1150
+ const id = await client.findElement({ by: "role", role, name });
1151
+ if (id === null) {
1152
+ throw new Error(`No element matched role=${role}[name="${name}"]`);
1153
+ }
1154
+ await client.click(id);
1155
+ },
1156
+ async countByLabel(label) {
1157
+ return (await client.findElements({ by: "accessibilityId", value: label })).length;
1158
+ },
1159
+ async fillFirstByLabel(label, value) {
1160
+ const id = await client.findElement({ by: "accessibilityId", value: label });
1161
+ if (id === null) {
1162
+ throw new Error(`No element matched label="${label}"`);
1163
+ }
1164
+ await client.setValue(id, value);
1165
+ },
1166
+ selectOptionFirstByLabel() {
1167
+ return rejectUnsupported("select");
1168
+ },
1169
+ // waiting --------------------------------------------------------------
1170
+ async waitForSelector(selector, waitOptions) {
1171
+ const query = parseAndroidSelector(selector);
1172
+ const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS;
1173
+ const deadline = Date.now() + timeoutMs;
1174
+ for (; ; ) {
1175
+ if ((await client.findElements(query)).length > 0) {
1176
+ return;
1177
+ }
1178
+ if (Date.now() >= deadline) {
1179
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
1180
+ }
1181
+ await delay(Math.min(WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
1182
+ }
1183
+ },
1184
+ waitForUrl() {
1185
+ return rejectUnsupported("waitForUrl");
1186
+ },
1187
+ waitForNetworkIdle() {
1188
+ return rejectUnsupported("waitForNetworkIdle");
1189
+ },
1190
+ // scripting & artifacts ------------------------------------------------
1191
+ evaluate() {
1192
+ return rejectUnsupported("evalScript");
1193
+ },
1194
+ async screenshot(screenshotOptions) {
1195
+ const png = await client.screenshotPng();
1196
+ fs3.writeFileSync(screenshotOptions.path, png);
1197
+ },
1198
+ // network / dialogs / downloads (all web-only) -------------------------
1199
+ onResponse(_handler) {
1200
+ throw unsupported("onResponse");
1201
+ },
1202
+ route(_url, _handler) {
1203
+ return rejectUnsupported("mockRoute");
1204
+ },
1205
+ unroute() {
1206
+ return rejectUnsupported("unmockRoute");
1207
+ },
1208
+ onDialog(_action) {
1209
+ throw unsupported("onDialog");
1210
+ },
1211
+ waitForDownloadEvent() {
1212
+ return rejectUnsupported("waitForDownload");
1213
+ },
1214
+ parseTextSelector(selector) {
1215
+ return unwrapAndroidTextSelector(selector);
1216
+ }
1217
+ };
1218
+ }
1219
+
1220
+ // src/browser/android-adb.ts
1221
+ import { execFile, spawn as spawn2 } from "child_process";
1222
+ var execFileAdbRunner = (args, options) => new Promise((resolve) => {
1223
+ execFile(
1224
+ "adb",
1225
+ args,
1226
+ { encoding: "utf-8", timeout: options?.timeoutMs, maxBuffer: 16 * 1024 * 1024 },
1227
+ (error, stdout, stderr) => {
1228
+ const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
1229
+ const capturedStderr = stderr ?? "";
1230
+ resolve({
1231
+ stdout: stdout ?? "",
1232
+ stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
1233
+ code
1234
+ });
1235
+ }
1236
+ );
1237
+ });
1238
+ var spawnAdbProcess = (args) => {
1239
+ const child = spawn2("adb", args, { stdio: "ignore" });
1240
+ child.on("error", () => {
1241
+ });
1242
+ return { kill: () => child.kill() };
1243
+ };
1244
+ function withSerial(serial, args) {
1245
+ return serial ? ["-s", serial, ...args] : args;
1246
+ }
1247
+ function parseAdbDevices(stdout) {
1248
+ const devices = [];
1249
+ for (const rawLine of stdout.split(/\r?\n/)) {
1250
+ const line = rawLine.trim();
1251
+ if (!line || /^list of devices attached/i.test(line)) {
1252
+ continue;
1253
+ }
1254
+ const [serial, state, ...rest] = line.split(/\s+/);
1255
+ if (!serial || !state) {
1256
+ continue;
1257
+ }
1258
+ const description = {};
1259
+ for (const token of rest) {
1260
+ const eq = token.indexOf(":");
1261
+ if (eq > 0) {
1262
+ description[token.slice(0, eq)] = token.slice(eq + 1);
1263
+ }
1264
+ }
1265
+ devices.push({ serial, state, description });
1266
+ }
1267
+ return devices;
1268
+ }
1269
+ function bootedDevices(devices) {
1270
+ return devices.filter((device) => device.state === "device");
1271
+ }
1272
+ function selectDeviceSerial(devices, requested) {
1273
+ const booted = bootedDevices(devices);
1274
+ if (requested) {
1275
+ const match = devices.find((device) => device.serial === requested);
1276
+ if (!match) {
1277
+ const attached = devices.length > 0 ? devices.map((d) => d.serial).join(", ") : "none";
1278
+ throw new Error(
1279
+ `Android device "${requested}" is not attached. Attached devices: ${attached}. Check \`adb devices -l\`.`
1280
+ );
1281
+ }
1282
+ if (match.state !== "device") {
1283
+ throw new Error(
1284
+ `Android device "${requested}" is present but not ready (state: ${match.state}). Boot or authorize it, then retry.`
1285
+ );
1286
+ }
1287
+ return requested;
1288
+ }
1289
+ if (booted.length === 0) {
1290
+ throw new Error(
1291
+ "No booted Android device found. Start an emulator or connect a device with USB debugging, then confirm it appears in `adb devices -l`."
1292
+ );
1293
+ }
1294
+ if (booted.length > 1) {
1295
+ throw new Error(
1296
+ `Multiple Android devices attached (${booted.map((d) => d.serial).join(", ")}). Set target.deviceSerial to pick one.`
1297
+ );
1298
+ }
1299
+ return booted[0].serial;
1300
+ }
1301
+ async function listDevices(runner) {
1302
+ const result = await runner(["devices", "-l"], { timeoutMs: 1e4 });
1303
+ if (result.code !== 0) {
1304
+ throw new Error(
1305
+ `\`adb devices\` failed (exit ${result.code}). Is adb on PATH and the server running? ` + (result.stderr.trim() || "").slice(0, 400)
1306
+ );
1307
+ }
1308
+ return parseAdbDevices(result.stdout);
1309
+ }
1310
+ function parseForwardPort(stdout) {
1311
+ const port = Number.parseInt(stdout.trim(), 10);
1312
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
1313
+ throw new Error(`Could not parse a forwarded port from adb output: "${stdout.trim()}"`);
1314
+ }
1315
+ return port;
1316
+ }
1317
+ function parseAaptPackage(stdout) {
1318
+ const match = /package:\s*name='([^']+)'/.exec(stdout);
1319
+ return match?.[1] ?? null;
1320
+ }
1321
+ async function forwardDynamicPort(runner, serial, remotePort) {
1322
+ const result = await runner(withSerial(serial, ["forward", "tcp:0", `tcp:${remotePort}`]), {
1323
+ timeoutMs: 1e4
1324
+ });
1325
+ if (result.code !== 0) {
1326
+ throw new Error(`adb forward failed (exit ${result.code}): ${result.stderr.trim()}`);
1327
+ }
1328
+ return parseForwardPort(result.stdout);
1329
+ }
1330
+ async function removeForward(runner, serial, localPort) {
1331
+ await runner(withSerial(serial, ["forward", "--remove", `tcp:${localPort}`]), { timeoutMs: 1e4 }).catch(
1332
+ () => void 0
1333
+ );
1334
+ }
1335
+ async function installApk(runner, serial, apkPath) {
1336
+ const result = await runner(withSerial(serial, ["install", "-r", "-t", "-g", apkPath]), {
1337
+ timeoutMs: 12e4
1338
+ });
1339
+ if (result.code !== 0 || /failure/i.test(result.stdout)) {
1340
+ throw new Error(
1341
+ `Failed to install APK "${apkPath}" (exit ${result.code}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
1342
+ );
1343
+ }
1344
+ }
1345
+ async function launchPackage(runner, serial, pkg) {
1346
+ const result = await runner(
1347
+ withSerial(serial, [
1348
+ "shell",
1349
+ "monkey",
1350
+ "-p",
1351
+ pkg,
1352
+ "-c",
1353
+ "android.intent.category.LAUNCHER",
1354
+ "1"
1355
+ ]),
1356
+ { timeoutMs: 3e4 }
1357
+ );
1358
+ if (result.code !== 0 || /no activities found|aborted|cannot launch/i.test(result.stdout + result.stderr)) {
1359
+ throw new Error(
1360
+ `Failed to launch Android package "${pkg}" (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 400)}. Is it installed?`
1361
+ );
1362
+ }
1363
+ }
1364
+ async function forceStop(runner, serial, pkg) {
1365
+ await runner(withSerial(serial, ["shell", "am", "force-stop", pkg]), { timeoutMs: 3e4 });
1366
+ }
1367
+ async function clearPackage(runner, serial, pkg) {
1368
+ const result = await runner(withSerial(serial, ["shell", "pm", "clear", pkg]), { timeoutMs: 3e4 });
1369
+ if (result.code !== 0 || !/success/i.test(result.stdout)) {
1370
+ throw new Error(
1371
+ `Failed to clear Android package "${pkg}" for cold start (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 300)}`
1372
+ );
1373
+ }
1374
+ }
1375
+ function startInstrumentation(spawner, serial) {
1376
+ return spawner(
1377
+ withSerial(serial, [
1378
+ "shell",
1379
+ "am",
1380
+ "instrument",
1381
+ "-w",
1382
+ "-e",
1383
+ "disableAnalytics",
1384
+ "true",
1385
+ "io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner"
1386
+ ])
1387
+ );
1388
+ }
1389
+
1390
+ // src/browser/android-agent.ts
1391
+ var DEFAULT_AGENT_REQUEST_TIMEOUT_MS = 3e4;
1392
+ var W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
1393
+ var Uia2HttpError = class extends Error {
1394
+ constructor(message, status, webdriverError) {
1395
+ super(message);
1396
+ this.status = status;
1397
+ this.webdriverError = webdriverError;
1398
+ this.name = "Uia2HttpError";
1399
+ }
1400
+ };
1401
+ var Uia2Transport = class {
1402
+ baseUrl;
1403
+ requestTimeoutMs;
1404
+ fetchImpl;
1405
+ constructor(options) {
1406
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
1407
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_AGENT_REQUEST_TIMEOUT_MS;
1408
+ const injected = options.fetchImpl;
1409
+ if (injected) {
1410
+ this.fetchImpl = injected;
1411
+ } else if (typeof fetch === "function") {
1412
+ this.fetchImpl = (url, init) => fetch(url, init);
1413
+ } else {
1414
+ throw new Error("global fetch is unavailable; Node 20+ is required for the Android target");
1415
+ }
1416
+ }
1417
+ /**
1418
+ * Send one request and return the parsed `value` field. Rejects with a
1419
+ * {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
1420
+ * per-request deadline elapses.
1421
+ */
1422
+ async request(method, path16, body, timeoutMs) {
1423
+ const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
1424
+ const controller = new AbortController();
1425
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
1426
+ timer.unref?.();
1427
+ const url = `${this.baseUrl}${path16}`;
1428
+ let response;
1429
+ try {
1430
+ response = await this.fetchImpl(url, {
1431
+ method,
1432
+ signal: controller.signal,
1433
+ headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
1434
+ body: body !== void 0 ? JSON.stringify(body) : void 0
1435
+ });
1436
+ } catch (error) {
1437
+ if (controller.signal.aborted) {
1438
+ const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
1439
+ throw new Error(`uiautomator2 request ${method} ${path16} timed out after ${shown}`);
1440
+ }
1441
+ throw error instanceof Error ? error : new Error(String(error));
1442
+ } finally {
1443
+ clearTimeout(timer);
1444
+ }
1445
+ const text = await response.text();
1446
+ const parsed = parseJson(text);
1447
+ if (!response.ok) {
1448
+ const wdError = extractWebdriverError(parsed);
1449
+ throw new Uia2HttpError(
1450
+ `uiautomator2 ${method} ${path16} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
1451
+ response.status,
1452
+ wdError
1453
+ );
1454
+ }
1455
+ return parsed?.value;
1456
+ }
1457
+ };
1458
+ function parseJson(text) {
1459
+ if (!text) {
1460
+ return void 0;
1461
+ }
1462
+ try {
1463
+ return JSON.parse(text);
1464
+ } catch {
1465
+ return void 0;
1466
+ }
1467
+ }
1468
+ function extractWebdriverError(parsed) {
1469
+ const value = parsed?.value;
1470
+ if (value && typeof value === "object") {
1471
+ const record = value;
1472
+ const error = typeof record.error === "string" ? record.error : void 0;
1473
+ const message = typeof record.message === "string" ? record.message : void 0;
1474
+ return error ?? message;
1475
+ }
1476
+ return void 0;
1477
+ }
1478
+ function extractElementId(value) {
1479
+ if (!value || typeof value !== "object") {
1480
+ return null;
1481
+ }
1482
+ const record = value;
1483
+ const id = record[W3C_ELEMENT_KEY] ?? record.ELEMENT;
1484
+ return typeof id === "string" ? id : null;
1485
+ }
1486
+ function isNoSuchElement(error) {
1487
+ if (error instanceof Uia2HttpError) {
1488
+ return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
1489
+ }
1490
+ return false;
1491
+ }
1492
+ async function createUia2Session(transport) {
1493
+ const value = await transport.request("POST", "/session", {
1494
+ capabilities: { alwaysMatch: {}, firstMatch: [{}] }
1495
+ });
1496
+ const record = value ?? {};
1497
+ const sessionId = record.sessionId;
1498
+ if (typeof sessionId === "string" && sessionId.length > 0) {
1499
+ return sessionId;
1500
+ }
1501
+ throw new Error("uiautomator2 did not return a session id");
1502
+ }
1503
+ async function waitForAgentReady(transport, options = { deadlineMs: 3e4 }) {
1504
+ const interval = options.intervalMs ?? 300;
1505
+ const deadline = Date.now() + options.deadlineMs;
1506
+ let lastError;
1507
+ for (; ; ) {
1508
+ try {
1509
+ const remainingMs = Math.max(1, deadline - Date.now());
1510
+ const value = await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
1511
+ const ready = value?.ready;
1512
+ if (ready === void 0 || ready === true) {
1513
+ return;
1514
+ }
1515
+ } catch (error) {
1516
+ lastError = error;
1517
+ }
1518
+ if (Date.now() >= deadline) {
1519
+ const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
1520
+ throw new Error(`uiautomator2 agent did not become ready within ${options.deadlineMs}ms${detail}`);
1521
+ }
1522
+ await sleep(Math.min(interval, Math.max(0, deadline - Date.now())));
1523
+ }
1524
+ }
1525
+ function sleep(ms) {
1526
+ return new Promise((resolve) => {
1527
+ const timer = setTimeout(resolve, ms);
1528
+ timer.unref?.();
1529
+ });
1530
+ }
1531
+ function createUia2AgentClient(transport, sessionId, options = {}) {
1532
+ const base = `/session/${sessionId}`;
1533
+ async function locate(query, path16) {
1534
+ return transport.request(
1535
+ "POST",
1536
+ `${base}${path16}`,
1537
+ androidQueryToLocator(query, { appPackage: options.appPackage })
1538
+ );
1539
+ }
1540
+ return {
1541
+ async findElement(query) {
1542
+ try {
1543
+ return extractElementId(await locate(query, "/element"));
1544
+ } catch (error) {
1545
+ if (isNoSuchElement(error)) {
1546
+ return null;
1547
+ }
1548
+ throw error;
1549
+ }
1550
+ },
1551
+ async findElements(query) {
1552
+ const value = await locate(query, "/elements");
1553
+ if (!Array.isArray(value)) {
1554
+ return [];
1555
+ }
1556
+ return value.map((entry) => extractElementId(entry)).filter((id) => id !== null);
1557
+ },
1558
+ async click(elementId) {
1559
+ await transport.request("POST", `${base}/element/${elementId}/click`, {});
1560
+ },
1561
+ async setValue(elementId, text) {
1562
+ await transport.request("POST", `${base}/element/${elementId}/value`, { text });
1563
+ },
1564
+ async getText(elementId) {
1565
+ const value = await transport.request("GET", `${base}/element/${elementId}/text`);
1566
+ return typeof value === "string" ? value : value == null ? null : String(value);
1567
+ },
1568
+ async pressKeyCode(keyCode) {
1569
+ await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
1570
+ },
1571
+ async screenshotPng() {
1572
+ const value = await transport.request("GET", `${base}/screenshot`);
1573
+ if (typeof value !== "string") {
1574
+ throw new Error("uiautomator2 screenshot did not return base64 data");
1575
+ }
1576
+ return Buffer.from(value, "base64");
1577
+ },
1578
+ async close() {
1579
+ await transport.request("DELETE", base).catch(() => void 0);
1580
+ }
1581
+ };
1582
+ }
1583
+
1584
+ // src/browser/android-helper.ts
1585
+ import { createRequire } from "module";
1586
+ import { execFile as execFile2 } from "child_process";
1587
+ import fs4 from "fs";
1588
+ import path3 from "path";
1589
+ var UIA2_REMOTE_PORT = 6790;
1590
+ function resolveAgentApks(requireFn = createRequire(import.meta.url)) {
1591
+ let pkgJsonPath;
1592
+ try {
1593
+ pkgJsonPath = requireFn.resolve("appium-uiautomator2-server/package.json");
1594
+ } catch {
1595
+ throw new Error(
1596
+ "The Android target requires the `appium-uiautomator2-server` package (its prebuilt APKs). It is an optional dependency of prowl-tools; it may have been skipped (--omit=optional) or failed to install. Restore it for a global Prowl install with: npm install -g appium-uiautomator2-server@10.6.2. If Prowl is installed locally in a project, run: npm install appium-uiautomator2-server@10.6.2"
1597
+ );
1598
+ }
1599
+ const pkgDir = path3.dirname(pkgJsonPath);
1600
+ const version = requireFn(pkgJsonPath).version;
1601
+ const serverApk = path3.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
1602
+ const testApk = path3.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
1603
+ for (const apk of [serverApk, testApk]) {
1604
+ if (!fs4.existsSync(apk)) {
1605
+ throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
1606
+ }
1607
+ }
1608
+ return { serverApk, testApk };
1609
+ }
1610
+ function looksLikeApk(app) {
1611
+ return app.toLowerCase().endsWith(".apk");
1612
+ }
1613
+ var execFileAaptResolver = async (apkPath) => {
1614
+ for (const tool of ["aapt", "aapt2"]) {
1615
+ const output = await new Promise((resolve) => {
1616
+ execFile2(
1617
+ tool,
1618
+ ["dump", "badging", apkPath],
1619
+ { encoding: "utf-8", timeout: 2e4, maxBuffer: 8 * 1024 * 1024 },
1620
+ (error, stdout) => resolve(error ? null : stdout)
1621
+ );
1622
+ });
1623
+ const pkg = output ? parseAaptPackage(output) : null;
1624
+ if (pkg) {
1625
+ return pkg;
1626
+ }
1627
+ }
1628
+ return null;
1629
+ };
1630
+ var defaultAgentConnector = async ({
1631
+ host,
1632
+ port,
1633
+ requestTimeoutMs,
1634
+ readyDeadlineMs,
1635
+ appPackage
1636
+ }) => {
1637
+ const transport = new Uia2Transport({
1638
+ baseUrl: `http://${host}:${port}/wd/hub`,
1639
+ requestTimeoutMs
1640
+ });
1641
+ await waitForAgentReady(transport, { deadlineMs: readyDeadlineMs });
1642
+ const sessionId = await createUia2Session(transport);
1643
+ return createUia2AgentClient(transport, sessionId, { appPackage });
1644
+ };
1645
+ async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
1646
+ if (!looksLikeApk(app)) {
1647
+ assertAndroidAppAllowed(allowedApps, app);
1648
+ return app;
1649
+ }
1650
+ const apkPath = path3.resolve(app);
1651
+ if (!fs4.existsSync(apkPath)) {
1652
+ throw new Error(`APK not found: ${apkPath}`);
1653
+ }
1654
+ const pkg = await aaptResolver(apkPath);
1655
+ if (!pkg) {
1656
+ throw new Error(
1657
+ `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.`
1658
+ );
1659
+ }
1660
+ assertAndroidAppAllowed(allowedApps, apkPath, pkg);
1661
+ await installApk(runner, serial, apkPath);
1662
+ return pkg;
1663
+ }
1664
+ async function launchAndroidSession(options) {
1665
+ const runner = options.runner ?? execFileAdbRunner;
1666
+ const spawner = options.spawner ?? spawnAdbProcess;
1667
+ const connector = options.agentConnector ?? defaultAgentConnector;
1668
+ const aaptResolver = options.aaptResolver ?? execFileAaptResolver;
1669
+ const apks = options.apks ?? resolveAgentApks();
1670
+ const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS) + 5e3;
1671
+ const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS);
1672
+ const devices = await listDevices(runner);
1673
+ const serial = selectDeviceSerial(devices, options.deviceSerial);
1674
+ const pkg = await resolvePackage(options.app, runner, serial, aaptResolver, options.allowedApps ?? []);
1675
+ await installApk(runner, serial, apks.serverApk);
1676
+ await installApk(runner, serial, apks.testApk);
1677
+ if (options.coldStart) {
1678
+ await clearPackage(runner, serial, pkg);
1679
+ }
1680
+ await launchPackage(runner, serial, pkg);
1681
+ let instrumentation;
1682
+ let localPort;
1683
+ let client;
1684
+ let tornDown = false;
1685
+ const teardown = async () => {
1686
+ if (tornDown) {
1687
+ return;
1688
+ }
1689
+ tornDown = true;
1690
+ if (client) {
1691
+ await client.close().catch(() => void 0);
1692
+ }
1693
+ instrumentation?.kill();
1694
+ const forwardedPort = localPort;
1695
+ localPort = void 0;
1696
+ if (forwardedPort !== void 0) {
1697
+ await removeForward(runner, serial, forwardedPort);
1698
+ }
1699
+ await forceStop(runner, serial, pkg).catch(() => void 0);
1700
+ };
1701
+ try {
1702
+ instrumentation = startInstrumentation(spawner, serial);
1703
+ localPort = await forwardDynamicPort(runner, serial, UIA2_REMOTE_PORT);
1704
+ client = await connector({
1705
+ host: "127.0.0.1",
1706
+ port: localPort,
1707
+ requestTimeoutMs,
1708
+ readyDeadlineMs,
1709
+ appPackage: pkg
1710
+ });
1711
+ const driver = createAndroidDriver(client, { appLabel: pkg });
1712
+ return { client, driver, package: pkg, serial, teardown };
1713
+ } catch (error) {
1714
+ await teardown();
1715
+ throw error;
1716
+ }
1717
+ }
1718
+ async function closeAndroidSession(session) {
1719
+ await session.teardown();
1720
+ }
1721
+
1722
+ // src/browser/ios-driver.ts
1723
+ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
1724
+ "query",
1725
+ "interact",
1726
+ "wait",
1727
+ "screenshot"
1728
+ ]);
1729
+ var WAIT_POLL_INTERVAL_MS2 = 250;
1730
+ var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
1731
+ var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
1732
+ function unquote3(value) {
1733
+ const trimmed = value.trim();
1734
+ const first = trimmed[0];
1735
+ if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
1736
+ return trimmed.slice(1, -1);
1737
+ }
1738
+ return trimmed;
1739
+ }
1740
+ function escapePredicateArg(value) {
1741
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1742
+ }
1743
+ function normalizeXcuiClassName(role) {
1744
+ return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
1745
+ }
1746
+ function parseIosSelector(selector) {
1747
+ const trimmed = selector.trim();
1748
+ if (trimmed === ":focus") {
1749
+ return { by: "focused" };
1750
+ }
1751
+ const idMatch = /^id=(.+)$/s.exec(trimmed);
1752
+ if (idMatch) {
1753
+ return { by: "accessibilityId", value: unquote3(idMatch[1]) };
1754
+ }
1755
+ const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
1756
+ if (roleMatch) {
1757
+ const name = roleMatch[2] !== void 0 ? unquote3(roleMatch[2]) : void 0;
1758
+ return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
1759
+ }
1760
+ const labelMatch = /^label=(.+)$/s.exec(trimmed);
1761
+ if (labelMatch) {
1762
+ return { by: "label", value: unquote3(labelMatch[1]) };
1763
+ }
1764
+ const textMatch = /^text=(.+)$/s.exec(trimmed);
1765
+ if (textMatch) {
1766
+ return { by: "text", value: unquote3(textMatch[1]) };
1767
+ }
1768
+ return { by: "text", value: trimmed };
1769
+ }
1770
+ function iosQueryToLocator(query) {
1771
+ switch (query.by) {
1772
+ case "accessibilityId":
1773
+ return { using: "accessibility id", value: query.value };
1774
+ case "label":
1775
+ return { using: "predicate string", value: `label == "${escapePredicateArg(query.value)}"` };
1776
+ case "text": {
1777
+ const escaped = escapePredicateArg(query.value);
1778
+ return {
1779
+ using: "predicate string",
1780
+ value: `label CONTAINS "${escaped}" OR value CONTAINS "${escaped}"`
1781
+ };
1782
+ }
1783
+ case "focused":
1784
+ return { using: "predicate string", value: "hasKeyboardFocus == 1" };
1785
+ case "role": {
1786
+ const className = normalizeXcuiClassName(query.role);
1787
+ if (query.name === void 0 || query.name.length === 0) {
1788
+ return { using: "class name", value: className };
1789
+ }
1790
+ const escapedClass = escapePredicateArg(className);
1791
+ const escapedName = escapePredicateArg(query.name);
1792
+ return {
1793
+ using: "predicate string",
1794
+ value: `type == "${escapedClass}" AND (label CONTAINS "${escapedName}" OR value CONTAINS "${escapedName}")`
1795
+ };
1796
+ }
1797
+ }
1798
+ }
1799
+ function unwrapIosTextSelector(selector) {
1800
+ const trimmed = selector.trim();
1801
+ if (!trimmed.startsWith("text=")) {
1802
+ return null;
1803
+ }
1804
+ return unquote3(trimmed.slice(5));
1805
+ }
1806
+ function delay2(ms) {
1807
+ return new Promise((resolve) => {
1808
+ setTimeout(resolve, ms);
1809
+ });
1810
+ }
1811
+ function createIosDriver(client, options) {
1812
+ const unsupported = (verb) => new Error(`${verb} is not supported by the iOS target`);
1813
+ const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
1814
+ async function resolveOne(selector) {
1815
+ const id = await client.findElement(parseIosSelector(selector));
1816
+ if (id === null) {
1817
+ throw new Error(`No element matched selector: ${selector}`);
1818
+ }
1819
+ return id;
1820
+ }
1821
+ async function clickSelector(selector) {
1822
+ await client.click(await resolveOne(selector));
1823
+ }
1824
+ async function fillSelector(selector, value) {
1825
+ await client.setValue(await resolveOne(selector), value);
1826
+ }
1827
+ async function pressKey(key) {
1828
+ const name = key.trim().toLowerCase();
1829
+ if (name === "enter" || name === "return") {
1830
+ await client.sendKeys(["\n"]);
1831
+ return;
1832
+ }
1833
+ if (name === "delete" || name === "backspace" || name === "del") {
1834
+ await client.sendKeys(["\b"]);
1835
+ return;
1836
+ }
1837
+ if (name === "home") {
1838
+ await client.homescreen();
1839
+ return;
1840
+ }
1841
+ throw new Error(
1842
+ `Unsupported key "${key}" for the iOS target. Supported keys: ${IOS_PRESS_KEYS.join(", ")}.`
1843
+ );
1844
+ }
1845
+ return {
1846
+ capabilities: IOS_CAPABILITIES,
1847
+ // navigation -----------------------------------------------------------
1848
+ goto(_url, _options) {
1849
+ return rejectUnsupported("navigate");
1850
+ },
1851
+ currentUrl() {
1852
+ return `ios:${options.appLabel ?? ""}`;
1853
+ },
1854
+ // queries --------------------------------------------------------------
1855
+ async count(selector) {
1856
+ return (await client.findElements(parseIosSelector(selector))).length;
1857
+ },
1858
+ async textContent(selector) {
1859
+ const id = await client.findElement(parseIosSelector(selector));
1860
+ if (id === null) {
1861
+ return null;
1862
+ }
1863
+ return client.getText(id);
1864
+ },
1865
+ // interactions ---------------------------------------------------------
1866
+ click: clickSelector,
1867
+ clickFirst: clickSelector,
1868
+ fill: fillSelector,
1869
+ fillFirst: fillSelector,
1870
+ async press(_selector, key) {
1871
+ await pressKey(key);
1872
+ },
1873
+ selectOption() {
1874
+ return rejectUnsupported("select");
1875
+ },
1876
+ selectOptionFirst() {
1877
+ return rejectUnsupported("select");
1878
+ },
1879
+ hover() {
1880
+ return rejectUnsupported("hover");
1881
+ },
1882
+ scrollIntoView() {
1883
+ return rejectUnsupported("scrollTo");
1884
+ },
1885
+ setInputFiles() {
1886
+ return rejectUnsupported("setInputFiles");
1887
+ },
1888
+ // semantic locators ----------------------------------------------------
1889
+ async countByRole(role, name) {
1890
+ return (await client.findElements({ by: "role", role, name })).length;
1891
+ },
1892
+ async clickFirstByRole(role, name) {
1893
+ const id = await client.findElement({ by: "role", role, name });
1894
+ if (id === null) {
1895
+ throw new Error(`No element matched role=${role}[name="${name}"]`);
1896
+ }
1897
+ await client.click(id);
1898
+ },
1899
+ async countByLabel(label) {
1900
+ return (await client.findElements({ by: "label", value: label })).length;
1901
+ },
1902
+ async fillFirstByLabel(label, value) {
1903
+ const id = await client.findElement({ by: "label", value: label });
1904
+ if (id === null) {
1905
+ throw new Error(`No element matched label="${label}"`);
1906
+ }
1907
+ await client.setValue(id, value);
1908
+ },
1909
+ selectOptionFirstByLabel() {
1910
+ return rejectUnsupported("select");
1911
+ },
1912
+ // waiting --------------------------------------------------------------
1913
+ async waitForSelector(selector, waitOptions) {
1914
+ const query = parseIosSelector(selector);
1915
+ const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
1916
+ const deadline = Date.now() + timeoutMs;
1917
+ for (; ; ) {
1918
+ if ((await client.findElements(query)).length > 0) {
1919
+ return;
1920
+ }
1921
+ if (Date.now() >= deadline) {
1922
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
1923
+ }
1924
+ await delay2(Math.min(WAIT_POLL_INTERVAL_MS2, Math.max(0, deadline - Date.now())));
1925
+ }
1926
+ },
1927
+ waitForUrl() {
1928
+ return rejectUnsupported("waitForUrl");
1929
+ },
1930
+ waitForNetworkIdle() {
1931
+ return rejectUnsupported("waitForNetworkIdle");
1932
+ },
1933
+ // scripting & artifacts ------------------------------------------------
1934
+ evaluate() {
1935
+ return rejectUnsupported("evalScript");
1936
+ },
1937
+ async screenshot(screenshotOptions) {
1938
+ await options.captureScreenshot(screenshotOptions.path);
1939
+ },
1940
+ // network / dialogs / downloads (all web-only) -------------------------
1941
+ onResponse(_handler) {
1942
+ throw unsupported("onResponse");
1943
+ },
1944
+ route(_url, _handler) {
1945
+ return rejectUnsupported("mockRoute");
1946
+ },
1947
+ unroute() {
1948
+ return rejectUnsupported("unmockRoute");
1949
+ },
1950
+ onDialog(_action) {
1951
+ throw unsupported("onDialog");
1952
+ },
1953
+ waitForDownloadEvent() {
1954
+ return rejectUnsupported("waitForDownload");
1955
+ },
1956
+ parseTextSelector(selector) {
1957
+ return unwrapIosTextSelector(selector);
1958
+ }
1959
+ };
1960
+ }
1961
+
1962
+ // src/browser/ios-simctl.ts
1963
+ import { execFile as execFile3 } from "child_process";
1964
+ import { mkdir, readFile, rm, writeFile } from "fs/promises";
1965
+ import net from "net";
1966
+ import os from "os";
1967
+ import path4 from "path";
1968
+ var DEFAULT_SIMULATOR_LOCK_ROOT = path4.join(os.tmpdir(), "prowl-ios-simulator-locks");
1969
+ var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
1970
+ var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
1971
+ execFile3(
1972
+ "xcrun",
1973
+ args,
1974
+ {
1975
+ encoding: "utf-8",
1976
+ timeout: options?.timeoutMs,
1977
+ maxBuffer: 32 * 1024 * 1024,
1978
+ env: options?.env ? { ...process.env, ...options.env } : process.env
1979
+ },
1980
+ (error, stdout, stderr) => {
1981
+ const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
1982
+ const capturedStderr = stderr ?? "";
1983
+ resolve({
1984
+ stdout: stdout ?? "",
1985
+ stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
1986
+ code
1987
+ });
1988
+ }
1989
+ );
1990
+ });
1991
+ function simulatorLockName(udid) {
1992
+ const safe = udid.replace(/[^A-Za-z0-9_.-]/g, "_");
1993
+ return `${safe || "simulator"}.lock`;
1994
+ }
1995
+ function isErrno(error, code) {
1996
+ return error?.code === code;
1997
+ }
1998
+ function isProcessAlive(pid) {
1999
+ try {
2000
+ process.kill(pid, 0);
2001
+ return true;
2002
+ } catch (error) {
2003
+ return isErrno(error, "EPERM");
2004
+ }
2005
+ }
2006
+ async function removeStaleSimulatorLock(lockPath) {
2007
+ try {
2008
+ const ownerText = await readFile(path4.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
2009
+ const owner = JSON.parse(ownerText);
2010
+ if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
2011
+ await rm(lockPath, { recursive: true, force: true });
2012
+ return true;
2013
+ }
2014
+ } catch {
2015
+ return false;
2016
+ }
2017
+ return false;
2018
+ }
2019
+ function simulatorReservedError(udid) {
2020
+ return new Error(
2021
+ `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.`
2022
+ );
2023
+ }
2024
+ async function reserveSimulatorUdid(udid, options = {}) {
2025
+ const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
2026
+ const lockPath = path4.join(lockRoot, simulatorLockName(udid));
2027
+ await mkdir(lockRoot, { recursive: true });
2028
+ for (let attempt = 0; attempt < 2; attempt += 1) {
2029
+ try {
2030
+ await mkdir(lockPath);
2031
+ } catch (error) {
2032
+ if (!isErrno(error, "EEXIST")) {
2033
+ throw error instanceof Error ? error : new Error(String(error));
2034
+ }
2035
+ if (attempt === 0 && await removeStaleSimulatorLock(lockPath)) {
2036
+ continue;
2037
+ }
2038
+ throw simulatorReservedError(udid);
2039
+ }
2040
+ let released = false;
2041
+ const release = async () => {
2042
+ if (released) {
2043
+ return;
2044
+ }
2045
+ released = true;
2046
+ await rm(lockPath, { recursive: true, force: true });
2047
+ };
2048
+ try {
2049
+ await writeFile(
2050
+ path4.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
2051
+ `${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
2052
+ `,
2053
+ { flag: "wx" }
2054
+ );
2055
+ } catch (error) {
2056
+ await release().catch(() => void 0);
2057
+ throw error instanceof Error ? error : new Error(String(error));
2058
+ }
2059
+ return { udid, release };
2060
+ }
2061
+ throw simulatorReservedError(udid);
2062
+ }
2063
+ function findFreePort() {
2064
+ return new Promise((resolve, reject) => {
2065
+ const server = net.createServer();
2066
+ server.on("error", reject);
2067
+ server.listen(0, "127.0.0.1", () => {
2068
+ const address = server.address();
2069
+ if (address && typeof address === "object") {
2070
+ const { port } = address;
2071
+ server.close(() => resolve(port));
2072
+ } else {
2073
+ server.close(() => reject(new Error("Could not allocate a local port")));
2074
+ }
2075
+ });
2076
+ });
2077
+ }
2078
+ function parseSimctlDevices(json) {
2079
+ let parsed;
2080
+ try {
2081
+ parsed = JSON.parse(json);
2082
+ } catch {
2083
+ throw new Error("Could not parse `simctl list devices --json` output.");
2084
+ }
2085
+ const byRuntime = parsed?.devices;
2086
+ if (!byRuntime || typeof byRuntime !== "object") {
2087
+ return [];
2088
+ }
2089
+ const devices = [];
2090
+ for (const [runtime, entries] of Object.entries(byRuntime)) {
2091
+ if (!Array.isArray(entries)) {
2092
+ continue;
2093
+ }
2094
+ for (const entry of entries) {
2095
+ if (!entry || typeof entry !== "object") {
2096
+ continue;
2097
+ }
2098
+ const record = entry;
2099
+ const udid = typeof record.udid === "string" ? record.udid : void 0;
2100
+ const name = typeof record.name === "string" ? record.name : void 0;
2101
+ const state = typeof record.state === "string" ? record.state : "Unknown";
2102
+ if (!udid || !name) {
2103
+ continue;
2104
+ }
2105
+ devices.push({
2106
+ udid,
2107
+ name,
2108
+ state,
2109
+ runtime,
2110
+ isAvailable: record.isAvailable !== false
2111
+ });
2112
+ }
2113
+ }
2114
+ return devices;
2115
+ }
2116
+ function bootedSimulators(devices) {
2117
+ return devices.filter((device) => device.state === "Booted");
2118
+ }
2119
+ function describeSimulator(device) {
2120
+ const runtime = device.runtime.replace(/^com\.apple\.CoreSimulator\.SimRuntime\./, "");
2121
+ return `${device.name} [${runtime}] (${device.udid})`;
2122
+ }
2123
+ function selectSimulatorUdid(devices, requested) {
2124
+ const booted = bootedSimulators(devices);
2125
+ if (requested) {
2126
+ const match = devices.find((device) => device.udid === requested);
2127
+ if (!match) {
2128
+ const known = devices.length > 0 ? devices.map((d) => d.udid).join(", ") : "none";
2129
+ throw new Error(
2130
+ `iOS simulator "${requested}" was not found. Known simulators: ${known}. Check \`xcrun simctl list devices\`.`
2131
+ );
2132
+ }
2133
+ if (match.state !== "Booted") {
2134
+ throw new Error(
2135
+ `iOS simulator "${requested}" is not booted (state: ${match.state}). Boot it with \`xcrun simctl boot ${requested}\`, then retry.`
2136
+ );
2137
+ }
2138
+ return requested;
2139
+ }
2140
+ if (booted.length === 0) {
2141
+ throw new Error(
2142
+ "No booted iOS simulator found. Boot one from Xcode or with `xcrun simctl boot <udid>` (see `xcrun simctl list devices available`), then retry."
2143
+ );
2144
+ }
2145
+ if (booted.length > 1) {
2146
+ throw new Error(
2147
+ `Multiple iOS simulators are booted (${booted.map(describeSimulator).join("; ")}). Set target.udid to pick one.`
2148
+ );
2149
+ }
2150
+ return booted[0].udid;
2151
+ }
2152
+ async function listSimulators(runner) {
2153
+ const result = await runner(["simctl", "list", "devices", "--json"], { timeoutMs: 3e4 });
2154
+ if (result.code !== 0) {
2155
+ throw new Error(
2156
+ `\`xcrun simctl list devices\` failed. Is Xcode installed and are the command-line tools selected (\`xcode-select -p\`)? ${(result.stderr.trim() || "").slice(0, 400)}`
2157
+ );
2158
+ }
2159
+ return parseSimctlDevices(result.stdout);
2160
+ }
2161
+ async function installApp(runner, udid, appPath) {
2162
+ const result = await runner(["simctl", "install", udid, appPath], { timeoutMs: 12e4 });
2163
+ if (result.code !== 0) {
2164
+ throw new Error(
2165
+ `Failed to install "${appPath}" onto simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
2166
+ );
2167
+ }
2168
+ }
2169
+ async function uninstallApp(runner, udid, bundleId) {
2170
+ await runner(["simctl", "uninstall", udid, bundleId], { timeoutMs: 6e4 }).catch(() => void 0);
2171
+ }
2172
+ async function launchApp(runner, udid, bundleId, childEnv = {}) {
2173
+ const env = {};
2174
+ for (const [key, value] of Object.entries(childEnv)) {
2175
+ env[`SIMCTL_CHILD_${key}`] = value;
2176
+ }
2177
+ const result = await runner(["simctl", "launch", udid, bundleId], {
2178
+ timeoutMs: 6e4,
2179
+ env: Object.keys(env).length > 0 ? env : void 0
2180
+ });
2181
+ if (result.code !== 0) {
2182
+ throw new Error(
2183
+ `Failed to launch "${bundleId}" on simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}. Is it installed?`
2184
+ );
2185
+ }
2186
+ }
2187
+ async function terminateApp(runner, udid, bundleId) {
2188
+ await runner(["simctl", "terminate", udid, bundleId], { timeoutMs: 3e4 }).catch(() => void 0);
2189
+ }
2190
+ async function captureScreenshot(runner, udid, outPath) {
2191
+ const result = await runner(["simctl", "io", udid, "screenshot", outPath], { timeoutMs: 3e4 });
2192
+ if (result.code !== 0) {
2193
+ throw new Error(
2194
+ `Failed to capture a simulator screenshot (${udid}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 300)}`
2195
+ );
2196
+ }
2197
+ }
2198
+ function parseXcodeVersion(stdout) {
2199
+ const match = /Xcode\s+([\d.]+)/i.exec(stdout);
2200
+ return match?.[1] ?? null;
2201
+ }
2202
+ async function xcodeVersion(runner) {
2203
+ const result = await runner(["xcodebuild", "-version"], { timeoutMs: 3e4 });
2204
+ if (result.code !== 0) {
2205
+ return null;
2206
+ }
2207
+ return parseXcodeVersion(result.stdout);
2208
+ }
2209
+
2210
+ // src/browser/ios-agent.ts
2211
+ var DEFAULT_WDA_REQUEST_TIMEOUT_MS = 3e4;
2212
+ var W3C_ELEMENT_KEY2 = "element-6066-11e4-a52e-4f735466cecf";
2213
+ var WdaHttpError = class extends Error {
2214
+ constructor(message, status, webdriverError) {
2215
+ super(message);
2216
+ this.status = status;
2217
+ this.webdriverError = webdriverError;
2218
+ this.name = "WdaHttpError";
2219
+ }
2220
+ };
2221
+ var WdaTransport = class {
2222
+ baseUrl;
2223
+ requestTimeoutMs;
2224
+ fetchImpl;
2225
+ constructor(options) {
2226
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
2227
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_WDA_REQUEST_TIMEOUT_MS;
2228
+ const injected = options.fetchImpl;
2229
+ if (injected) {
2230
+ this.fetchImpl = injected;
2231
+ } else if (typeof fetch === "function") {
2232
+ this.fetchImpl = (url, init) => fetch(url, init);
2233
+ } else {
2234
+ throw new Error("global fetch is unavailable; Node 20+ is required for the iOS target");
2235
+ }
2236
+ }
2237
+ /**
2238
+ * Send one request and return the full parsed JSON body. Rejects with a
2239
+ * {@link WdaHttpError} on a non-2xx response, or a timeout error when the
2240
+ * per-request deadline elapses.
2241
+ */
2242
+ async requestFull(method, path16, body, timeoutMs) {
2243
+ const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
2244
+ const controller = new AbortController();
2245
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
2246
+ timer.unref?.();
2247
+ const url = `${this.baseUrl}${path16}`;
2248
+ const timeoutError = () => {
2249
+ const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
2250
+ return new Error(`WebDriverAgent request ${method} ${path16} timed out after ${shown}`);
2251
+ };
2252
+ let response;
2253
+ try {
2254
+ response = await this.fetchImpl(url, {
2255
+ method,
2256
+ signal: controller.signal,
2257
+ headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
2258
+ body: body !== void 0 ? JSON.stringify(body) : void 0
2259
+ });
2260
+ } catch (error) {
2261
+ clearTimeout(timer);
2262
+ if (controller.signal.aborted) {
2263
+ throw timeoutError();
2264
+ }
2265
+ throw error instanceof Error ? error : new Error(String(error));
2266
+ }
2267
+ let text;
2268
+ try {
2269
+ text = await response.text();
2270
+ } catch (error) {
2271
+ if (controller.signal.aborted) {
2272
+ throw timeoutError();
2273
+ }
2274
+ throw error instanceof Error ? error : new Error(String(error));
2275
+ } finally {
2276
+ clearTimeout(timer);
2277
+ }
2278
+ const parsed = parseJson2(text);
2279
+ if (!response.ok) {
2280
+ const wdError = extractWebdriverError2(parsed);
2281
+ throw new WdaHttpError(
2282
+ `WebDriverAgent ${method} ${path16} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2283
+ response.status,
2284
+ wdError
2285
+ );
2286
+ }
2287
+ return parsed;
2288
+ }
2289
+ /** Like {@link requestFull} but returns just the `value` field. */
2290
+ async request(method, path16, body, timeoutMs) {
2291
+ const parsed = await this.requestFull(method, path16, body, timeoutMs);
2292
+ return parsed?.value;
2293
+ }
2294
+ };
2295
+ function parseJson2(text) {
2296
+ if (!text) {
2297
+ return void 0;
2298
+ }
2299
+ try {
2300
+ return JSON.parse(text);
2301
+ } catch {
2302
+ return void 0;
2303
+ }
2304
+ }
2305
+ function extractWebdriverError2(parsed) {
2306
+ const value = parsed?.value;
2307
+ if (value && typeof value === "object") {
2308
+ const record = value;
2309
+ const error = typeof record.error === "string" ? record.error : void 0;
2310
+ const message = typeof record.message === "string" ? record.message : void 0;
2311
+ return error ?? message;
2312
+ }
2313
+ return void 0;
2314
+ }
2315
+ function extractElementId2(value) {
2316
+ if (!value || typeof value !== "object") {
2317
+ return null;
2318
+ }
2319
+ const record = value;
2320
+ const id = record[W3C_ELEMENT_KEY2] ?? record.ELEMENT;
2321
+ return typeof id === "string" ? id : null;
2322
+ }
2323
+ function isNoSuchElement2(error) {
2324
+ if (error instanceof WdaHttpError) {
2325
+ return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
2326
+ }
2327
+ return false;
2328
+ }
2329
+ async function createWdaSession(transport, bundleId) {
2330
+ const body = await transport.requestFull("POST", "/session", {
2331
+ capabilities: { alwaysMatch: { bundleId }, firstMatch: [{}] }
2332
+ });
2333
+ const envelope = body ?? {};
2334
+ const value = envelope.value ?? {};
2335
+ const sessionId = typeof value.sessionId === "string" && value.sessionId || typeof envelope.sessionId === "string" && envelope.sessionId || "";
2336
+ if (sessionId.length > 0) {
2337
+ return sessionId;
2338
+ }
2339
+ throw new Error("WebDriverAgent did not return a session id");
2340
+ }
2341
+ async function waitForWdaReady(transport, options = { deadlineMs: 6e4 }) {
2342
+ const interval = options.intervalMs ?? 300;
2343
+ const deadline = Date.now() + options.deadlineMs;
2344
+ let lastError;
2345
+ for (; ; ) {
2346
+ try {
2347
+ const remainingMs = Math.max(1, deadline - Date.now());
2348
+ await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
2349
+ return;
2350
+ } catch (error) {
2351
+ lastError = error;
2352
+ }
2353
+ if (Date.now() >= deadline) {
2354
+ const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
2355
+ throw new Error(`WebDriverAgent did not become ready within ${options.deadlineMs}ms${detail}`);
783
2356
  }
784
- this.pending.delete(id);
785
- clearTimeout(pending.timer);
786
- if (message.ok === true) {
787
- pending.resolve(message.result ?? {});
788
- } else {
789
- pending.reject(new Error(typeof message.error === "string" ? message.error : "prowl-macdriver error"));
2357
+ await sleep2(Math.min(interval, Math.max(0, deadline - Date.now())));
2358
+ }
2359
+ }
2360
+ function sleep2(ms) {
2361
+ return new Promise((resolve) => {
2362
+ setTimeout(resolve, ms);
2363
+ });
2364
+ }
2365
+ function createWdaAgentClient(transport, sessionId) {
2366
+ const base = `/session/${sessionId}`;
2367
+ async function locate(query, path16) {
2368
+ return transport.request("POST", `${base}${path16}`, iosQueryToLocator(query));
2369
+ }
2370
+ return {
2371
+ async findElement(query) {
2372
+ try {
2373
+ return extractElementId2(await locate(query, "/element"));
2374
+ } catch (error) {
2375
+ if (isNoSuchElement2(error)) {
2376
+ return null;
2377
+ }
2378
+ throw error;
2379
+ }
2380
+ },
2381
+ async findElements(query) {
2382
+ const value = await locate(query, "/elements");
2383
+ if (!Array.isArray(value)) {
2384
+ return [];
2385
+ }
2386
+ return value.map((entry) => extractElementId2(entry)).filter((id) => id !== null);
2387
+ },
2388
+ async click(elementId) {
2389
+ await transport.request("POST", `${base}/element/${elementId}/click`, {});
2390
+ },
2391
+ async setValue(elementId, text) {
2392
+ await transport.request("POST", `${base}/element/${elementId}/value`, { text });
2393
+ },
2394
+ async getText(elementId) {
2395
+ const value = await transport.request("GET", `${base}/element/${elementId}/text`);
2396
+ return typeof value === "string" ? value : value == null ? null : String(value);
2397
+ },
2398
+ async sendKeys(keys) {
2399
+ await transport.request("POST", `${base}/wda/keys`, { value: keys });
2400
+ },
2401
+ async homescreen() {
2402
+ await transport.request("POST", "/wda/homescreen", {});
2403
+ },
2404
+ async close() {
2405
+ await transport.request("DELETE", base).catch(() => void 0);
790
2406
  }
2407
+ };
2408
+ }
2409
+
2410
+ // src/browser/ios-helper.ts
2411
+ import { createRequire as createRequire2 } from "module";
2412
+ import fs5 from "fs";
2413
+ import os2 from "os";
2414
+ import path5 from "path";
2415
+ var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
2416
+ var WDA_RUNNER_APP_NAME = "WebDriverAgentRunner-Runner.app";
2417
+ var WDA_USE_PORT_ENV = "USE_PORT";
2418
+ var WDA_STARTUP_ATTEMPTS = 2;
2419
+ var defaultIosAgentConnector = async ({
2420
+ host,
2421
+ port,
2422
+ bundleId,
2423
+ requestTimeoutMs,
2424
+ readyDeadlineMs
2425
+ }) => {
2426
+ const transport = new WdaTransport({ baseUrl: `http://${host}:${port}`, requestTimeoutMs });
2427
+ await waitForWdaReady(transport, { deadlineMs: readyDeadlineMs });
2428
+ const sessionId = await createWdaSession(transport, bundleId);
2429
+ return createWdaAgentClient(transport, sessionId);
2430
+ };
2431
+ function resolveWdaProject(requireFn = createRequire2(import.meta.url)) {
2432
+ let pkgJsonPath;
2433
+ try {
2434
+ pkgJsonPath = requireFn.resolve("appium-webdriveragent/package.json");
2435
+ } catch {
2436
+ throw new Error(
2437
+ "The iOS target requires the `appium-webdriveragent` package (its WDA Xcode project). It is an optional dependency of prowl-tools; it may have been skipped (--omit=optional) or failed to install. Restore it for a global Prowl install with: npm install -g appium-webdriveragent@16.4.0. If Prowl is installed locally in a project, run: npm install appium-webdriveragent@16.4.0"
2438
+ );
791
2439
  }
792
- failAll(error) {
793
- for (const pending of this.pending.values()) {
794
- clearTimeout(pending.timer);
795
- pending.reject(error);
2440
+ const pkgDir = path5.dirname(pkgJsonPath);
2441
+ const version = requireFn(pkgJsonPath).version;
2442
+ const projectPath = path5.join(pkgDir, "WebDriverAgent.xcodeproj");
2443
+ if (!fs5.existsSync(projectPath)) {
2444
+ throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
2445
+ }
2446
+ return { projectPath, version };
2447
+ }
2448
+ function wdaCacheDir(wdaVersion, xcode, homeDir = os2.homedir()) {
2449
+ return path5.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
2450
+ }
2451
+ function runnerAppPath(derivedDataPath) {
2452
+ return path5.join(derivedDataPath, "Build", "Products", "Debug-iphonesimulator", WDA_RUNNER_APP_NAME);
2453
+ }
2454
+ async function resolveWdaRunner(options = {}) {
2455
+ const runner = options.runner ?? execFileXcrunRunner;
2456
+ const env = options.env ?? process.env;
2457
+ const homeDir = options.homeDir ?? os2.homedir();
2458
+ const log = options.logger ?? ((message) => process.stderr.write(`${message}
2459
+ `));
2460
+ const override = env.PROWL_WDA_RUNNER;
2461
+ if (override) {
2462
+ if (!fs5.existsSync(override)) {
2463
+ throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
796
2464
  }
797
- this.pending.clear();
2465
+ return override;
798
2466
  }
799
- recordTerminalFailure(error) {
800
- this.terminalError ??= error;
801
- this.closed = true;
802
- this.failAll(this.terminalError);
2467
+ const { projectPath, version } = resolveWdaProject(options.requireFn);
2468
+ const xcode = await xcodeVersion(runner);
2469
+ if (!xcode) {
2470
+ throw new Error(
2471
+ "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."
2472
+ );
803
2473
  }
804
- /** Number of in-flight requests awaiting a response (for teardown/tests). */
805
- get pendingCount() {
806
- return this.pending.size;
2474
+ const cacheDir = wdaCacheDir(version, xcode, homeDir);
2475
+ const cachedRunner = runnerAppPath(cacheDir);
2476
+ if (fs5.existsSync(cachedRunner)) {
2477
+ return cachedRunner;
807
2478
  }
808
- request(cmd, params = {}) {
809
- if (this.terminalError) {
810
- return Promise.reject(this.terminalError);
811
- }
812
- if (this.closed) {
813
- return Promise.reject(new Error("prowl-macdriver client is closed"));
2479
+ log(
2480
+ `Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
2481
+ );
2482
+ fs5.mkdirSync(cacheDir, { recursive: true });
2483
+ const result = await runner(
2484
+ [
2485
+ "xcodebuild",
2486
+ "build-for-testing",
2487
+ "-project",
2488
+ projectPath,
2489
+ "-scheme",
2490
+ "WebDriverAgentRunner",
2491
+ "-destination",
2492
+ "generic/platform=iOS Simulator",
2493
+ "-derivedDataPath",
2494
+ cacheDir,
2495
+ "CODE_SIGNING_ALLOWED=NO"
2496
+ ],
2497
+ { timeoutMs: 12e5 }
2498
+ );
2499
+ if (result.code !== 0) {
2500
+ throw new Error(
2501
+ `Failed to build WebDriverAgent with \`xcodebuild build-for-testing\`. Ensure a full Xcode (not just the command-line tools) is installed and selected (\`xcode-select -p\`). Details: ${(result.stderr.trim() || result.stdout.trim()).slice(-800)}`
2502
+ );
2503
+ }
2504
+ if (!fs5.existsSync(cachedRunner)) {
2505
+ throw new Error(
2506
+ `WebDriverAgent build succeeded but the runner app was not found at ${cachedRunner}. This may indicate an Xcode layout change; set PROWL_WDA_RUNNER to a prebuilt runner.`
2507
+ );
2508
+ }
2509
+ return cachedRunner;
2510
+ }
2511
+ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
2512
+ if (!looksLikeIosAppPath(app)) {
2513
+ assertIosAppAllowed(allowedApps, app);
2514
+ if (coldStart) {
2515
+ throw new Error(
2516
+ `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.`
2517
+ );
814
2518
  }
815
- const id = this.nextId++;
816
- const payload = JSON.stringify({ id, cmd, ...params });
817
- return new Promise((resolve, reject) => {
818
- const timer = setTimeout(() => {
819
- if (this.pending.delete(id)) {
820
- const shown = this.requestTimeoutMs >= 1e3 ? `${Math.round(this.requestTimeoutMs / 1e3)}s` : `${this.requestTimeoutMs}ms`;
821
- reject(new Error(`prowl-macdriver request "${cmd}" timed out after ${shown}`));
822
- }
823
- }, this.requestTimeoutMs);
824
- timer.unref?.();
825
- this.pending.set(id, { cmd, resolve, reject, timer });
826
- this.child.stdin?.write(payload + "\n", (error) => {
827
- if (error && this.pending.delete(id)) {
828
- clearTimeout(timer);
829
- reject(error);
830
- }
831
- });
832
- });
2519
+ return app;
833
2520
  }
834
- async close() {
835
- if (this.closed) {
2521
+ const appPath = path5.resolve(app);
2522
+ if (!fs5.existsSync(appPath)) {
2523
+ throw new Error(`.app bundle not found: ${appPath}`);
2524
+ }
2525
+ const bundleId = readIosBundleIdentifier(appPath);
2526
+ if (!bundleId) {
2527
+ throw new Error(
2528
+ `Could not read CFBundleIdentifier from "${appPath}" (root Info.plist). Ensure target.app points at a built iOS .app bundle.`
2529
+ );
2530
+ }
2531
+ assertIosAppAllowed(allowedApps, appPath);
2532
+ if (coldStart) {
2533
+ await uninstallApp(runner, udid, bundleId);
2534
+ }
2535
+ await installApp(runner, udid, appPath);
2536
+ return bundleId;
2537
+ }
2538
+ async function launchIosSession(options) {
2539
+ const runner = options.runner ?? execFileXcrunRunner;
2540
+ const portAllocator = options.portAllocator ?? findFreePort;
2541
+ const connector = options.agentConnector ?? defaultIosAgentConnector;
2542
+ const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
2543
+ const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, 6e4);
2544
+ const devices = await listSimulators(runner);
2545
+ const udid = selectSimulatorUdid(devices, options.udid);
2546
+ const reservation = await reserveSimulatorUdid(udid, { lockRoot: options.simulatorLockRoot });
2547
+ let reservationReleased = false;
2548
+ const releaseReservation = async () => {
2549
+ if (reservationReleased) {
836
2550
  return;
837
2551
  }
838
- this.closed = true;
839
- try {
840
- this.child.stdin?.write(JSON.stringify({ cmd: "shutdown" }) + "\n");
841
- this.child.stdin?.end();
842
- } catch {
843
- }
844
- await new Promise((resolve) => {
845
- if (this.child.exitCode !== null || this.child.signalCode !== null) {
846
- resolve();
847
- return;
848
- }
849
- const timer = setTimeout(() => {
850
- this.child.kill("SIGKILL");
851
- resolve();
852
- }, 2e3);
853
- this.child.once("exit", () => {
854
- clearTimeout(timer);
855
- resolve();
856
- });
857
- });
858
- this.failAll(new Error("prowl-macdriver client is closed"));
859
- }
860
- };
861
- async function launchMacSession(options) {
862
- const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_REQUEST_TIMEOUT_MS) + 5e3;
863
- const client = options.clientFactory ? options.clientFactory() : new SpawnMacHelperClient(resolveHelperBinary(), { requestTimeoutMs });
864
- const timeoutSeconds = (options.timeoutMs ?? 1e4) / 1e3;
2552
+ reservationReleased = true;
2553
+ await reservation.release();
2554
+ };
865
2555
  try {
866
- const trust = await client.request("check");
867
- if (trust.trusted !== true) {
868
- throw new Error(
869
- "Prowl's macOS target is not trusted for Accessibility. Grant the hosting terminal/app permission in System Settings \u2192 Privacy & Security \u2192 Accessibility, then retry."
870
- );
2556
+ const bundleId = await resolveBundleId(
2557
+ options.app,
2558
+ runner,
2559
+ udid,
2560
+ options.coldStart ?? false,
2561
+ options.allowedApps ?? []
2562
+ );
2563
+ const wdaRunnerApp = options.wdaRunnerApp ?? await resolveWdaRunner({ runner, logger: options.logger });
2564
+ await installApp(runner, udid, wdaRunnerApp);
2565
+ for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
2566
+ let client;
2567
+ let tornDown = false;
2568
+ let terminateWda = false;
2569
+ let terminateTarget = false;
2570
+ let stage = "port";
2571
+ const teardownAttempt = async () => {
2572
+ if (tornDown) {
2573
+ return;
2574
+ }
2575
+ tornDown = true;
2576
+ if (client) {
2577
+ await client.close().catch(() => void 0);
2578
+ }
2579
+ if (terminateWda) {
2580
+ await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
2581
+ }
2582
+ if (terminateTarget) {
2583
+ await terminateApp(runner, udid, bundleId);
2584
+ }
2585
+ };
2586
+ try {
2587
+ const port = await portAllocator();
2588
+ stage = "wda-launch";
2589
+ terminateWda = true;
2590
+ await launchApp(runner, udid, WDA_RUNNER_BUNDLE_ID, { [WDA_USE_PORT_ENV]: String(port) });
2591
+ stage = "target-launch";
2592
+ terminateTarget = true;
2593
+ await launchApp(runner, udid, bundleId);
2594
+ stage = "connect";
2595
+ client = await connector({ host: "127.0.0.1", port, bundleId, requestTimeoutMs, readyDeadlineMs });
2596
+ const driver = createIosDriver(client, {
2597
+ appLabel: bundleId,
2598
+ captureScreenshot: (outPath) => captureScreenshot(runner, udid, outPath)
2599
+ });
2600
+ const teardown = async () => {
2601
+ await teardownAttempt();
2602
+ await releaseReservation();
2603
+ };
2604
+ return { client, driver, bundleId, udid, teardown };
2605
+ } catch (error) {
2606
+ await teardownAttempt();
2607
+ if (stage === "target-launch" || attempt >= WDA_STARTUP_ATTEMPTS) {
2608
+ throw error instanceof Error ? error : new Error(String(error));
2609
+ }
2610
+ }
871
2611
  }
872
- const launched = await client.request("launch", { app: options.app, timeout: timeoutSeconds });
873
- const bundleId = String(launched.bundleId ?? options.app);
874
- const driver = createMacDriver(client, { appLabel: bundleId });
875
- return { client, driver, bundleId };
2612
+ throw new Error("WebDriverAgent startup failed without an error.");
876
2613
  } catch (error) {
877
- await client.close().catch(() => void 0);
2614
+ await releaseReservation().catch(() => void 0);
878
2615
  throw error;
879
2616
  }
880
2617
  }
881
- async function closeMacSession(session) {
882
- try {
883
- await session.client.request("quit");
884
- } catch {
885
- } finally {
886
- await session.client.close();
887
- }
2618
+ async function closeIosSession(session) {
2619
+ await session.teardown();
888
2620
  }
889
2621
 
890
2622
  // src/runner/healing.ts
@@ -929,8 +2661,8 @@ async function healSelector(probe, selector, options) {
929
2661
  for (const candidate of buildHealCandidates(selector)) {
930
2662
  let count;
931
2663
  try {
932
- const locator = probe.locator(candidate.selector);
933
- count = await locator.count();
2664
+ const locator2 = probe.locator(candidate.selector);
2665
+ count = await locator2.count();
934
2666
  } catch {
935
2667
  continue;
936
2668
  }
@@ -942,15 +2674,15 @@ async function healSelector(probe, selector, options) {
942
2674
  }
943
2675
 
944
2676
  // src/runner/history.ts
945
- import fs3 from "fs";
946
- import path3 from "path";
2677
+ import fs6 from "fs";
2678
+ import path6 from "path";
947
2679
  var HISTORY_FILE = "history.json";
948
2680
  var LOCK_FILE_SUFFIX = ".lock";
949
2681
  var LOCK_RETRY_MS = 10;
950
2682
  var LOCK_TIMEOUT_MS = 5e3;
951
2683
  var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
952
2684
  function historyPath(configDir) {
953
- return path3.join(configDir, HISTORY_FILE);
2685
+ return path6.join(configDir, HISTORY_FILE);
954
2686
  }
955
2687
  function isHistoryEntry(value) {
956
2688
  if (!value || typeof value !== "object") {
@@ -961,11 +2693,11 @@ function isHistoryEntry(value) {
961
2693
  }
962
2694
  function readHistory(configDir) {
963
2695
  const filePath = historyPath(configDir);
964
- if (!fs3.existsSync(filePath)) {
2696
+ if (!fs6.existsSync(filePath)) {
965
2697
  return { entries: [] };
966
2698
  }
967
2699
  try {
968
- const raw = fs3.readFileSync(filePath, "utf-8");
2700
+ const raw = fs6.readFileSync(filePath, "utf-8");
969
2701
  const parsed = JSON.parse(raw);
970
2702
  if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
971
2703
  const validatedEntries = parsed.entries.filter(isHistoryEntry);
@@ -1004,12 +2736,12 @@ function sleepSync(ms) {
1004
2736
  function withHistoryLock(configDir, fn) {
1005
2737
  const filePath = historyPath(configDir);
1006
2738
  const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
1007
- fs3.mkdirSync(path3.dirname(filePath), { recursive: true });
2739
+ fs6.mkdirSync(path6.dirname(filePath), { recursive: true });
1008
2740
  const startedAt = Date.now();
1009
2741
  while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
1010
2742
  let fd;
1011
2743
  try {
1012
- fd = fs3.openSync(lockPath, "wx");
2744
+ fd = fs6.openSync(lockPath, "wx");
1013
2745
  } catch (error) {
1014
2746
  if (error.code === "EEXIST") {
1015
2747
  sleepSync(LOCK_RETRY_MS);
@@ -1021,10 +2753,10 @@ function withHistoryLock(configDir, fn) {
1021
2753
  return fn();
1022
2754
  } finally {
1023
2755
  try {
1024
- fs3.closeSync(fd);
2756
+ fs6.closeSync(fd);
1025
2757
  } catch {
1026
2758
  }
1027
- fs3.rmSync(lockPath, { force: true });
2759
+ fs6.rmSync(lockPath, { force: true });
1028
2760
  }
1029
2761
  }
1030
2762
  throw new Error(
@@ -1037,19 +2769,19 @@ function appendEntry(configDir, entry, maxRuns) {
1037
2769
  const current = readHistory(configDir);
1038
2770
  const next = pruneEntries([...current.entries, entry], maxRuns);
1039
2771
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
1040
- fs3.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
2772
+ fs6.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
1041
2773
  `);
1042
- fs3.renameSync(tempPath, filePath);
2774
+ fs6.renameSync(tempPath, filePath);
1043
2775
  });
1044
2776
  }
1045
2777
 
1046
2778
  // src/runner/index.ts
1047
- import fs9 from "fs";
1048
- import path9 from "path";
2779
+ import fs12 from "fs";
2780
+ import path12 from "path";
1049
2781
 
1050
2782
  // src/browser/playwright-driver.ts
1051
- import fs4 from "fs";
1052
- import path4 from "path";
2783
+ import fs7 from "fs";
2784
+ import path7 from "path";
1053
2785
  import {
1054
2786
  chromium,
1055
2787
  firefox,
@@ -1075,14 +2807,14 @@ async function launchBrowser(options) {
1075
2807
  contextOptions.viewport = options.viewport;
1076
2808
  }
1077
2809
  if (options.storageStatePath) {
1078
- if (fs4.existsSync(options.storageStatePath)) {
2810
+ if (fs7.existsSync(options.storageStatePath)) {
1079
2811
  contextOptions.storageState = options.storageStatePath;
1080
2812
  } else {
1081
2813
  console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
1082
2814
  }
1083
2815
  }
1084
2816
  if (options.recordHar) {
1085
- contextOptions.recordHar = { path: path4.join(options.runDir, "network.har") };
2817
+ contextOptions.recordHar = { path: path7.join(options.runDir, "network.har") };
1086
2818
  }
1087
2819
  const context = await browser.newContext(contextOptions);
1088
2820
  const page = await context.newPage();
@@ -1090,7 +2822,7 @@ async function launchBrowser(options) {
1090
2822
  page.setDefaultNavigationTimeout(options.timeout);
1091
2823
  let tracePath;
1092
2824
  if (options.trace) {
1093
- tracePath = path4.join(options.runDir, "trace.zip");
2825
+ tracePath = path7.join(options.runDir, "trace.zip");
1094
2826
  await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
1095
2827
  }
1096
2828
  return { browser, context, page, tracePath };
@@ -1268,8 +3000,8 @@ function createPlaywrightDriver(page) {
1268
3000
  }
1269
3001
 
1270
3002
  // src/runner/steps.ts
1271
- import fs5 from "fs";
1272
- import path5 from "path";
3003
+ import fs8 from "fs";
3004
+ import path8 from "path";
1273
3005
 
1274
3006
  // src/runner/policy.ts
1275
3007
  var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
@@ -1705,7 +3437,7 @@ async function runInlineAssert(driver, policy, assertion) {
1705
3437
  throw new Error("assert step is missing an assertion type");
1706
3438
  }
1707
3439
  function screenshotPath(screenshotsDir, fileName) {
1708
- return path5.join(screenshotsDir, fileName);
3440
+ return path8.join(screenshotsDir, fileName);
1709
3441
  }
1710
3442
  function stepPath(prefix, index) {
1711
3443
  return prefix ? `${prefix}.${index}` : `${index}`;
@@ -1722,12 +3454,12 @@ function validateDownloadFilename(suggestedFilename) {
1722
3454
  const safeFilename = suggestedFilename.trim();
1723
3455
  const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
1724
3456
  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)) {
3457
+ if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== path8.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
1726
3458
  throw new Error(`Invalid download filename: "${suggestedFilename}"`);
1727
3459
  }
1728
3460
  return safeFilename;
1729
3461
  }
1730
- async function captureScreenshot(taker, filePath) {
3462
+ async function captureScreenshot2(taker, filePath) {
1731
3463
  try {
1732
3464
  await taker.screenshot({ path: filePath, fullPage: true });
1733
3465
  } catch (error) {
@@ -1902,7 +3634,7 @@ var STEP_HANDLERS = {
1902
3634
  if (!("setInputFiles" in h.step)) unknownStep();
1903
3635
  const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
1904
3636
  const rawFiles = h.step.setInputFiles.files;
1905
- const resolveFile = (f) => path5.isAbsolute(f) ? f : path5.join(h.context.configDir, f);
3637
+ const resolveFile = (f) => path8.isAbsolute(f) ? f : path8.join(h.context.configDir, f);
1906
3638
  const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
1907
3639
  await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
1908
3640
  h.policy.ensureLocationAllowed(h.driver);
@@ -1939,7 +3671,7 @@ var STEP_HANDLERS = {
1939
3671
  redactedFillSteps: subRedacted,
1940
3672
  randomVars
1941
3673
  } = interpolateHunt(subHunt, process.env, h.context.randomVars);
1942
- const subTargetType = h.driver.capabilities.has("navigate") ? "web" : "macos";
3674
+ const subTargetType = h.context.targetType ?? (h.driver.capabilities.has("navigate") ? "web" : "macos");
1943
3675
  assertStepsSupportedByTarget(interpolatedSubHunt.steps, subTargetType);
1944
3676
  assertHuntAssertionsSupportedByTarget(interpolatedSubHunt.assertions, subTargetType);
1945
3677
  h.policy.assertWithinMaxSteps(interpolatedSubHunt.steps.length, huntName);
@@ -2264,15 +3996,15 @@ var STEP_HANDLERS = {
2264
3996
  if (!responseFile) {
2265
3997
  throw new Error("mock.response must include either body or file");
2266
3998
  }
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);
3999
+ const candidateFilePath = path8.isAbsolute(responseFile) ? responseFile : path8.join(h.context.configDir, responseFile);
4000
+ const resolvedConfigDir = path8.resolve(h.context.configDir);
4001
+ const resolvedFilePath = path8.resolve(candidateFilePath);
4002
+ const relativePath = path8.relative(resolvedConfigDir, resolvedFilePath);
4003
+ const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${path8.sep}`) && !path8.isAbsolute(relativePath);
2272
4004
  if (!isWithinConfigDir) {
2273
4005
  throw new Error("mock.response.file must resolve within config directory");
2274
4006
  }
2275
- responseBody = await fs5.promises.readFile(resolvedFilePath, "utf-8");
4007
+ responseBody = await fs8.promises.readFile(resolvedFilePath, "utf-8");
2276
4008
  }
2277
4009
  const contentType = mock.response.contentType ?? "application/json";
2278
4010
  const status = mock.response.status;
@@ -2335,8 +4067,8 @@ var STEP_HANDLERS = {
2335
4067
  capabilities: ["evaluate"],
2336
4068
  run: async (h) => {
2337
4069
  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");
4070
+ const filePath = path8.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : path8.join(h.context.configDir, h.step.runScript.file);
4071
+ const fileContents = fs8.readFileSync(filePath, "utf-8");
2340
4072
  await h.driver.evaluate(fileContents);
2341
4073
  return {
2342
4074
  kind: "result",
@@ -2357,19 +4089,19 @@ var STEP_HANDLERS = {
2357
4089
  const name = h.step.assertScreenshot.name;
2358
4090
  const threshold = h.step.assertScreenshot.threshold ?? 0.1;
2359
4091
  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 });
4092
+ const baselinePath = path8.join(baselineDir, `${name}.png`);
4093
+ const currentScreenshotPath = path8.join(h.context.runDir, "screenshots", `${name}-current.png`);
4094
+ fs8.mkdirSync(path8.dirname(currentScreenshotPath), { recursive: true });
2363
4095
  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);
4096
+ h.screenshots.push(path8.join("screenshots", `${name}-current.png`));
4097
+ if (!fs8.existsSync(baselinePath)) {
4098
+ fs8.copyFileSync(currentScreenshotPath, baselinePath);
2367
4099
  return {
2368
4100
  kind: "result",
2369
4101
  result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
2370
4102
  };
2371
4103
  }
2372
- const diffPath = path5.join(h.context.runDir, "screenshots", `${name}-diff.png`);
4104
+ const diffPath = path8.join(h.context.runDir, "screenshots", `${name}-diff.png`);
2373
4105
  const comparison = await compareScreenshots(baselinePath, currentScreenshotPath, diffPath, threshold);
2374
4106
  if (comparison.match) {
2375
4107
  return {
@@ -2382,7 +4114,7 @@ var STEP_HANDLERS = {
2382
4114
  }
2383
4115
  };
2384
4116
  }
2385
- h.screenshots.push(path5.join("screenshots", `${name}-diff.png`));
4117
+ h.screenshots.push(path8.join("screenshots", `${name}-diff.png`));
2386
4118
  throw new Error(
2387
4119
  `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
2388
4120
  );
@@ -2424,7 +4156,7 @@ var STEP_HANDLERS = {
2424
4156
  `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
2425
4157
  );
2426
4158
  }
2427
- const savePath = path5.join(h.context.runDir, suggestedFilename);
4159
+ const savePath = path8.join(h.context.runDir, suggestedFilename);
2428
4160
  await download.saveAs(savePath);
2429
4161
  return {
2430
4162
  kind: "result",
@@ -2453,8 +4185,8 @@ async function executeSteps(context) {
2453
4185
  maxSteps: context.maxSteps,
2454
4186
  selfHealing: context.selfHealing
2455
4187
  });
2456
- const screenshotsDir = path5.join(context.runDir, "screenshots");
2457
- fs5.mkdirSync(screenshotsDir, { recursive: true });
4188
+ const screenshotsDir = path8.join(context.runDir, "screenshots");
4189
+ fs8.mkdirSync(screenshotsDir, { recursive: true });
2458
4190
  const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
2459
4191
  policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
2460
4192
  const results = [];
@@ -2463,8 +4195,8 @@ async function executeSteps(context) {
2463
4195
  context.runStartedAtMs = runStartedAtMs;
2464
4196
  const addScreenshot = async (fileName) => {
2465
4197
  const fullPath = screenshotPath(screenshotsDir, fileName);
2466
- await captureScreenshot(driver, fullPath);
2467
- const relative = path5.join("screenshots", fileName);
4198
+ await captureScreenshot2(driver, fullPath);
4199
+ const relative = path8.join("screenshots", fileName);
2468
4200
  screenshots.push(relative);
2469
4201
  return relative;
2470
4202
  };
@@ -2552,12 +4284,12 @@ async function executeSteps(context) {
2552
4284
  return { results, screenshots, failed: false };
2553
4285
  }
2554
4286
  async function captureFinalScreenshot(page, runDir) {
2555
- const screenshotsDir = path5.join(runDir, "screenshots");
2556
- fs5.mkdirSync(screenshotsDir, { recursive: true });
4287
+ const screenshotsDir = path8.join(runDir, "screenshots");
4288
+ fs8.mkdirSync(screenshotsDir, { recursive: true });
2557
4289
  const fileName = "final.png";
2558
4290
  const filePath = screenshotPath(screenshotsDir, fileName);
2559
- await captureScreenshot(page, filePath);
2560
- return path5.join("screenshots", fileName);
4291
+ await captureScreenshot2(page, filePath);
4292
+ return path8.join("screenshots", fileName);
2561
4293
  }
2562
4294
 
2563
4295
  // src/runner/assertions.ts
@@ -2717,18 +4449,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
2717
4449
  }
2718
4450
 
2719
4451
  // src/reporter/result.ts
2720
- import fs6 from "fs";
2721
- import path6 from "path";
4452
+ import fs9 from "fs";
4453
+ import path9 from "path";
2722
4454
  function writeResult(runDir, result) {
2723
4455
  const fileName = "result.json";
2724
- const fullPath = path6.join(runDir, fileName);
2725
- fs6.writeFileSync(fullPath, JSON.stringify(result, null, 2));
4456
+ const fullPath = path9.join(runDir, fileName);
4457
+ fs9.writeFileSync(fullPath, JSON.stringify(result, null, 2));
2726
4458
  return fileName;
2727
4459
  }
2728
4460
 
2729
4461
  // src/reporter/summary.ts
2730
- import fs7 from "fs";
2731
- import path7 from "path";
4462
+ import fs10 from "fs";
4463
+ import path10 from "path";
2732
4464
  function escapeMd(text) {
2733
4465
  return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
2734
4466
  }
@@ -2806,15 +4538,15 @@ function writeSummary(runDir, result) {
2806
4538
  }
2807
4539
  }
2808
4540
  const fileName = "summary.md";
2809
- const fullPath = path7.join(runDir, fileName);
2810
- fs7.writeFileSync(fullPath, `${lines.join("\n")}
4541
+ const fullPath = path10.join(runDir, fileName);
4542
+ fs10.writeFileSync(fullPath, `${lines.join("\n")}
2811
4543
  `);
2812
4544
  return fileName;
2813
4545
  }
2814
4546
 
2815
4547
  // src/reporter/junit.ts
2816
- import fs8 from "fs";
2817
- import path8 from "path";
4548
+ import fs11 from "fs";
4549
+ import path11 from "path";
2818
4550
  function escapeXml(text) {
2819
4551
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
2820
4552
  }
@@ -2858,8 +4590,8 @@ function writeJunit(runDir, result) {
2858
4590
  lines.push(" </testsuite>");
2859
4591
  lines.push("</testsuites>");
2860
4592
  const fileName = "junit.xml";
2861
- const fullPath = path8.join(runDir, fileName);
2862
- fs8.writeFileSync(fullPath, `${lines.join("\n")}
4593
+ const fullPath = path11.join(runDir, fileName);
4594
+ fs11.writeFileSync(fullPath, `${lines.join("\n")}
2863
4595
  `);
2864
4596
  return fileName;
2865
4597
  }
@@ -2901,11 +4633,11 @@ function parseViewportFlag(value) {
2901
4633
  return value;
2902
4634
  }
2903
4635
  function resolvePath(configDir, inputPath) {
2904
- if (path9.isAbsolute(inputPath)) {
4636
+ if (path12.isAbsolute(inputPath)) {
2905
4637
  return inputPath;
2906
4638
  }
2907
- const projectRoot = path9.dirname(configDir);
2908
- return path9.join(projectRoot, inputPath);
4639
+ const projectRoot = path12.dirname(configDir);
4640
+ return path12.join(projectRoot, inputPath);
2909
4641
  }
2910
4642
  function buildRunResult(options) {
2911
4643
  return {
@@ -2924,12 +4656,12 @@ function buildRunResult(options) {
2924
4656
  }
2925
4657
  function writeConsoleLog(runDir, entries) {
2926
4658
  const fileName = "console.log";
2927
- const filePath = path9.join(runDir, fileName);
4659
+ const filePath = path12.join(runDir, fileName);
2928
4660
  const lines = entries.map((entry) => {
2929
4661
  const location = entry.location ? ` (${entry.location})` : "";
2930
4662
  return `[${entry.type}] ${entry.text}${location}`;
2931
4663
  });
2932
- fs9.writeFileSync(filePath, `${lines.join("\n")}
4664
+ fs12.writeFileSync(filePath, `${lines.join("\n")}
2933
4665
  `);
2934
4666
  return fileName;
2935
4667
  }
@@ -2937,8 +4669,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
2937
4669
  const headless = options.headed ? false : config.browser.headless;
2938
4670
  const slowMo = options.slowMo ?? config.browser.slowMo;
2939
4671
  const maxSteps = config.guardrails.maxSteps;
2940
- const runDir = path9.join(configDir, "runs", timestamp());
2941
- fs9.mkdirSync(runDir, { recursive: true });
4672
+ const runDir = path12.join(configDir, "runs", timestamp());
4673
+ fs12.mkdirSync(runDir, { recursive: true });
2942
4674
  const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
2943
4675
  const engine = options.browser ?? config.browser.engine;
2944
4676
  const channel = options.channel ?? config.browser.channel;
@@ -3055,7 +4787,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
3055
4787
  }
3056
4788
  return { result, runDir, steps: interpolatedHunt.steps };
3057
4789
  }
3058
- function delay(ms) {
4790
+ function delay3(ms) {
3059
4791
  return new Promise((resolve) => setTimeout(resolve, ms));
3060
4792
  }
3061
4793
  async function runHunt(options) {
@@ -3063,6 +4795,12 @@ async function runHunt(options) {
3063
4795
  if (config.target.type === "macos") {
3064
4796
  return runMacHunt(options, config, configDir, config.target);
3065
4797
  }
4798
+ if (config.target.type === "android") {
4799
+ return runAndroidHunt(options, config, configDir, config.target);
4800
+ }
4801
+ if (config.target.type === "ios") {
4802
+ return runIosHunt(options, config, configDir, config.target);
4803
+ }
3066
4804
  const hunt = loadHunt(options.huntName, configDir);
3067
4805
  const {
3068
4806
  hunt: interpolatedHunt,
@@ -3084,7 +4822,7 @@ async function runHunt(options) {
3084
4822
  let lastResult;
3085
4823
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
3086
4824
  if (attempt > 0 && retryDelay > 0) {
3087
- await delay(retryDelay);
4825
+ await delay3(retryDelay);
3088
4826
  }
3089
4827
  lastResult = await executeHuntAttempt(
3090
4828
  options,
@@ -3113,19 +4851,17 @@ async function runHunt(options) {
3113
4851
  }
3114
4852
  return lastResult;
3115
4853
  }
3116
- async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
4854
+ async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
3117
4855
  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
- });
4856
+ const runDir = path12.join(configDir, "runs", timestamp());
4857
+ fs12.mkdirSync(runDir, { recursive: true });
4858
+ const session = await native.launchSession();
3125
4859
  let result;
3126
4860
  try {
3127
- const targetLabel = `macos:${session.bundleId}`;
3128
- const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, target.app, session.bundleId])];
4861
+ const driver = native.sessionDriver(session);
4862
+ const appIdentity = native.sessionAppIdentity(session);
4863
+ const targetLabel = `${native.targetType}:${appIdentity}`;
4864
+ const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
3129
4865
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3130
4866
  const startTime = Date.now();
3131
4867
  let stepResults = [];
@@ -3133,7 +4869,8 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3133
4869
  let stepFailed = false;
3134
4870
  try {
3135
4871
  const stepExecution = await executeSteps({
3136
- driver: session.driver,
4872
+ driver,
4873
+ targetType: native.targetType,
3137
4874
  steps: interpolatedHunt.steps,
3138
4875
  targetUrl: targetLabel,
3139
4876
  runDir,
@@ -3160,7 +4897,7 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3160
4897
  }
3161
4898
  let finalScreenshot;
3162
4899
  try {
3163
- finalScreenshot = await captureFinalScreenshot(session.driver, runDir);
4900
+ finalScreenshot = await captureFinalScreenshot(driver, runDir);
3164
4901
  } catch {
3165
4902
  finalScreenshot = void 0;
3166
4903
  }
@@ -3181,16 +4918,116 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3181
4918
  });
3182
4919
  result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
3183
4920
  } finally {
3184
- await closeMacSession(session);
4921
+ await native.closeSession(session);
3185
4922
  }
3186
4923
  return { result, runDir, steps: interpolatedHunt.steps };
3187
4924
  }
4925
+ async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
4926
+ return executeNativeHuntAttempt(
4927
+ options,
4928
+ config,
4929
+ configDir,
4930
+ interpolatedHunt,
4931
+ redactedFillSteps,
4932
+ randomVars,
4933
+ allowedApps,
4934
+ {
4935
+ targetType: "macos",
4936
+ targetApp: target.app,
4937
+ launchSession: () => launchMacSession({
4938
+ app: target.app,
4939
+ timeoutMs: config.browser.timeout,
4940
+ clientFactory: options.macClientFactory
4941
+ }),
4942
+ closeSession: closeMacSession,
4943
+ sessionDriver: (session) => session.driver,
4944
+ sessionAppIdentity: (session) => session.bundleId
4945
+ }
4946
+ );
4947
+ }
3188
4948
  async function runMacHunt(options, config, configDir, target) {
4949
+ return runNativeHunt(options, config, configDir, target, {
4950
+ targetType: "macos",
4951
+ assertAppAllowed: (allowedApps, nativeTarget) => assertTargetAppAllowed(allowedApps, nativeTarget.app),
4952
+ attempt: executeMacHuntAttempt
4953
+ });
4954
+ }
4955
+ async function executeAndroidHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
4956
+ const launch = options.androidSessionFactory ?? launchAndroidSession;
4957
+ return executeNativeHuntAttempt(
4958
+ options,
4959
+ config,
4960
+ configDir,
4961
+ interpolatedHunt,
4962
+ redactedFillSteps,
4963
+ randomVars,
4964
+ allowedApps,
4965
+ {
4966
+ targetType: "android",
4967
+ targetApp: target.app,
4968
+ launchSession: () => launch({
4969
+ app: target.app,
4970
+ deviceSerial: target.deviceSerial,
4971
+ coldStart: target.coldStart,
4972
+ timeoutMs: config.browser.timeout,
4973
+ allowedApps
4974
+ }),
4975
+ closeSession: closeAndroidSession,
4976
+ sessionDriver: (session) => session.driver,
4977
+ sessionAppIdentity: (session) => session.package
4978
+ }
4979
+ );
4980
+ }
4981
+ async function runAndroidHunt(options, config, configDir, target) {
4982
+ return runNativeHunt(options, config, configDir, target, {
4983
+ targetType: "android",
4984
+ assertAppAllowed: (allowedApps, nativeTarget) => {
4985
+ if (!nativeTarget.app.toLowerCase().endsWith(".apk")) {
4986
+ assertAndroidAppAllowed(allowedApps, nativeTarget.app);
4987
+ }
4988
+ },
4989
+ attempt: executeAndroidHuntAttempt
4990
+ });
4991
+ }
4992
+ async function executeIosHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
4993
+ const launch = options.iosSessionFactory ?? launchIosSession;
4994
+ return executeNativeHuntAttempt(
4995
+ options,
4996
+ config,
4997
+ configDir,
4998
+ interpolatedHunt,
4999
+ redactedFillSteps,
5000
+ randomVars,
5001
+ allowedApps,
5002
+ {
5003
+ targetType: "ios",
5004
+ targetApp: target.app,
5005
+ launchSession: () => launch({
5006
+ app: target.app,
5007
+ udid: target.udid,
5008
+ coldStart: target.coldStart,
5009
+ timeoutMs: config.browser.timeout,
5010
+ allowedApps
5011
+ }),
5012
+ closeSession: closeIosSession,
5013
+ sessionDriver: (session) => session.driver,
5014
+ sessionAppIdentity: (session) => session.bundleId
5015
+ }
5016
+ );
5017
+ }
5018
+ async function runIosHunt(options, config, configDir, target) {
5019
+ return runNativeHunt(options, config, configDir, target, {
5020
+ targetType: "ios",
5021
+ assertAppAllowed: (allowedApps, nativeTarget) => assertIosAppAllowed(allowedApps, nativeTarget.app),
5022
+ attempt: executeIosHuntAttempt
5023
+ });
5024
+ }
5025
+ async function runNativeHunt(options, config, configDir, target, native) {
3189
5026
  const hunt = loadHunt(options.huntName, configDir);
3190
5027
  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);
5028
+ assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
5029
+ assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
5030
+ native.assertAppAllowed(config.guardrails.allowedApps, target);
3194
5031
  const maxSteps = config.guardrails.maxSteps;
3195
5032
  if (interpolatedHunt.steps.length > maxSteps) {
3196
5033
  throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
@@ -3200,9 +5037,9 @@ async function runMacHunt(options, config, configDir, target) {
3200
5037
  let lastResult;
3201
5038
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
3202
5039
  if (attempt > 0 && retryDelay > 0) {
3203
- await delay(retryDelay);
5040
+ await delay3(retryDelay);
3204
5041
  }
3205
- lastResult = await executeMacHuntAttempt(
5042
+ lastResult = await native.attempt(
3206
5043
  options,
3207
5044
  config,
3208
5045
  configDir,
@@ -3230,7 +5067,7 @@ async function runMacHunt(options, config, configDir, target) {
3230
5067
  }
3231
5068
  function recordHistory(configDir, outcome, maxRuns) {
3232
5069
  try {
3233
- const relativeRunDir = path9.relative(configDir, outcome.runDir);
5070
+ const relativeRunDir = path12.relative(configDir, outcome.runDir);
3234
5071
  appendEntry(
3235
5072
  configDir,
3236
5073
  {
@@ -3351,8 +5188,8 @@ function clusterFailures(failures) {
3351
5188
  }
3352
5189
 
3353
5190
  // src/backlog/index.ts
3354
- import fs10 from "fs";
3355
- import path10 from "path";
5191
+ import fs13 from "fs";
5192
+ import path13 from "path";
3356
5193
 
3357
5194
  // src/backlog/parse.ts
3358
5195
  var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
@@ -3442,7 +5279,7 @@ ${after}`;
3442
5279
  // src/backlog/index.ts
3443
5280
  function readFileOrEmpty(filePath) {
3444
5281
  try {
3445
- return fs10.readFileSync(filePath, "utf-8");
5282
+ return fs13.readFileSync(filePath, "utf-8");
3446
5283
  } catch (error) {
3447
5284
  const err = error;
3448
5285
  if (err.code === "ENOENT") return "";
@@ -3458,7 +5295,7 @@ function buildFailure(hunt) {
3458
5295
  if (!hunt.runDir) return failure;
3459
5296
  let run;
3460
5297
  try {
3461
- const resultJson = readFileOrEmpty(path10.join(hunt.runDir, "result.json"));
5298
+ const resultJson = readFileOrEmpty(path13.join(hunt.runDir, "result.json"));
3462
5299
  if (!resultJson) return failure;
3463
5300
  run = JSON.parse(resultJson);
3464
5301
  } catch (error) {
@@ -3487,8 +5324,8 @@ function extractFailures(suiteResult) {
3487
5324
  }
3488
5325
  function updateBacklogFromSuite(suiteResult, options = {}) {
3489
5326
  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");
5327
+ const backlogPath = options.backlogPath ?? path13.join(projectRoot, "docs", "backlog.md");
5328
+ const resolvedPath = options.resolvedPath ?? path13.join(projectRoot, "docs", "resolved.md");
3492
5329
  const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
3493
5330
  const summary = { created: [], regressions: [], skipped: [], backlogPath };
3494
5331
  const failures = extractFailures(suiteResult);
@@ -3520,18 +5357,18 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
3520
5357
  }
3521
5358
  }
3522
5359
  if (ticketsToAdd.length > 0) {
3523
- fs10.mkdirSync(path10.dirname(backlogPath), { recursive: true });
3524
- fs10.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
5360
+ fs13.mkdirSync(path13.dirname(backlogPath), { recursive: true });
5361
+ fs13.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
3525
5362
  }
3526
5363
  return summary;
3527
5364
  }
3528
5365
 
3529
5366
  // src/runner/suite.ts
3530
- import path12 from "path";
5367
+ import path15 from "path";
3531
5368
 
3532
5369
  // src/reporter/ci-summary.ts
3533
- import fs11 from "fs";
3534
- import path11 from "path";
5370
+ import fs14 from "fs";
5371
+ import path14 from "path";
3535
5372
  import chalk from "chalk";
3536
5373
  function countCiResults(results) {
3537
5374
  return {
@@ -3595,9 +5432,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
3595
5432
  ...flaky.length > 0 ? { flaky } : {},
3596
5433
  ...clusters.length > 0 ? { clusters } : {}
3597
5434
  };
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");
5435
+ fs14.mkdirSync(ciRunDir, { recursive: true });
5436
+ const filePath = path14.join(ciRunDir, "ci-result.json");
5437
+ fs14.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
3601
5438
  return filePath;
3602
5439
  }
3603
5440
 
@@ -3782,7 +5619,7 @@ async function runSuite(options = {}) {
3782
5619
  const clusters = clusterFailures(
3783
5620
  extractFailures({ result: { hunts: results }, resultPath: null })
3784
5621
  ).filter((cluster) => cluster.count > 1);
3785
- const ciRunDir = path12.join(configDir, "runs", timestamp("ci"));
5622
+ const ciRunDir = path15.join(configDir, "runs", timestamp("ci"));
3786
5623
  const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
3787
5624
  const { passed, failed, skipped } = countCiResults(results);
3788
5625
  return {
@@ -3917,6 +5754,134 @@ async function analyzePage(page) {
3917
5754
  };
3918
5755
  }
3919
5756
 
5757
+ // src/analyzer/mac.ts
5758
+ var INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
5759
+ "AXButton",
5760
+ "AXTextField",
5761
+ "AXSecureTextField",
5762
+ "AXTextArea",
5763
+ "AXCheckBox",
5764
+ "AXRadioButton",
5765
+ "AXPopUpButton",
5766
+ "AXMenuButton",
5767
+ "AXLink",
5768
+ "AXMenuItem",
5769
+ "AXComboBox",
5770
+ "AXSlider",
5771
+ "AXDisclosureTriangle"
5772
+ ]);
5773
+ var DEFAULT_ANALYZE_TREE_DEPTH = 20;
5774
+ function str(value) {
5775
+ if (typeof value !== "string") {
5776
+ return void 0;
5777
+ }
5778
+ const trimmed = value.trim();
5779
+ return trimmed.length > 0 ? trimmed : void 0;
5780
+ }
5781
+ function toNode(raw) {
5782
+ const node = raw ?? {};
5783
+ const children = Array.isArray(node.children) ? node.children.map(toNode) : void 0;
5784
+ return {
5785
+ role: str(node.role),
5786
+ title: str(node.title),
5787
+ description: str(node.description),
5788
+ value: str(node.value),
5789
+ identifier: str(node.identifier),
5790
+ enabled: typeof node.enabled === "boolean" ? node.enabled : void 0,
5791
+ ...children ? { children } : {}
5792
+ };
5793
+ }
5794
+ function quote(value) {
5795
+ return `"${value}"`;
5796
+ }
5797
+ function rankMacSelectors(node) {
5798
+ const selectors = [];
5799
+ const identifier = node.identifier;
5800
+ const exactLabel = node.title ?? node.description;
5801
+ const name = node.title ?? node.description ?? node.value;
5802
+ if (identifier) {
5803
+ selectors.push(`id=${identifier}`);
5804
+ }
5805
+ if (exactLabel) {
5806
+ selectors.push(`label=${quote(exactLabel)}`);
5807
+ }
5808
+ if (node.role && name) {
5809
+ selectors.push(`role=${node.role}[name=${quote(name)}]`);
5810
+ }
5811
+ if (name) {
5812
+ selectors.push(`text=${quote(name)}`);
5813
+ }
5814
+ if (selectors.length === 0 && node.role) {
5815
+ selectors.push(`role=${node.role}`);
5816
+ }
5817
+ return selectors;
5818
+ }
5819
+ function toElement(node, source) {
5820
+ return {
5821
+ role: node.role ?? "?",
5822
+ ...node.title ? { title: node.title } : {},
5823
+ ...node.description ? { description: node.description } : {},
5824
+ ...node.value ? { value: node.value } : {},
5825
+ ...node.identifier ? { identifier: node.identifier } : {},
5826
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
5827
+ source,
5828
+ selectors: rankMacSelectors(node)
5829
+ };
5830
+ }
5831
+ function collectInteractive(root) {
5832
+ const out = [];
5833
+ const visit = (node) => {
5834
+ if (node.role && INTERACTIVE_ROLES.has(node.role)) {
5835
+ out.push(toElement(node, "window"));
5836
+ }
5837
+ for (const child of node.children ?? []) {
5838
+ visit(child);
5839
+ }
5840
+ };
5841
+ visit(root);
5842
+ return out;
5843
+ }
5844
+ function toWindow(node) {
5845
+ const [best] = rankMacSelectors(node);
5846
+ return {
5847
+ ...node.title ? { title: node.title } : {},
5848
+ ...node.identifier ? { identifier: node.identifier } : {},
5849
+ selector: best ?? "role=AXWindow"
5850
+ };
5851
+ }
5852
+ async function analyzeMacApp(client, options) {
5853
+ const depth = options.treeDepth ?? DEFAULT_ANALYZE_TREE_DEPTH;
5854
+ const treeResult = await client.request("tree", { depth });
5855
+ const elements = collectInteractive(toNode(treeResult.tree));
5856
+ const windowsResult = await client.request("windows");
5857
+ const rawWindows = Array.isArray(windowsResult.windows) ? windowsResult.windows : [];
5858
+ const windows = rawWindows.map((raw) => toWindow(toNode(raw)));
5859
+ const menuItems = await readStatusMenu(client, options.menuTimeoutSeconds);
5860
+ return { app: options.app, elements, windows, menuItems };
5861
+ }
5862
+ async function readStatusMenu(client, menuTimeoutSeconds) {
5863
+ let statusItems;
5864
+ try {
5865
+ const status = await client.request("statusItems");
5866
+ statusItems = Array.isArray(status.items) ? status.items : [];
5867
+ } catch {
5868
+ return [];
5869
+ }
5870
+ if (statusItems.length === 0) {
5871
+ return [];
5872
+ }
5873
+ const params = menuTimeoutSeconds !== void 0 ? { timeout: menuTimeoutSeconds } : {};
5874
+ try {
5875
+ const menu = await client.request("openMenu", params);
5876
+ const rawItems = Array.isArray(menu.items) ? menu.items : [];
5877
+ return rawItems.map((raw) => toNode(raw)).filter((node) => node.role !== "AXMenuItem" || node.title || node.identifier || node.description).map((node) => toElement(node, "menu"));
5878
+ } catch {
5879
+ return [];
5880
+ } finally {
5881
+ await client.request("closeMenu").catch(() => void 0);
5882
+ }
5883
+ }
5884
+
3920
5885
  // src/generator/index.ts
3921
5886
  import yaml from "yaml";
3922
5887
 
@@ -4141,7 +6106,12 @@ export {
4141
6106
  WEB_ONLY_STEP_TYPES,
4142
6107
  webOnlyReason,
4143
6108
  assertStepsSupportedByTarget,
6109
+ readIosBundleIdentifier,
6110
+ iosAppAllowedIdentities,
4144
6111
  assertTargetAppAllowed,
6112
+ assertIosAppAllowed,
6113
+ androidAppAllowedIdentities,
6114
+ assertAndroidAppAllowed,
4145
6115
  launchBrowser,
4146
6116
  closeBrowser,
4147
6117
  saveStorageState,
@@ -4154,6 +6124,59 @@ export {
4154
6124
  SpawnMacHelperClient,
4155
6125
  launchMacSession,
4156
6126
  closeMacSession,
6127
+ ANDROID_KEYCODES,
6128
+ escapeUiSelectorArg,
6129
+ parseAndroidSelector,
6130
+ androidQueryToLocator,
6131
+ unwrapAndroidTextSelector,
6132
+ createAndroidDriver,
6133
+ parseAdbDevices,
6134
+ bootedDevices,
6135
+ selectDeviceSerial,
6136
+ listDevices,
6137
+ parseForwardPort,
6138
+ parseAaptPackage,
6139
+ DEFAULT_AGENT_REQUEST_TIMEOUT_MS,
6140
+ Uia2HttpError,
6141
+ Uia2Transport,
6142
+ extractElementId,
6143
+ createUia2Session,
6144
+ waitForAgentReady,
6145
+ createUia2AgentClient,
6146
+ UIA2_REMOTE_PORT,
6147
+ resolveAgentApks,
6148
+ defaultAgentConnector,
6149
+ launchAndroidSession,
6150
+ closeAndroidSession,
6151
+ IOS_PRESS_KEYS,
6152
+ escapePredicateArg,
6153
+ normalizeXcuiClassName,
6154
+ parseIosSelector,
6155
+ iosQueryToLocator,
6156
+ unwrapIosTextSelector,
6157
+ createIosDriver,
6158
+ DEFAULT_SIMULATOR_LOCK_ROOT,
6159
+ reserveSimulatorUdid,
6160
+ findFreePort,
6161
+ parseSimctlDevices,
6162
+ bootedSimulators,
6163
+ selectSimulatorUdid,
6164
+ listSimulators,
6165
+ parseXcodeVersion,
6166
+ DEFAULT_WDA_REQUEST_TIMEOUT_MS,
6167
+ WdaHttpError,
6168
+ WdaTransport,
6169
+ createWdaSession,
6170
+ waitForWdaReady,
6171
+ createWdaAgentClient,
6172
+ WDA_RUNNER_BUNDLE_ID,
6173
+ WDA_RUNNER_APP_NAME,
6174
+ defaultIosAgentConnector,
6175
+ resolveWdaProject,
6176
+ wdaCacheDir,
6177
+ resolveWdaRunner,
6178
+ launchIosSession,
6179
+ closeIosSession,
4157
6180
  extractSelectorIntent,
4158
6181
  buildHealCandidates,
4159
6182
  healSelector,
@@ -4170,6 +6193,10 @@ export {
4170
6193
  runSuite,
4171
6194
  parseBrowserEngine,
4172
6195
  analyzePage,
6196
+ INTERACTIVE_ROLES,
6197
+ DEFAULT_ANALYZE_TREE_DEPTH,
6198
+ rankMacSelectors,
6199
+ analyzeMacApp,
4173
6200
  generateHunt
4174
6201
  };
4175
- //# sourceMappingURL=chunk-ZEFVTKQT.js.map
6202
+ //# sourceMappingURL=chunk-2KD2XCTH.js.map