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.
package/dist/lib.cjs CHANGED
@@ -37,12 +37,12 @@ __export(visual_exports, {
37
37
  ensureBaselineDir: () => ensureBaselineDir
38
38
  });
39
39
  async function compareScreenshots(baselinePath, currentPath, diffPath, threshold) {
40
- const baselineData = import_pngjs.PNG.sync.read(import_node_fs5.default.readFileSync(baselinePath));
41
- const currentData = import_pngjs.PNG.sync.read(import_node_fs5.default.readFileSync(currentPath));
40
+ const baselineData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(baselinePath));
41
+ const currentData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(currentPath));
42
42
  const { width, height } = baselineData;
43
43
  if (currentData.width !== width || currentData.height !== height) {
44
44
  const diff2 = new import_pngjs.PNG({ width, height });
45
- import_node_fs5.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
45
+ import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
46
46
  return {
47
47
  match: false,
48
48
  diffPercentage: 1,
@@ -60,7 +60,7 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
60
60
  );
61
61
  const totalPixels = width * height;
62
62
  const diffPercentage = diffPixels / totalPixels;
63
- import_node_fs5.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
63
+ import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
64
64
  return {
65
65
  match: diffPercentage <= threshold,
66
66
  diffPercentage,
@@ -68,16 +68,16 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
68
68
  };
69
69
  }
70
70
  function ensureBaselineDir(configDir) {
71
- const baselineDir = import_node_path5.default.join(configDir, "baselines");
72
- import_node_fs5.default.mkdirSync(baselineDir, { recursive: true });
71
+ const baselineDir = import_node_path8.default.join(configDir, "baselines");
72
+ import_node_fs8.default.mkdirSync(baselineDir, { recursive: true });
73
73
  return baselineDir;
74
74
  }
75
- var import_node_fs5, import_node_path5, import_pngjs, import_pixelmatch;
75
+ var import_node_fs8, import_node_path8, import_pngjs, import_pixelmatch;
76
76
  var init_visual = __esm({
77
77
  "src/runner/visual.ts"() {
78
78
  "use strict";
79
- import_node_fs5 = __toESM(require("fs"), 1);
80
- import_node_path5 = __toESM(require("path"), 1);
79
+ import_node_fs8 = __toESM(require("fs"), 1);
80
+ import_node_path8 = __toESM(require("path"), 1);
81
81
  import_pngjs = require("pngjs");
82
82
  import_pixelmatch = __toESM(require("pixelmatch"), 1);
83
83
  }
@@ -86,41 +86,103 @@ var init_visual = __esm({
86
86
  // src/index.ts
87
87
  var src_exports = {};
88
88
  __export(src_exports, {
89
+ ANDROID_KEYCODES: () => ANDROID_KEYCODES,
90
+ DEFAULT_AGENT_REQUEST_TIMEOUT_MS: () => DEFAULT_AGENT_REQUEST_TIMEOUT_MS,
91
+ DEFAULT_ANALYZE_TREE_DEPTH: () => DEFAULT_ANALYZE_TREE_DEPTH,
89
92
  DEFAULT_FLAKY_THRESHOLD: () => DEFAULT_FLAKY_THRESHOLD,
90
93
  DEFAULT_REQUEST_TIMEOUT_MS: () => DEFAULT_REQUEST_TIMEOUT_MS,
94
+ DEFAULT_SIMULATOR_LOCK_ROOT: () => DEFAULT_SIMULATOR_LOCK_ROOT,
95
+ DEFAULT_WDA_REQUEST_TIMEOUT_MS: () => DEFAULT_WDA_REQUEST_TIMEOUT_MS,
96
+ INTERACTIVE_ROLES: () => INTERACTIVE_ROLES,
97
+ IOS_PRESS_KEYS: () => IOS_PRESS_KEYS,
91
98
  SpawnMacHelperClient: () => SpawnMacHelperClient,
99
+ UIA2_REMOTE_PORT: () => UIA2_REMOTE_PORT,
100
+ Uia2HttpError: () => Uia2HttpError,
101
+ Uia2Transport: () => Uia2Transport,
102
+ WDA_RUNNER_APP_NAME: () => WDA_RUNNER_APP_NAME,
103
+ WDA_RUNNER_BUNDLE_ID: () => WDA_RUNNER_BUNDLE_ID,
92
104
  WEB_ONLY_STEP_TYPES: () => WEB_ONLY_STEP_TYPES,
105
+ WdaHttpError: () => WdaHttpError,
106
+ WdaTransport: () => WdaTransport,
107
+ analyzeMacApp: () => analyzeMacApp,
93
108
  analyzePage: () => analyzePage,
109
+ androidAppAllowedIdentities: () => androidAppAllowedIdentities,
110
+ androidQueryToLocator: () => androidQueryToLocator,
111
+ assertAndroidAppAllowed: () => assertAndroidAppAllowed,
112
+ assertIosAppAllowed: () => assertIosAppAllowed,
94
113
  assertStepsSupportedByTarget: () => assertStepsSupportedByTarget,
95
114
  assertTargetAppAllowed: () => assertTargetAppAllowed,
115
+ bootedDevices: () => bootedDevices,
116
+ bootedSimulators: () => bootedSimulators,
96
117
  buildHealCandidates: () => buildHealCandidates,
118
+ closeAndroidSession: () => closeAndroidSession,
119
+ closeIosSession: () => closeIosSession,
97
120
  closeMacSession: () => closeMacSession,
98
121
  clusterFailures: () => clusterFailures,
99
122
  computeFlakeScore: () => computeFlakeScore,
100
123
  configSchema: () => configSchema,
124
+ createAndroidDriver: () => createAndroidDriver,
125
+ createIosDriver: () => createIosDriver,
101
126
  createMacDriver: () => createMacDriver,
127
+ createUia2AgentClient: () => createUia2AgentClient,
128
+ createUia2Session: () => createUia2Session,
129
+ createWdaAgentClient: () => createWdaAgentClient,
130
+ createWdaSession: () => createWdaSession,
131
+ defaultAgentConnector: () => defaultAgentConnector,
132
+ defaultIosAgentConnector: () => defaultIosAgentConnector,
133
+ escapePredicateArg: () => escapePredicateArg,
134
+ escapeUiSelectorArg: () => escapeUiSelectorArg,
135
+ extractElementId: () => extractElementId,
102
136
  extractFailures: () => extractFailures,
103
137
  extractSelectorIntent: () => extractSelectorIntent,
138
+ findFreePort: () => findFreePort,
104
139
  generateHunt: () => generateHunt,
105
140
  healSelector: () => healSelector,
106
141
  huntSchema: () => huntSchema,
107
142
  interpolateHunt: () => interpolateHunt,
143
+ iosAppAllowedIdentities: () => iosAppAllowedIdentities,
144
+ iosQueryToLocator: () => iosQueryToLocator,
145
+ launchAndroidSession: () => launchAndroidSession,
146
+ launchIosSession: () => launchIosSession,
108
147
  launchMacSession: () => launchMacSession,
148
+ listDevices: () => listDevices,
109
149
  listHunts: () => listHunts,
150
+ listSimulators: () => listSimulators,
110
151
  loadConfig: () => loadConfig,
111
152
  loadHunt: () => loadHunt,
112
153
  loadHuntMeta: () => loadHuntMeta,
113
154
  loadHuntTags: () => loadHuntTags,
114
155
  macdriverBuildInstructions: () => macdriverBuildInstructions,
156
+ normalizeXcuiClassName: () => normalizeXcuiClassName,
157
+ parseAaptPackage: () => parseAaptPackage,
158
+ parseAdbDevices: () => parseAdbDevices,
159
+ parseAndroidSelector: () => parseAndroidSelector,
160
+ parseForwardPort: () => parseForwardPort,
161
+ parseIosSelector: () => parseIosSelector,
115
162
  parseMacSelector: () => parseMacSelector,
163
+ parseSimctlDevices: () => parseSimctlDevices,
164
+ parseXcodeVersion: () => parseXcodeVersion,
116
165
  rankFlaky: () => rankFlaky,
166
+ rankMacSelectors: () => rankMacSelectors,
117
167
  readHistory: () => readHistory,
118
168
  readHuntHistory: () => readHuntHistory,
169
+ readIosBundleIdentifier: () => readIosBundleIdentifier,
170
+ reserveSimulatorUdid: () => reserveSimulatorUdid,
171
+ resolveAgentApks: () => resolveAgentApks,
119
172
  resolveHelperBinary: () => resolveHelperBinary,
173
+ resolveWdaProject: () => resolveWdaProject,
174
+ resolveWdaRunner: () => resolveWdaRunner,
120
175
  runHunt: () => runHunt,
121
176
  runSuite: () => runSuite,
177
+ selectDeviceSerial: () => selectDeviceSerial,
178
+ selectSimulatorUdid: () => selectSimulatorUdid,
122
179
  stepSchema: () => stepSchema,
180
+ unwrapAndroidTextSelector: () => unwrapAndroidTextSelector,
181
+ unwrapIosTextSelector: () => unwrapIosTextSelector,
123
182
  updateBacklogFromSuite: () => updateBacklogFromSuite,
183
+ waitForAgentReady: () => waitForAgentReady,
184
+ waitForWdaReady: () => waitForWdaReady,
185
+ wdaCacheDir: () => wdaCacheDir,
124
186
  webOnlyReason: () => webOnlyReason
125
187
  });
126
188
  module.exports = __toCommonJS(src_exports);
@@ -273,7 +335,11 @@ function createMacDriver(client, options = {}) {
273
335
  return rejectUnsupported("evalScript");
274
336
  },
275
337
  async screenshot(screenshotOptions) {
276
- await client.request("screenshot", { path: screenshotOptions.path });
338
+ const result = await client.request("screenshot", { path: screenshotOptions.path });
339
+ const warning = result.warning;
340
+ if (typeof warning === "string" && warning.length > 0) {
341
+ console.warn(`macOS screenshot fell back to full screen: ${warning}`);
342
+ }
277
343
  },
278
344
  // network / dialogs / downloads (all web-only) -------------------------
279
345
  onResponse(_handler) {
@@ -504,147 +570,1881 @@ async function closeMacSession(session) {
504
570
  }
505
571
  }
506
572
 
507
- // src/config/target.ts
508
- var import_node_child_process2 = require("child_process");
573
+ // src/browser/android-driver.ts
509
574
  var import_node_fs2 = __toESM(require("fs"), 1);
510
- var import_node_path2 = __toESM(require("path"), 1);
511
- var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
512
- "navigate",
513
- "waitForUrl",
514
- "waitForNetworkIdle",
515
- "mockRoute",
516
- "unmockRoute",
517
- "evalScript",
518
- "runScript",
519
- "onDialog",
520
- "select",
521
- "selectOption",
522
- "setInputFiles",
523
- "waitForDownload",
524
- "scroll"
525
- // directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
575
+ var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
576
+ "query",
577
+ "interact",
578
+ "wait",
579
+ "screenshot"
526
580
  ]);
527
- function webOnlyReason(step) {
528
- for (const type of WEB_ONLY_STEP_TYPES) {
529
- if (type in step) {
530
- return type;
581
+ var WAIT_POLL_INTERVAL_MS = 250;
582
+ var DEFAULT_WAIT_TIMEOUT_MS = 5e3;
583
+ var ANDROID_KEYCODES = {
584
+ enter: 66,
585
+ return: 66,
586
+ tab: 61,
587
+ space: 62,
588
+ backspace: 67,
589
+ delete: 67,
590
+ del: 67,
591
+ escape: 111,
592
+ esc: 111,
593
+ back: 4,
594
+ home: 3,
595
+ menu: 82,
596
+ search: 84,
597
+ up: 19,
598
+ arrowup: 19,
599
+ down: 20,
600
+ arrowdown: 20,
601
+ left: 21,
602
+ arrowleft: 21,
603
+ right: 22,
604
+ arrowright: 22,
605
+ pageup: 92,
606
+ pagedown: 93
607
+ };
608
+ function unquote2(value) {
609
+ const trimmed = value.trim();
610
+ const first = trimmed[0];
611
+ if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
612
+ return trimmed.slice(1, -1);
613
+ }
614
+ return trimmed;
615
+ }
616
+ function escapeUiSelectorArg(value) {
617
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
618
+ }
619
+ function parseAndroidSelector(selector) {
620
+ const trimmed = selector.trim();
621
+ if (trimmed === ":focus") {
622
+ return { by: "focused" };
623
+ }
624
+ const idMatch = /^id=(.+)$/s.exec(trimmed);
625
+ if (idMatch) {
626
+ return { by: "id", value: unquote2(idMatch[1]) };
627
+ }
628
+ const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
629
+ if (roleMatch) {
630
+ const name = roleMatch[2] !== void 0 ? unquote2(roleMatch[2]) : void 0;
631
+ return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
632
+ }
633
+ const labelMatch = /^label=(.+)$/s.exec(trimmed);
634
+ if (labelMatch) {
635
+ return { by: "accessibilityId", value: unquote2(labelMatch[1]) };
636
+ }
637
+ const textMatch = /^text=(.+)$/s.exec(trimmed);
638
+ if (textMatch) {
639
+ return { by: "text", value: unquote2(textMatch[1]) };
640
+ }
641
+ return { by: "text", value: trimmed };
642
+ }
643
+ function locator(strategy, selector) {
644
+ return { strategy, selector, context: "" };
645
+ }
646
+ function qualifyResourceId(value, appPackage) {
647
+ if (value.includes(":") || !appPackage) {
648
+ return value;
649
+ }
650
+ return `${appPackage}:id/${value}`;
651
+ }
652
+ function androidQueryToLocator(query, options = {}) {
653
+ switch (query.by) {
654
+ case "id":
655
+ return locator("id", qualifyResourceId(query.value, options.appPackage));
656
+ case "accessibilityId":
657
+ return locator("accessibility id", query.value);
658
+ case "text":
659
+ return locator(
660
+ "-android uiautomator",
661
+ `new UiSelector().textContains("${escapeUiSelectorArg(query.value)}")`
662
+ );
663
+ case "focused":
664
+ return locator("-android uiautomator", "new UiSelector().focused(true)");
665
+ case "role": {
666
+ const className = escapeUiSelectorArg(query.role);
667
+ if (query.name === void 0 || query.name.length === 0) {
668
+ return locator("class name", query.role);
669
+ }
670
+ return locator(
671
+ "-android uiautomator",
672
+ `new UiSelector().className("${className}").textContains("${escapeUiSelectorArg(query.name)}")`
673
+ );
531
674
  }
532
675
  }
533
- if ("assert" in step) {
534
- const assertion = step.assert;
535
- if (assertion.urlIncludes !== void 0 || assertion.urlEquals !== void 0) {
536
- return "assert (url)";
676
+ }
677
+ function unwrapAndroidTextSelector(selector) {
678
+ const trimmed = selector.trim();
679
+ if (!trimmed.startsWith("text=")) {
680
+ return null;
681
+ }
682
+ return unquote2(trimmed.slice(5));
683
+ }
684
+ function keyCodeFor(key) {
685
+ const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
686
+ if (code === void 0) {
687
+ throw new Error(
688
+ `Unsupported key "${key}" for the Android target. Supported keys: ${Object.keys(ANDROID_KEYCODES).sort().join(", ")}.`
689
+ );
690
+ }
691
+ return code;
692
+ }
693
+ function delay(ms) {
694
+ return new Promise((resolve) => {
695
+ setTimeout(resolve, ms);
696
+ });
697
+ }
698
+ function createAndroidDriver(client, options = {}) {
699
+ const unsupported = (verb) => new Error(`${verb} is not supported by the Android target`);
700
+ const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
701
+ async function resolveOne(selector) {
702
+ const id = await client.findElement(parseAndroidSelector(selector));
703
+ if (id === null) {
704
+ throw new Error(`No element matched selector: ${selector}`);
537
705
  }
706
+ return id;
538
707
  }
539
- return null;
708
+ async function clickSelector(selector) {
709
+ await client.click(await resolveOne(selector));
710
+ }
711
+ async function fillSelector(selector, value) {
712
+ await client.setValue(await resolveOne(selector), value);
713
+ }
714
+ return {
715
+ capabilities: ANDROID_CAPABILITIES,
716
+ // navigation -----------------------------------------------------------
717
+ goto(_url, _options) {
718
+ return rejectUnsupported("navigate");
719
+ },
720
+ currentUrl() {
721
+ return `android:${options.appLabel ?? ""}`;
722
+ },
723
+ // queries --------------------------------------------------------------
724
+ async count(selector) {
725
+ return (await client.findElements(parseAndroidSelector(selector))).length;
726
+ },
727
+ async textContent(selector) {
728
+ const id = await client.findElement(parseAndroidSelector(selector));
729
+ if (id === null) {
730
+ return null;
731
+ }
732
+ return client.getText(id);
733
+ },
734
+ // interactions ---------------------------------------------------------
735
+ click: clickSelector,
736
+ clickFirst: clickSelector,
737
+ fill: fillSelector,
738
+ fillFirst: fillSelector,
739
+ async press(_selector, key) {
740
+ await client.pressKeyCode(keyCodeFor(key));
741
+ },
742
+ selectOption() {
743
+ return rejectUnsupported("select");
744
+ },
745
+ selectOptionFirst() {
746
+ return rejectUnsupported("select");
747
+ },
748
+ hover() {
749
+ return rejectUnsupported("hover");
750
+ },
751
+ scrollIntoView() {
752
+ return rejectUnsupported("scrollTo");
753
+ },
754
+ setInputFiles() {
755
+ return rejectUnsupported("setInputFiles");
756
+ },
757
+ // semantic locators ----------------------------------------------------
758
+ async countByRole(role, name) {
759
+ return (await client.findElements({ by: "role", role, name })).length;
760
+ },
761
+ async clickFirstByRole(role, name) {
762
+ const id = await client.findElement({ by: "role", role, name });
763
+ if (id === null) {
764
+ throw new Error(`No element matched role=${role}[name="${name}"]`);
765
+ }
766
+ await client.click(id);
767
+ },
768
+ async countByLabel(label) {
769
+ return (await client.findElements({ by: "accessibilityId", value: label })).length;
770
+ },
771
+ async fillFirstByLabel(label, value) {
772
+ const id = await client.findElement({ by: "accessibilityId", value: label });
773
+ if (id === null) {
774
+ throw new Error(`No element matched label="${label}"`);
775
+ }
776
+ await client.setValue(id, value);
777
+ },
778
+ selectOptionFirstByLabel() {
779
+ return rejectUnsupported("select");
780
+ },
781
+ // waiting --------------------------------------------------------------
782
+ async waitForSelector(selector, waitOptions) {
783
+ const query = parseAndroidSelector(selector);
784
+ const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS;
785
+ const deadline = Date.now() + timeoutMs;
786
+ for (; ; ) {
787
+ if ((await client.findElements(query)).length > 0) {
788
+ return;
789
+ }
790
+ if (Date.now() >= deadline) {
791
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
792
+ }
793
+ await delay(Math.min(WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
794
+ }
795
+ },
796
+ waitForUrl() {
797
+ return rejectUnsupported("waitForUrl");
798
+ },
799
+ waitForNetworkIdle() {
800
+ return rejectUnsupported("waitForNetworkIdle");
801
+ },
802
+ // scripting & artifacts ------------------------------------------------
803
+ evaluate() {
804
+ return rejectUnsupported("evalScript");
805
+ },
806
+ async screenshot(screenshotOptions) {
807
+ const png = await client.screenshotPng();
808
+ import_node_fs2.default.writeFileSync(screenshotOptions.path, png);
809
+ },
810
+ // network / dialogs / downloads (all web-only) -------------------------
811
+ onResponse(_handler) {
812
+ throw unsupported("onResponse");
813
+ },
814
+ route(_url, _handler) {
815
+ return rejectUnsupported("mockRoute");
816
+ },
817
+ unroute() {
818
+ return rejectUnsupported("unmockRoute");
819
+ },
820
+ onDialog(_action) {
821
+ throw unsupported("onDialog");
822
+ },
823
+ waitForDownloadEvent() {
824
+ return rejectUnsupported("waitForDownload");
825
+ },
826
+ parseTextSelector(selector) {
827
+ return unwrapAndroidTextSelector(selector);
828
+ }
829
+ };
540
830
  }
541
- function assertStepsSupportedByTarget(steps, target) {
542
- if (target !== "macos") {
543
- return;
831
+
832
+ // src/browser/android-helper.ts
833
+ var import_node_module = require("module");
834
+ var import_node_child_process4 = require("child_process");
835
+ var import_node_fs4 = __toESM(require("fs"), 1);
836
+ var import_node_path3 = __toESM(require("path"), 1);
837
+
838
+ // src/browser/android-adb.ts
839
+ var import_node_child_process2 = require("child_process");
840
+ var execFileAdbRunner = (args, options) => new Promise((resolve) => {
841
+ (0, import_node_child_process2.execFile)(
842
+ "adb",
843
+ args,
844
+ { encoding: "utf-8", timeout: options?.timeoutMs, maxBuffer: 16 * 1024 * 1024 },
845
+ (error, stdout, stderr) => {
846
+ const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
847
+ const capturedStderr = stderr ?? "";
848
+ resolve({
849
+ stdout: stdout ?? "",
850
+ stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
851
+ code
852
+ });
853
+ }
854
+ );
855
+ });
856
+ var spawnAdbProcess = (args) => {
857
+ const child = (0, import_node_child_process2.spawn)("adb", args, { stdio: "ignore" });
858
+ child.on("error", () => {
859
+ });
860
+ return { kill: () => child.kill() };
861
+ };
862
+ function withSerial(serial, args) {
863
+ return serial ? ["-s", serial, ...args] : args;
864
+ }
865
+ function parseAdbDevices(stdout) {
866
+ const devices = [];
867
+ for (const rawLine of stdout.split(/\r?\n/)) {
868
+ const line = rawLine.trim();
869
+ if (!line || /^list of devices attached/i.test(line)) {
870
+ continue;
871
+ }
872
+ const [serial, state, ...rest] = line.split(/\s+/);
873
+ if (!serial || !state) {
874
+ continue;
875
+ }
876
+ const description = {};
877
+ for (const token of rest) {
878
+ const eq = token.indexOf(":");
879
+ if (eq > 0) {
880
+ description[token.slice(0, eq)] = token.slice(eq + 1);
881
+ }
882
+ }
883
+ devices.push({ serial, state, description });
544
884
  }
545
- for (const step of steps) {
546
- const reason = webOnlyReason(step);
547
- if (reason) {
885
+ return devices;
886
+ }
887
+ function bootedDevices(devices) {
888
+ return devices.filter((device) => device.state === "device");
889
+ }
890
+ function selectDeviceSerial(devices, requested) {
891
+ const booted = bootedDevices(devices);
892
+ if (requested) {
893
+ const match = devices.find((device) => device.serial === requested);
894
+ if (!match) {
895
+ const attached = devices.length > 0 ? devices.map((d) => d.serial).join(", ") : "none";
548
896
  throw new Error(
549
- `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.).`
897
+ `Android device "${requested}" is not attached. Attached devices: ${attached}. Check \`adb devices -l\`.`
550
898
  );
551
899
  }
552
- if ("if" in step) {
553
- assertStepsSupportedByTarget(step.if.then, target);
554
- if (step.if.else) {
555
- assertStepsSupportedByTarget(step.if.else, target);
900
+ if (match.state !== "device") {
901
+ throw new Error(
902
+ `Android device "${requested}" is present but not ready (state: ${match.state}). Boot or authorize it, then retry.`
903
+ );
904
+ }
905
+ return requested;
906
+ }
907
+ if (booted.length === 0) {
908
+ throw new Error(
909
+ "No booted Android device found. Start an emulator or connect a device with USB debugging, then confirm it appears in `adb devices -l`."
910
+ );
911
+ }
912
+ if (booted.length > 1) {
913
+ throw new Error(
914
+ `Multiple Android devices attached (${booted.map((d) => d.serial).join(", ")}). Set target.deviceSerial to pick one.`
915
+ );
916
+ }
917
+ return booted[0].serial;
918
+ }
919
+ async function listDevices(runner) {
920
+ const result = await runner(["devices", "-l"], { timeoutMs: 1e4 });
921
+ if (result.code !== 0) {
922
+ throw new Error(
923
+ `\`adb devices\` failed (exit ${result.code}). Is adb on PATH and the server running? ` + (result.stderr.trim() || "").slice(0, 400)
924
+ );
925
+ }
926
+ return parseAdbDevices(result.stdout);
927
+ }
928
+ function parseForwardPort(stdout) {
929
+ const port = Number.parseInt(stdout.trim(), 10);
930
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
931
+ throw new Error(`Could not parse a forwarded port from adb output: "${stdout.trim()}"`);
932
+ }
933
+ return port;
934
+ }
935
+ function parseAaptPackage(stdout) {
936
+ const match = /package:\s*name='([^']+)'/.exec(stdout);
937
+ return match?.[1] ?? null;
938
+ }
939
+ async function forwardDynamicPort(runner, serial, remotePort) {
940
+ const result = await runner(withSerial(serial, ["forward", "tcp:0", `tcp:${remotePort}`]), {
941
+ timeoutMs: 1e4
942
+ });
943
+ if (result.code !== 0) {
944
+ throw new Error(`adb forward failed (exit ${result.code}): ${result.stderr.trim()}`);
945
+ }
946
+ return parseForwardPort(result.stdout);
947
+ }
948
+ async function removeForward(runner, serial, localPort) {
949
+ await runner(withSerial(serial, ["forward", "--remove", `tcp:${localPort}`]), { timeoutMs: 1e4 }).catch(
950
+ () => void 0
951
+ );
952
+ }
953
+ async function installApk(runner, serial, apkPath) {
954
+ const result = await runner(withSerial(serial, ["install", "-r", "-t", "-g", apkPath]), {
955
+ timeoutMs: 12e4
956
+ });
957
+ if (result.code !== 0 || /failure/i.test(result.stdout)) {
958
+ throw new Error(
959
+ `Failed to install APK "${apkPath}" (exit ${result.code}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
960
+ );
961
+ }
962
+ }
963
+ async function launchPackage(runner, serial, pkg) {
964
+ const result = await runner(
965
+ withSerial(serial, [
966
+ "shell",
967
+ "monkey",
968
+ "-p",
969
+ pkg,
970
+ "-c",
971
+ "android.intent.category.LAUNCHER",
972
+ "1"
973
+ ]),
974
+ { timeoutMs: 3e4 }
975
+ );
976
+ if (result.code !== 0 || /no activities found|aborted|cannot launch/i.test(result.stdout + result.stderr)) {
977
+ throw new Error(
978
+ `Failed to launch Android package "${pkg}" (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 400)}. Is it installed?`
979
+ );
980
+ }
981
+ }
982
+ async function forceStop(runner, serial, pkg) {
983
+ await runner(withSerial(serial, ["shell", "am", "force-stop", pkg]), { timeoutMs: 3e4 });
984
+ }
985
+ async function clearPackage(runner, serial, pkg) {
986
+ const result = await runner(withSerial(serial, ["shell", "pm", "clear", pkg]), { timeoutMs: 3e4 });
987
+ if (result.code !== 0 || !/success/i.test(result.stdout)) {
988
+ throw new Error(
989
+ `Failed to clear Android package "${pkg}" for cold start (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 300)}`
990
+ );
991
+ }
992
+ }
993
+ function startInstrumentation(spawner, serial) {
994
+ return spawner(
995
+ withSerial(serial, [
996
+ "shell",
997
+ "am",
998
+ "instrument",
999
+ "-w",
1000
+ "-e",
1001
+ "disableAnalytics",
1002
+ "true",
1003
+ "io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner"
1004
+ ])
1005
+ );
1006
+ }
1007
+
1008
+ // src/browser/android-agent.ts
1009
+ var DEFAULT_AGENT_REQUEST_TIMEOUT_MS = 3e4;
1010
+ var W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
1011
+ var Uia2HttpError = class extends Error {
1012
+ constructor(message, status, webdriverError) {
1013
+ super(message);
1014
+ this.status = status;
1015
+ this.webdriverError = webdriverError;
1016
+ this.name = "Uia2HttpError";
1017
+ }
1018
+ };
1019
+ var Uia2Transport = class {
1020
+ baseUrl;
1021
+ requestTimeoutMs;
1022
+ fetchImpl;
1023
+ constructor(options) {
1024
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
1025
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_AGENT_REQUEST_TIMEOUT_MS;
1026
+ const injected = options.fetchImpl;
1027
+ if (injected) {
1028
+ this.fetchImpl = injected;
1029
+ } else if (typeof fetch === "function") {
1030
+ this.fetchImpl = (url, init) => fetch(url, init);
1031
+ } else {
1032
+ throw new Error("global fetch is unavailable; Node 20+ is required for the Android target");
1033
+ }
1034
+ }
1035
+ /**
1036
+ * Send one request and return the parsed `value` field. Rejects with a
1037
+ * {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
1038
+ * per-request deadline elapses.
1039
+ */
1040
+ async request(method, path18, body, timeoutMs) {
1041
+ const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
1042
+ const controller = new AbortController();
1043
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
1044
+ timer.unref?.();
1045
+ const url = `${this.baseUrl}${path18}`;
1046
+ let response;
1047
+ try {
1048
+ response = await this.fetchImpl(url, {
1049
+ method,
1050
+ signal: controller.signal,
1051
+ headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
1052
+ body: body !== void 0 ? JSON.stringify(body) : void 0
1053
+ });
1054
+ } catch (error) {
1055
+ if (controller.signal.aborted) {
1056
+ const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
1057
+ throw new Error(`uiautomator2 request ${method} ${path18} timed out after ${shown}`);
1058
+ }
1059
+ throw error instanceof Error ? error : new Error(String(error));
1060
+ } finally {
1061
+ clearTimeout(timer);
1062
+ }
1063
+ const text = await response.text();
1064
+ const parsed = parseJson(text);
1065
+ if (!response.ok) {
1066
+ const wdError = extractWebdriverError(parsed);
1067
+ throw new Uia2HttpError(
1068
+ `uiautomator2 ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
1069
+ response.status,
1070
+ wdError
1071
+ );
1072
+ }
1073
+ return parsed?.value;
1074
+ }
1075
+ };
1076
+ function parseJson(text) {
1077
+ if (!text) {
1078
+ return void 0;
1079
+ }
1080
+ try {
1081
+ return JSON.parse(text);
1082
+ } catch {
1083
+ return void 0;
1084
+ }
1085
+ }
1086
+ function extractWebdriverError(parsed) {
1087
+ const value = parsed?.value;
1088
+ if (value && typeof value === "object") {
1089
+ const record = value;
1090
+ const error = typeof record.error === "string" ? record.error : void 0;
1091
+ const message = typeof record.message === "string" ? record.message : void 0;
1092
+ return error ?? message;
1093
+ }
1094
+ return void 0;
1095
+ }
1096
+ function extractElementId(value) {
1097
+ if (!value || typeof value !== "object") {
1098
+ return null;
1099
+ }
1100
+ const record = value;
1101
+ const id = record[W3C_ELEMENT_KEY] ?? record.ELEMENT;
1102
+ return typeof id === "string" ? id : null;
1103
+ }
1104
+ function isNoSuchElement(error) {
1105
+ if (error instanceof Uia2HttpError) {
1106
+ return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
1107
+ }
1108
+ return false;
1109
+ }
1110
+ async function createUia2Session(transport) {
1111
+ const value = await transport.request("POST", "/session", {
1112
+ capabilities: { alwaysMatch: {}, firstMatch: [{}] }
1113
+ });
1114
+ const record = value ?? {};
1115
+ const sessionId = record.sessionId;
1116
+ if (typeof sessionId === "string" && sessionId.length > 0) {
1117
+ return sessionId;
1118
+ }
1119
+ throw new Error("uiautomator2 did not return a session id");
1120
+ }
1121
+ async function waitForAgentReady(transport, options = { deadlineMs: 3e4 }) {
1122
+ const interval = options.intervalMs ?? 300;
1123
+ const deadline = Date.now() + options.deadlineMs;
1124
+ let lastError;
1125
+ for (; ; ) {
1126
+ try {
1127
+ const remainingMs = Math.max(1, deadline - Date.now());
1128
+ const value = await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
1129
+ const ready = value?.ready;
1130
+ if (ready === void 0 || ready === true) {
1131
+ return;
1132
+ }
1133
+ } catch (error) {
1134
+ lastError = error;
1135
+ }
1136
+ if (Date.now() >= deadline) {
1137
+ const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
1138
+ throw new Error(`uiautomator2 agent did not become ready within ${options.deadlineMs}ms${detail}`);
1139
+ }
1140
+ await sleep(Math.min(interval, Math.max(0, deadline - Date.now())));
1141
+ }
1142
+ }
1143
+ function sleep(ms) {
1144
+ return new Promise((resolve) => {
1145
+ const timer = setTimeout(resolve, ms);
1146
+ timer.unref?.();
1147
+ });
1148
+ }
1149
+ function createUia2AgentClient(transport, sessionId, options = {}) {
1150
+ const base = `/session/${sessionId}`;
1151
+ async function locate(query, path18) {
1152
+ return transport.request(
1153
+ "POST",
1154
+ `${base}${path18}`,
1155
+ androidQueryToLocator(query, { appPackage: options.appPackage })
1156
+ );
1157
+ }
1158
+ return {
1159
+ async findElement(query) {
1160
+ try {
1161
+ return extractElementId(await locate(query, "/element"));
1162
+ } catch (error) {
1163
+ if (isNoSuchElement(error)) {
1164
+ return null;
1165
+ }
1166
+ throw error;
1167
+ }
1168
+ },
1169
+ async findElements(query) {
1170
+ const value = await locate(query, "/elements");
1171
+ if (!Array.isArray(value)) {
1172
+ return [];
1173
+ }
1174
+ return value.map((entry) => extractElementId(entry)).filter((id) => id !== null);
1175
+ },
1176
+ async click(elementId) {
1177
+ await transport.request("POST", `${base}/element/${elementId}/click`, {});
1178
+ },
1179
+ async setValue(elementId, text) {
1180
+ await transport.request("POST", `${base}/element/${elementId}/value`, { text });
1181
+ },
1182
+ async getText(elementId) {
1183
+ const value = await transport.request("GET", `${base}/element/${elementId}/text`);
1184
+ return typeof value === "string" ? value : value == null ? null : String(value);
1185
+ },
1186
+ async pressKeyCode(keyCode) {
1187
+ await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
1188
+ },
1189
+ async screenshotPng() {
1190
+ const value = await transport.request("GET", `${base}/screenshot`);
1191
+ if (typeof value !== "string") {
1192
+ throw new Error("uiautomator2 screenshot did not return base64 data");
1193
+ }
1194
+ return Buffer.from(value, "base64");
1195
+ },
1196
+ async close() {
1197
+ await transport.request("DELETE", base).catch(() => void 0);
1198
+ }
1199
+ };
1200
+ }
1201
+
1202
+ // src/config/target.ts
1203
+ var import_node_child_process3 = require("child_process");
1204
+ var import_node_fs3 = __toESM(require("fs"), 1);
1205
+ var import_node_path2 = __toESM(require("path"), 1);
1206
+ var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
1207
+ "navigate",
1208
+ "waitForUrl",
1209
+ "waitForNetworkIdle",
1210
+ "mockRoute",
1211
+ "unmockRoute",
1212
+ "evalScript",
1213
+ "runScript",
1214
+ "onDialog",
1215
+ "select",
1216
+ "selectOption",
1217
+ "setInputFiles",
1218
+ "waitForDownload",
1219
+ "scroll"
1220
+ // directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
1221
+ ]);
1222
+ function webOnlyReason(step) {
1223
+ for (const type of WEB_ONLY_STEP_TYPES) {
1224
+ if (type in step) {
1225
+ return type;
1226
+ }
1227
+ }
1228
+ if ("assert" in step) {
1229
+ const assertion = step.assert;
1230
+ if (assertion.urlIncludes !== void 0 || assertion.urlEquals !== void 0) {
1231
+ return "assert (url)";
1232
+ }
1233
+ }
1234
+ return null;
1235
+ }
1236
+ function nativeTargetLabel(target) {
1237
+ if (target === "android") {
1238
+ return "Android";
1239
+ }
1240
+ if (target === "ios") {
1241
+ return "iOS";
1242
+ }
1243
+ return "macOS";
1244
+ }
1245
+ function assertStepsSupportedByTarget(steps, target) {
1246
+ if (target === "web") {
1247
+ return;
1248
+ }
1249
+ const label = nativeTargetLabel(target);
1250
+ for (const step of steps) {
1251
+ const reason = webOnlyReason(step);
1252
+ if (reason) {
1253
+ throw new Error(
1254
+ `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.).`
1255
+ );
1256
+ }
1257
+ if ("if" in step) {
1258
+ assertStepsSupportedByTarget(step.if.then, target);
1259
+ if (step.if.else) {
1260
+ assertStepsSupportedByTarget(step.if.else, target);
1261
+ }
1262
+ }
1263
+ if ("repeat" in step) {
1264
+ assertStepsSupportedByTarget(step.repeat.steps, target);
1265
+ }
1266
+ }
1267
+ }
1268
+ function assertHuntAssertionsSupportedByTarget(assertions, target) {
1269
+ if (target === "web" || !assertions || assertions.length === 0) {
1270
+ return;
1271
+ }
1272
+ throw new Error(
1273
+ `Hunt-level assertions are not supported by the ${nativeTargetLabel(target)} target. Use inline assert visible/notVisible steps instead.`
1274
+ );
1275
+ }
1276
+ function trimTrailingPathSeparators(value) {
1277
+ return value.replace(/[\\/]+$/g, "");
1278
+ }
1279
+ function looksLikeAppBundlePath(app) {
1280
+ const trimmed = trimTrailingPathSeparators(app);
1281
+ return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
1282
+ }
1283
+ function looksLikeIosAppPath(app) {
1284
+ const trimmed = trimTrailingPathSeparators(app);
1285
+ if (trimmed.includes("/") || trimmed.includes("\\")) {
1286
+ return true;
1287
+ }
1288
+ if (trimmed.toLowerCase().endsWith(".app")) {
1289
+ try {
1290
+ return import_node_fs3.default.statSync(normalizeAppPath(app)).isDirectory();
1291
+ } catch {
1292
+ return false;
1293
+ }
1294
+ }
1295
+ return false;
1296
+ }
1297
+ function normalizeAppPath(app) {
1298
+ return import_node_path2.default.resolve(trimTrailingPathSeparators(app));
1299
+ }
1300
+ function parseBundleIdentifier(plist) {
1301
+ const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
1302
+ return match?.[1]?.trim() || null;
1303
+ }
1304
+ function readBundleIdentifier(appPath, ...plistSubPath) {
1305
+ const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), ...plistSubPath);
1306
+ if (!import_node_fs3.default.existsSync(infoPlistPath)) {
1307
+ return null;
1308
+ }
1309
+ try {
1310
+ const parsed = parseBundleIdentifier(import_node_fs3.default.readFileSync(infoPlistPath, "utf-8"));
1311
+ if (parsed) {
1312
+ return parsed;
1313
+ }
1314
+ } catch {
1315
+ }
1316
+ if (process.platform !== "darwin" || !import_node_fs3.default.existsSync("/usr/bin/plutil")) {
1317
+ return null;
1318
+ }
1319
+ try {
1320
+ const output = (0, import_node_child_process3.execFileSync)(
1321
+ "/usr/bin/plutil",
1322
+ ["-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlistPath],
1323
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1e3 }
1324
+ );
1325
+ return output.trim() || null;
1326
+ } catch {
1327
+ return null;
1328
+ }
1329
+ }
1330
+ function readMacosBundleIdentifier(appPath) {
1331
+ return readBundleIdentifier(appPath, "Contents", "Info.plist");
1332
+ }
1333
+ function readIosBundleIdentifier(appPath) {
1334
+ return readBundleIdentifier(appPath, "Info.plist");
1335
+ }
1336
+ function appBundleAllowedIdentities(app, readBundleId, isAppPath = looksLikeAppBundlePath) {
1337
+ const identities = /* @__PURE__ */ new Set([app]);
1338
+ if (isAppPath(app)) {
1339
+ const normalizedPath = normalizeAppPath(app);
1340
+ identities.add(trimTrailingPathSeparators(app));
1341
+ identities.add(normalizedPath);
1342
+ const bundleName = import_node_path2.default.basename(normalizedPath).replace(/\.app$/i, "");
1343
+ if (bundleName) {
1344
+ identities.add(bundleName);
1345
+ }
1346
+ const bundleId = readBundleId(app);
1347
+ if (bundleId) {
1348
+ identities.add(bundleId);
1349
+ }
1350
+ }
1351
+ return [...identities];
1352
+ }
1353
+ function macosAppAllowedIdentities(app) {
1354
+ return appBundleAllowedIdentities(app, readMacosBundleIdentifier);
1355
+ }
1356
+ function iosAppAllowedIdentities(app) {
1357
+ return appBundleAllowedIdentities(app, readIosBundleIdentifier, looksLikeIosAppPath);
1358
+ }
1359
+ function assertTargetAppAllowed(allowedApps, app) {
1360
+ assertNativeAppAllowed(allowedApps, app, macosAppAllowedIdentities);
1361
+ }
1362
+ function assertIosAppAllowed(allowedApps, app) {
1363
+ assertNativeAppAllowed(allowedApps, app, iosAppAllowedIdentities);
1364
+ }
1365
+ function looksLikeApkPath(app) {
1366
+ const trimmed = trimTrailingPathSeparators(app);
1367
+ return trimmed.includes("/") || trimmed.includes("\\") || trimmed.toLowerCase().endsWith(".apk");
1368
+ }
1369
+ function normalizeAndroidApkPath(app) {
1370
+ const resolved = import_node_path2.default.resolve(trimTrailingPathSeparators(app));
1371
+ try {
1372
+ return import_node_fs3.default.realpathSync.native(resolved);
1373
+ } catch {
1374
+ return resolved;
1375
+ }
1376
+ }
1377
+ function androidAppAllowedIdentities(app, resolvedPackage) {
1378
+ if (looksLikeApkPath(app)) {
1379
+ return resolvedPackage ? [normalizeAndroidApkPath(app), resolvedPackage] : [normalizeAndroidApkPath(app)];
1380
+ }
1381
+ return [app];
1382
+ }
1383
+ function assertAndroidAppAllowed(allowedApps, app, resolvedPackage) {
1384
+ assertNativeAppAllowed(
1385
+ allowedApps,
1386
+ app,
1387
+ (value) => androidAppAllowedIdentities(value, value === app ? resolvedPackage : void 0)
1388
+ );
1389
+ }
1390
+ function assertNativeAppAllowed(allowedApps, app, resolveIdentities) {
1391
+ if (allowedApps.length === 0) {
1392
+ return;
1393
+ }
1394
+ const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => resolveIdentities(allowedApp)));
1395
+ if (resolveIdentities(app).some((identity) => allowedIdentities.has(identity))) {
1396
+ return;
1397
+ }
1398
+ throw new Error(
1399
+ `Target app "${app}" is not in guardrails.allowedApps (${allowedApps.join(", ")}).`
1400
+ );
1401
+ }
1402
+
1403
+ // src/browser/android-helper.ts
1404
+ var import_meta2 = {};
1405
+ var UIA2_REMOTE_PORT = 6790;
1406
+ function resolveAgentApks(requireFn = (0, import_node_module.createRequire)(import_meta2.url)) {
1407
+ let pkgJsonPath;
1408
+ try {
1409
+ pkgJsonPath = requireFn.resolve("appium-uiautomator2-server/package.json");
1410
+ } catch {
1411
+ throw new Error(
1412
+ "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"
1413
+ );
1414
+ }
1415
+ const pkgDir = import_node_path3.default.dirname(pkgJsonPath);
1416
+ const version = requireFn(pkgJsonPath).version;
1417
+ const serverApk = import_node_path3.default.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
1418
+ const testApk = import_node_path3.default.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
1419
+ for (const apk of [serverApk, testApk]) {
1420
+ if (!import_node_fs4.default.existsSync(apk)) {
1421
+ throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
1422
+ }
1423
+ }
1424
+ return { serverApk, testApk };
1425
+ }
1426
+ function looksLikeApk(app) {
1427
+ return app.toLowerCase().endsWith(".apk");
1428
+ }
1429
+ var execFileAaptResolver = async (apkPath) => {
1430
+ for (const tool of ["aapt", "aapt2"]) {
1431
+ const output = await new Promise((resolve) => {
1432
+ (0, import_node_child_process4.execFile)(
1433
+ tool,
1434
+ ["dump", "badging", apkPath],
1435
+ { encoding: "utf-8", timeout: 2e4, maxBuffer: 8 * 1024 * 1024 },
1436
+ (error, stdout) => resolve(error ? null : stdout)
1437
+ );
1438
+ });
1439
+ const pkg = output ? parseAaptPackage(output) : null;
1440
+ if (pkg) {
1441
+ return pkg;
1442
+ }
1443
+ }
1444
+ return null;
1445
+ };
1446
+ var defaultAgentConnector = async ({
1447
+ host,
1448
+ port,
1449
+ requestTimeoutMs,
1450
+ readyDeadlineMs,
1451
+ appPackage
1452
+ }) => {
1453
+ const transport = new Uia2Transport({
1454
+ baseUrl: `http://${host}:${port}/wd/hub`,
1455
+ requestTimeoutMs
1456
+ });
1457
+ await waitForAgentReady(transport, { deadlineMs: readyDeadlineMs });
1458
+ const sessionId = await createUia2Session(transport);
1459
+ return createUia2AgentClient(transport, sessionId, { appPackage });
1460
+ };
1461
+ async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
1462
+ if (!looksLikeApk(app)) {
1463
+ assertAndroidAppAllowed(allowedApps, app);
1464
+ return app;
1465
+ }
1466
+ const apkPath = import_node_path3.default.resolve(app);
1467
+ if (!import_node_fs4.default.existsSync(apkPath)) {
1468
+ throw new Error(`APK not found: ${apkPath}`);
1469
+ }
1470
+ const pkg = await aaptResolver(apkPath);
1471
+ if (!pkg) {
1472
+ throw new Error(
1473
+ `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.`
1474
+ );
1475
+ }
1476
+ assertAndroidAppAllowed(allowedApps, apkPath, pkg);
1477
+ await installApk(runner, serial, apkPath);
1478
+ return pkg;
1479
+ }
1480
+ async function launchAndroidSession(options) {
1481
+ const runner = options.runner ?? execFileAdbRunner;
1482
+ const spawner = options.spawner ?? spawnAdbProcess;
1483
+ const connector = options.agentConnector ?? defaultAgentConnector;
1484
+ const aaptResolver = options.aaptResolver ?? execFileAaptResolver;
1485
+ const apks = options.apks ?? resolveAgentApks();
1486
+ const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS) + 5e3;
1487
+ const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS);
1488
+ const devices = await listDevices(runner);
1489
+ const serial = selectDeviceSerial(devices, options.deviceSerial);
1490
+ const pkg = await resolvePackage(options.app, runner, serial, aaptResolver, options.allowedApps ?? []);
1491
+ await installApk(runner, serial, apks.serverApk);
1492
+ await installApk(runner, serial, apks.testApk);
1493
+ if (options.coldStart) {
1494
+ await clearPackage(runner, serial, pkg);
1495
+ }
1496
+ await launchPackage(runner, serial, pkg);
1497
+ let instrumentation;
1498
+ let localPort;
1499
+ let client;
1500
+ let tornDown = false;
1501
+ const teardown = async () => {
1502
+ if (tornDown) {
1503
+ return;
1504
+ }
1505
+ tornDown = true;
1506
+ if (client) {
1507
+ await client.close().catch(() => void 0);
1508
+ }
1509
+ instrumentation?.kill();
1510
+ const forwardedPort = localPort;
1511
+ localPort = void 0;
1512
+ if (forwardedPort !== void 0) {
1513
+ await removeForward(runner, serial, forwardedPort);
1514
+ }
1515
+ await forceStop(runner, serial, pkg).catch(() => void 0);
1516
+ };
1517
+ try {
1518
+ instrumentation = startInstrumentation(spawner, serial);
1519
+ localPort = await forwardDynamicPort(runner, serial, UIA2_REMOTE_PORT);
1520
+ client = await connector({
1521
+ host: "127.0.0.1",
1522
+ port: localPort,
1523
+ requestTimeoutMs,
1524
+ readyDeadlineMs,
1525
+ appPackage: pkg
1526
+ });
1527
+ const driver = createAndroidDriver(client, { appLabel: pkg });
1528
+ return { client, driver, package: pkg, serial, teardown };
1529
+ } catch (error) {
1530
+ await teardown();
1531
+ throw error;
1532
+ }
1533
+ }
1534
+ async function closeAndroidSession(session) {
1535
+ await session.teardown();
1536
+ }
1537
+
1538
+ // src/browser/ios-driver.ts
1539
+ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
1540
+ "query",
1541
+ "interact",
1542
+ "wait",
1543
+ "screenshot"
1544
+ ]);
1545
+ var WAIT_POLL_INTERVAL_MS2 = 250;
1546
+ var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
1547
+ var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
1548
+ function unquote3(value) {
1549
+ const trimmed = value.trim();
1550
+ const first = trimmed[0];
1551
+ if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
1552
+ return trimmed.slice(1, -1);
1553
+ }
1554
+ return trimmed;
1555
+ }
1556
+ function escapePredicateArg(value) {
1557
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1558
+ }
1559
+ function normalizeXcuiClassName(role) {
1560
+ return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
1561
+ }
1562
+ function parseIosSelector(selector) {
1563
+ const trimmed = selector.trim();
1564
+ if (trimmed === ":focus") {
1565
+ return { by: "focused" };
1566
+ }
1567
+ const idMatch = /^id=(.+)$/s.exec(trimmed);
1568
+ if (idMatch) {
1569
+ return { by: "accessibilityId", value: unquote3(idMatch[1]) };
1570
+ }
1571
+ const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
1572
+ if (roleMatch) {
1573
+ const name = roleMatch[2] !== void 0 ? unquote3(roleMatch[2]) : void 0;
1574
+ return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
1575
+ }
1576
+ const labelMatch = /^label=(.+)$/s.exec(trimmed);
1577
+ if (labelMatch) {
1578
+ return { by: "label", value: unquote3(labelMatch[1]) };
1579
+ }
1580
+ const textMatch = /^text=(.+)$/s.exec(trimmed);
1581
+ if (textMatch) {
1582
+ return { by: "text", value: unquote3(textMatch[1]) };
1583
+ }
1584
+ return { by: "text", value: trimmed };
1585
+ }
1586
+ function iosQueryToLocator(query) {
1587
+ switch (query.by) {
1588
+ case "accessibilityId":
1589
+ return { using: "accessibility id", value: query.value };
1590
+ case "label":
1591
+ return { using: "predicate string", value: `label == "${escapePredicateArg(query.value)}"` };
1592
+ case "text": {
1593
+ const escaped = escapePredicateArg(query.value);
1594
+ return {
1595
+ using: "predicate string",
1596
+ value: `label CONTAINS "${escaped}" OR value CONTAINS "${escaped}"`
1597
+ };
1598
+ }
1599
+ case "focused":
1600
+ return { using: "predicate string", value: "hasKeyboardFocus == 1" };
1601
+ case "role": {
1602
+ const className = normalizeXcuiClassName(query.role);
1603
+ if (query.name === void 0 || query.name.length === 0) {
1604
+ return { using: "class name", value: className };
1605
+ }
1606
+ const escapedClass = escapePredicateArg(className);
1607
+ const escapedName = escapePredicateArg(query.name);
1608
+ return {
1609
+ using: "predicate string",
1610
+ value: `type == "${escapedClass}" AND (label CONTAINS "${escapedName}" OR value CONTAINS "${escapedName}")`
1611
+ };
1612
+ }
1613
+ }
1614
+ }
1615
+ function unwrapIosTextSelector(selector) {
1616
+ const trimmed = selector.trim();
1617
+ if (!trimmed.startsWith("text=")) {
1618
+ return null;
1619
+ }
1620
+ return unquote3(trimmed.slice(5));
1621
+ }
1622
+ function delay2(ms) {
1623
+ return new Promise((resolve) => {
1624
+ setTimeout(resolve, ms);
1625
+ });
1626
+ }
1627
+ function createIosDriver(client, options) {
1628
+ const unsupported = (verb) => new Error(`${verb} is not supported by the iOS target`);
1629
+ const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
1630
+ async function resolveOne(selector) {
1631
+ const id = await client.findElement(parseIosSelector(selector));
1632
+ if (id === null) {
1633
+ throw new Error(`No element matched selector: ${selector}`);
1634
+ }
1635
+ return id;
1636
+ }
1637
+ async function clickSelector(selector) {
1638
+ await client.click(await resolveOne(selector));
1639
+ }
1640
+ async function fillSelector(selector, value) {
1641
+ await client.setValue(await resolveOne(selector), value);
1642
+ }
1643
+ async function pressKey(key) {
1644
+ const name = key.trim().toLowerCase();
1645
+ if (name === "enter" || name === "return") {
1646
+ await client.sendKeys(["\n"]);
1647
+ return;
1648
+ }
1649
+ if (name === "delete" || name === "backspace" || name === "del") {
1650
+ await client.sendKeys(["\b"]);
1651
+ return;
1652
+ }
1653
+ if (name === "home") {
1654
+ await client.homescreen();
1655
+ return;
1656
+ }
1657
+ throw new Error(
1658
+ `Unsupported key "${key}" for the iOS target. Supported keys: ${IOS_PRESS_KEYS.join(", ")}.`
1659
+ );
1660
+ }
1661
+ return {
1662
+ capabilities: IOS_CAPABILITIES,
1663
+ // navigation -----------------------------------------------------------
1664
+ goto(_url, _options) {
1665
+ return rejectUnsupported("navigate");
1666
+ },
1667
+ currentUrl() {
1668
+ return `ios:${options.appLabel ?? ""}`;
1669
+ },
1670
+ // queries --------------------------------------------------------------
1671
+ async count(selector) {
1672
+ return (await client.findElements(parseIosSelector(selector))).length;
1673
+ },
1674
+ async textContent(selector) {
1675
+ const id = await client.findElement(parseIosSelector(selector));
1676
+ if (id === null) {
1677
+ return null;
1678
+ }
1679
+ return client.getText(id);
1680
+ },
1681
+ // interactions ---------------------------------------------------------
1682
+ click: clickSelector,
1683
+ clickFirst: clickSelector,
1684
+ fill: fillSelector,
1685
+ fillFirst: fillSelector,
1686
+ async press(_selector, key) {
1687
+ await pressKey(key);
1688
+ },
1689
+ selectOption() {
1690
+ return rejectUnsupported("select");
1691
+ },
1692
+ selectOptionFirst() {
1693
+ return rejectUnsupported("select");
1694
+ },
1695
+ hover() {
1696
+ return rejectUnsupported("hover");
1697
+ },
1698
+ scrollIntoView() {
1699
+ return rejectUnsupported("scrollTo");
1700
+ },
1701
+ setInputFiles() {
1702
+ return rejectUnsupported("setInputFiles");
1703
+ },
1704
+ // semantic locators ----------------------------------------------------
1705
+ async countByRole(role, name) {
1706
+ return (await client.findElements({ by: "role", role, name })).length;
1707
+ },
1708
+ async clickFirstByRole(role, name) {
1709
+ const id = await client.findElement({ by: "role", role, name });
1710
+ if (id === null) {
1711
+ throw new Error(`No element matched role=${role}[name="${name}"]`);
1712
+ }
1713
+ await client.click(id);
1714
+ },
1715
+ async countByLabel(label) {
1716
+ return (await client.findElements({ by: "label", value: label })).length;
1717
+ },
1718
+ async fillFirstByLabel(label, value) {
1719
+ const id = await client.findElement({ by: "label", value: label });
1720
+ if (id === null) {
1721
+ throw new Error(`No element matched label="${label}"`);
1722
+ }
1723
+ await client.setValue(id, value);
1724
+ },
1725
+ selectOptionFirstByLabel() {
1726
+ return rejectUnsupported("select");
1727
+ },
1728
+ // waiting --------------------------------------------------------------
1729
+ async waitForSelector(selector, waitOptions) {
1730
+ const query = parseIosSelector(selector);
1731
+ const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
1732
+ const deadline = Date.now() + timeoutMs;
1733
+ for (; ; ) {
1734
+ if ((await client.findElements(query)).length > 0) {
1735
+ return;
1736
+ }
1737
+ if (Date.now() >= deadline) {
1738
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
1739
+ }
1740
+ await delay2(Math.min(WAIT_POLL_INTERVAL_MS2, Math.max(0, deadline - Date.now())));
1741
+ }
1742
+ },
1743
+ waitForUrl() {
1744
+ return rejectUnsupported("waitForUrl");
1745
+ },
1746
+ waitForNetworkIdle() {
1747
+ return rejectUnsupported("waitForNetworkIdle");
1748
+ },
1749
+ // scripting & artifacts ------------------------------------------------
1750
+ evaluate() {
1751
+ return rejectUnsupported("evalScript");
1752
+ },
1753
+ async screenshot(screenshotOptions) {
1754
+ await options.captureScreenshot(screenshotOptions.path);
1755
+ },
1756
+ // network / dialogs / downloads (all web-only) -------------------------
1757
+ onResponse(_handler) {
1758
+ throw unsupported("onResponse");
1759
+ },
1760
+ route(_url, _handler) {
1761
+ return rejectUnsupported("mockRoute");
1762
+ },
1763
+ unroute() {
1764
+ return rejectUnsupported("unmockRoute");
1765
+ },
1766
+ onDialog(_action) {
1767
+ throw unsupported("onDialog");
1768
+ },
1769
+ waitForDownloadEvent() {
1770
+ return rejectUnsupported("waitForDownload");
1771
+ },
1772
+ parseTextSelector(selector) {
1773
+ return unwrapIosTextSelector(selector);
1774
+ }
1775
+ };
1776
+ }
1777
+
1778
+ // src/browser/ios-helper.ts
1779
+ var import_node_module2 = require("module");
1780
+ var import_node_fs5 = __toESM(require("fs"), 1);
1781
+ var import_node_os2 = __toESM(require("os"), 1);
1782
+ var import_node_path5 = __toESM(require("path"), 1);
1783
+
1784
+ // src/browser/ios-simctl.ts
1785
+ var import_node_child_process5 = require("child_process");
1786
+ var import_promises = require("fs/promises");
1787
+ var import_node_net = __toESM(require("net"), 1);
1788
+ var import_node_os = __toESM(require("os"), 1);
1789
+ var import_node_path4 = __toESM(require("path"), 1);
1790
+ var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path4.default.join(import_node_os.default.tmpdir(), "prowl-ios-simulator-locks");
1791
+ var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
1792
+ var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
1793
+ (0, import_node_child_process5.execFile)(
1794
+ "xcrun",
1795
+ args,
1796
+ {
1797
+ encoding: "utf-8",
1798
+ timeout: options?.timeoutMs,
1799
+ maxBuffer: 32 * 1024 * 1024,
1800
+ env: options?.env ? { ...process.env, ...options.env } : process.env
1801
+ },
1802
+ (error, stdout, stderr) => {
1803
+ const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
1804
+ const capturedStderr = stderr ?? "";
1805
+ resolve({
1806
+ stdout: stdout ?? "",
1807
+ stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
1808
+ code
1809
+ });
1810
+ }
1811
+ );
1812
+ });
1813
+ function simulatorLockName(udid) {
1814
+ const safe = udid.replace(/[^A-Za-z0-9_.-]/g, "_");
1815
+ return `${safe || "simulator"}.lock`;
1816
+ }
1817
+ function isErrno(error, code) {
1818
+ return error?.code === code;
1819
+ }
1820
+ function isProcessAlive(pid) {
1821
+ try {
1822
+ process.kill(pid, 0);
1823
+ return true;
1824
+ } catch (error) {
1825
+ return isErrno(error, "EPERM");
1826
+ }
1827
+ }
1828
+ async function removeStaleSimulatorLock(lockPath) {
1829
+ try {
1830
+ const ownerText = await (0, import_promises.readFile)(import_node_path4.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
1831
+ const owner = JSON.parse(ownerText);
1832
+ if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
1833
+ await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
1834
+ return true;
1835
+ }
1836
+ } catch {
1837
+ return false;
1838
+ }
1839
+ return false;
1840
+ }
1841
+ function simulatorReservedError(udid) {
1842
+ return new Error(
1843
+ `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.`
1844
+ );
1845
+ }
1846
+ async function reserveSimulatorUdid(udid, options = {}) {
1847
+ const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
1848
+ const lockPath = import_node_path4.default.join(lockRoot, simulatorLockName(udid));
1849
+ await (0, import_promises.mkdir)(lockRoot, { recursive: true });
1850
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1851
+ try {
1852
+ await (0, import_promises.mkdir)(lockPath);
1853
+ } catch (error) {
1854
+ if (!isErrno(error, "EEXIST")) {
1855
+ throw error instanceof Error ? error : new Error(String(error));
1856
+ }
1857
+ if (attempt === 0 && await removeStaleSimulatorLock(lockPath)) {
1858
+ continue;
1859
+ }
1860
+ throw simulatorReservedError(udid);
1861
+ }
1862
+ let released = false;
1863
+ const release = async () => {
1864
+ if (released) {
1865
+ return;
1866
+ }
1867
+ released = true;
1868
+ await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
1869
+ };
1870
+ try {
1871
+ await (0, import_promises.writeFile)(
1872
+ import_node_path4.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
1873
+ `${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
1874
+ `,
1875
+ { flag: "wx" }
1876
+ );
1877
+ } catch (error) {
1878
+ await release().catch(() => void 0);
1879
+ throw error instanceof Error ? error : new Error(String(error));
1880
+ }
1881
+ return { udid, release };
1882
+ }
1883
+ throw simulatorReservedError(udid);
1884
+ }
1885
+ function findFreePort() {
1886
+ return new Promise((resolve, reject) => {
1887
+ const server = import_node_net.default.createServer();
1888
+ server.on("error", reject);
1889
+ server.listen(0, "127.0.0.1", () => {
1890
+ const address = server.address();
1891
+ if (address && typeof address === "object") {
1892
+ const { port } = address;
1893
+ server.close(() => resolve(port));
1894
+ } else {
1895
+ server.close(() => reject(new Error("Could not allocate a local port")));
1896
+ }
1897
+ });
1898
+ });
1899
+ }
1900
+ function parseSimctlDevices(json) {
1901
+ let parsed;
1902
+ try {
1903
+ parsed = JSON.parse(json);
1904
+ } catch {
1905
+ throw new Error("Could not parse `simctl list devices --json` output.");
1906
+ }
1907
+ const byRuntime = parsed?.devices;
1908
+ if (!byRuntime || typeof byRuntime !== "object") {
1909
+ return [];
1910
+ }
1911
+ const devices = [];
1912
+ for (const [runtime, entries] of Object.entries(byRuntime)) {
1913
+ if (!Array.isArray(entries)) {
1914
+ continue;
1915
+ }
1916
+ for (const entry of entries) {
1917
+ if (!entry || typeof entry !== "object") {
1918
+ continue;
1919
+ }
1920
+ const record = entry;
1921
+ const udid = typeof record.udid === "string" ? record.udid : void 0;
1922
+ const name = typeof record.name === "string" ? record.name : void 0;
1923
+ const state = typeof record.state === "string" ? record.state : "Unknown";
1924
+ if (!udid || !name) {
1925
+ continue;
1926
+ }
1927
+ devices.push({
1928
+ udid,
1929
+ name,
1930
+ state,
1931
+ runtime,
1932
+ isAvailable: record.isAvailable !== false
1933
+ });
1934
+ }
1935
+ }
1936
+ return devices;
1937
+ }
1938
+ function bootedSimulators(devices) {
1939
+ return devices.filter((device) => device.state === "Booted");
1940
+ }
1941
+ function describeSimulator(device) {
1942
+ const runtime = device.runtime.replace(/^com\.apple\.CoreSimulator\.SimRuntime\./, "");
1943
+ return `${device.name} [${runtime}] (${device.udid})`;
1944
+ }
1945
+ function selectSimulatorUdid(devices, requested) {
1946
+ const booted = bootedSimulators(devices);
1947
+ if (requested) {
1948
+ const match = devices.find((device) => device.udid === requested);
1949
+ if (!match) {
1950
+ const known = devices.length > 0 ? devices.map((d) => d.udid).join(", ") : "none";
1951
+ throw new Error(
1952
+ `iOS simulator "${requested}" was not found. Known simulators: ${known}. Check \`xcrun simctl list devices\`.`
1953
+ );
1954
+ }
1955
+ if (match.state !== "Booted") {
1956
+ throw new Error(
1957
+ `iOS simulator "${requested}" is not booted (state: ${match.state}). Boot it with \`xcrun simctl boot ${requested}\`, then retry.`
1958
+ );
1959
+ }
1960
+ return requested;
1961
+ }
1962
+ if (booted.length === 0) {
1963
+ throw new Error(
1964
+ "No booted iOS simulator found. Boot one from Xcode or with `xcrun simctl boot <udid>` (see `xcrun simctl list devices available`), then retry."
1965
+ );
1966
+ }
1967
+ if (booted.length > 1) {
1968
+ throw new Error(
1969
+ `Multiple iOS simulators are booted (${booted.map(describeSimulator).join("; ")}). Set target.udid to pick one.`
1970
+ );
1971
+ }
1972
+ return booted[0].udid;
1973
+ }
1974
+ async function listSimulators(runner) {
1975
+ const result = await runner(["simctl", "list", "devices", "--json"], { timeoutMs: 3e4 });
1976
+ if (result.code !== 0) {
1977
+ throw new Error(
1978
+ `\`xcrun simctl list devices\` failed. Is Xcode installed and are the command-line tools selected (\`xcode-select -p\`)? ${(result.stderr.trim() || "").slice(0, 400)}`
1979
+ );
1980
+ }
1981
+ return parseSimctlDevices(result.stdout);
1982
+ }
1983
+ async function installApp(runner, udid, appPath) {
1984
+ const result = await runner(["simctl", "install", udid, appPath], { timeoutMs: 12e4 });
1985
+ if (result.code !== 0) {
1986
+ throw new Error(
1987
+ `Failed to install "${appPath}" onto simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
1988
+ );
1989
+ }
1990
+ }
1991
+ async function uninstallApp(runner, udid, bundleId) {
1992
+ await runner(["simctl", "uninstall", udid, bundleId], { timeoutMs: 6e4 }).catch(() => void 0);
1993
+ }
1994
+ async function launchApp(runner, udid, bundleId, childEnv = {}) {
1995
+ const env = {};
1996
+ for (const [key, value] of Object.entries(childEnv)) {
1997
+ env[`SIMCTL_CHILD_${key}`] = value;
1998
+ }
1999
+ const result = await runner(["simctl", "launch", udid, bundleId], {
2000
+ timeoutMs: 6e4,
2001
+ env: Object.keys(env).length > 0 ? env : void 0
2002
+ });
2003
+ if (result.code !== 0) {
2004
+ throw new Error(
2005
+ `Failed to launch "${bundleId}" on simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}. Is it installed?`
2006
+ );
2007
+ }
2008
+ }
2009
+ async function terminateApp(runner, udid, bundleId) {
2010
+ await runner(["simctl", "terminate", udid, bundleId], { timeoutMs: 3e4 }).catch(() => void 0);
2011
+ }
2012
+ async function captureScreenshot(runner, udid, outPath) {
2013
+ const result = await runner(["simctl", "io", udid, "screenshot", outPath], { timeoutMs: 3e4 });
2014
+ if (result.code !== 0) {
2015
+ throw new Error(
2016
+ `Failed to capture a simulator screenshot (${udid}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 300)}`
2017
+ );
2018
+ }
2019
+ }
2020
+ function parseXcodeVersion(stdout) {
2021
+ const match = /Xcode\s+([\d.]+)/i.exec(stdout);
2022
+ return match?.[1] ?? null;
2023
+ }
2024
+ async function xcodeVersion(runner) {
2025
+ const result = await runner(["xcodebuild", "-version"], { timeoutMs: 3e4 });
2026
+ if (result.code !== 0) {
2027
+ return null;
2028
+ }
2029
+ return parseXcodeVersion(result.stdout);
2030
+ }
2031
+
2032
+ // src/browser/ios-agent.ts
2033
+ var DEFAULT_WDA_REQUEST_TIMEOUT_MS = 3e4;
2034
+ var W3C_ELEMENT_KEY2 = "element-6066-11e4-a52e-4f735466cecf";
2035
+ var WdaHttpError = class extends Error {
2036
+ constructor(message, status, webdriverError) {
2037
+ super(message);
2038
+ this.status = status;
2039
+ this.webdriverError = webdriverError;
2040
+ this.name = "WdaHttpError";
2041
+ }
2042
+ };
2043
+ var WdaTransport = class {
2044
+ baseUrl;
2045
+ requestTimeoutMs;
2046
+ fetchImpl;
2047
+ constructor(options) {
2048
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
2049
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_WDA_REQUEST_TIMEOUT_MS;
2050
+ const injected = options.fetchImpl;
2051
+ if (injected) {
2052
+ this.fetchImpl = injected;
2053
+ } else if (typeof fetch === "function") {
2054
+ this.fetchImpl = (url, init) => fetch(url, init);
2055
+ } else {
2056
+ throw new Error("global fetch is unavailable; Node 20+ is required for the iOS target");
2057
+ }
2058
+ }
2059
+ /**
2060
+ * Send one request and return the full parsed JSON body. Rejects with a
2061
+ * {@link WdaHttpError} on a non-2xx response, or a timeout error when the
2062
+ * per-request deadline elapses.
2063
+ */
2064
+ async requestFull(method, path18, body, timeoutMs) {
2065
+ const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
2066
+ const controller = new AbortController();
2067
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
2068
+ timer.unref?.();
2069
+ const url = `${this.baseUrl}${path18}`;
2070
+ const timeoutError = () => {
2071
+ const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
2072
+ return new Error(`WebDriverAgent request ${method} ${path18} timed out after ${shown}`);
2073
+ };
2074
+ let response;
2075
+ try {
2076
+ response = await this.fetchImpl(url, {
2077
+ method,
2078
+ signal: controller.signal,
2079
+ headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
2080
+ body: body !== void 0 ? JSON.stringify(body) : void 0
2081
+ });
2082
+ } catch (error) {
2083
+ clearTimeout(timer);
2084
+ if (controller.signal.aborted) {
2085
+ throw timeoutError();
556
2086
  }
2087
+ throw error instanceof Error ? error : new Error(String(error));
557
2088
  }
558
- if ("repeat" in step) {
559
- assertStepsSupportedByTarget(step.repeat.steps, target);
2089
+ let text;
2090
+ try {
2091
+ text = await response.text();
2092
+ } catch (error) {
2093
+ if (controller.signal.aborted) {
2094
+ throw timeoutError();
2095
+ }
2096
+ throw error instanceof Error ? error : new Error(String(error));
2097
+ } finally {
2098
+ clearTimeout(timer);
2099
+ }
2100
+ const parsed = parseJson2(text);
2101
+ if (!response.ok) {
2102
+ const wdError = extractWebdriverError2(parsed);
2103
+ throw new WdaHttpError(
2104
+ `WebDriverAgent ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2105
+ response.status,
2106
+ wdError
2107
+ );
560
2108
  }
2109
+ return parsed;
2110
+ }
2111
+ /** Like {@link requestFull} but returns just the `value` field. */
2112
+ async request(method, path18, body, timeoutMs) {
2113
+ const parsed = await this.requestFull(method, path18, body, timeoutMs);
2114
+ return parsed?.value;
2115
+ }
2116
+ };
2117
+ function parseJson2(text) {
2118
+ if (!text) {
2119
+ return void 0;
2120
+ }
2121
+ try {
2122
+ return JSON.parse(text);
2123
+ } catch {
2124
+ return void 0;
561
2125
  }
562
2126
  }
563
- function assertHuntAssertionsSupportedByTarget(assertions, target) {
564
- if (target !== "macos" || !assertions || assertions.length === 0) {
565
- return;
2127
+ function extractWebdriverError2(parsed) {
2128
+ const value = parsed?.value;
2129
+ if (value && typeof value === "object") {
2130
+ const record = value;
2131
+ const error = typeof record.error === "string" ? record.error : void 0;
2132
+ const message = typeof record.message === "string" ? record.message : void 0;
2133
+ return error ?? message;
566
2134
  }
567
- throw new Error(
568
- "Hunt-level assertions are not supported by the macOS target. Use inline assert visible/notVisible steps instead."
569
- );
2135
+ return void 0;
570
2136
  }
571
- function trimTrailingPathSeparators(value) {
572
- return value.replace(/[\\/]+$/g, "");
2137
+ function extractElementId2(value) {
2138
+ if (!value || typeof value !== "object") {
2139
+ return null;
2140
+ }
2141
+ const record = value;
2142
+ const id = record[W3C_ELEMENT_KEY2] ?? record.ELEMENT;
2143
+ return typeof id === "string" ? id : null;
573
2144
  }
574
- function looksLikeMacosAppPath(app) {
575
- const trimmed = trimTrailingPathSeparators(app);
576
- return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
2145
+ function isNoSuchElement2(error) {
2146
+ if (error instanceof WdaHttpError) {
2147
+ return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
2148
+ }
2149
+ return false;
577
2150
  }
578
- function normalizeAppPath(app) {
579
- return import_node_path2.default.resolve(trimTrailingPathSeparators(app));
2151
+ async function createWdaSession(transport, bundleId) {
2152
+ const body = await transport.requestFull("POST", "/session", {
2153
+ capabilities: { alwaysMatch: { bundleId }, firstMatch: [{}] }
2154
+ });
2155
+ const envelope = body ?? {};
2156
+ const value = envelope.value ?? {};
2157
+ const sessionId = typeof value.sessionId === "string" && value.sessionId || typeof envelope.sessionId === "string" && envelope.sessionId || "";
2158
+ if (sessionId.length > 0) {
2159
+ return sessionId;
2160
+ }
2161
+ throw new Error("WebDriverAgent did not return a session id");
2162
+ }
2163
+ async function waitForWdaReady(transport, options = { deadlineMs: 6e4 }) {
2164
+ const interval = options.intervalMs ?? 300;
2165
+ const deadline = Date.now() + options.deadlineMs;
2166
+ let lastError;
2167
+ for (; ; ) {
2168
+ try {
2169
+ const remainingMs = Math.max(1, deadline - Date.now());
2170
+ await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
2171
+ return;
2172
+ } catch (error) {
2173
+ lastError = error;
2174
+ }
2175
+ if (Date.now() >= deadline) {
2176
+ const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
2177
+ throw new Error(`WebDriverAgent did not become ready within ${options.deadlineMs}ms${detail}`);
2178
+ }
2179
+ await sleep2(Math.min(interval, Math.max(0, deadline - Date.now())));
2180
+ }
580
2181
  }
581
- function parseBundleIdentifier(plist) {
582
- const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
583
- return match?.[1]?.trim() || null;
2182
+ function sleep2(ms) {
2183
+ return new Promise((resolve) => {
2184
+ setTimeout(resolve, ms);
2185
+ });
584
2186
  }
585
- function readBundleIdentifier(appPath) {
586
- const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), "Contents", "Info.plist");
587
- if (!import_node_fs2.default.existsSync(infoPlistPath)) {
588
- return null;
2187
+ function createWdaAgentClient(transport, sessionId) {
2188
+ const base = `/session/${sessionId}`;
2189
+ async function locate(query, path18) {
2190
+ return transport.request("POST", `${base}${path18}`, iosQueryToLocator(query));
589
2191
  }
590
- try {
591
- const parsed = parseBundleIdentifier(import_node_fs2.default.readFileSync(infoPlistPath, "utf-8"));
592
- if (parsed) {
593
- return parsed;
2192
+ return {
2193
+ async findElement(query) {
2194
+ try {
2195
+ return extractElementId2(await locate(query, "/element"));
2196
+ } catch (error) {
2197
+ if (isNoSuchElement2(error)) {
2198
+ return null;
2199
+ }
2200
+ throw error;
2201
+ }
2202
+ },
2203
+ async findElements(query) {
2204
+ const value = await locate(query, "/elements");
2205
+ if (!Array.isArray(value)) {
2206
+ return [];
2207
+ }
2208
+ return value.map((entry) => extractElementId2(entry)).filter((id) => id !== null);
2209
+ },
2210
+ async click(elementId) {
2211
+ await transport.request("POST", `${base}/element/${elementId}/click`, {});
2212
+ },
2213
+ async setValue(elementId, text) {
2214
+ await transport.request("POST", `${base}/element/${elementId}/value`, { text });
2215
+ },
2216
+ async getText(elementId) {
2217
+ const value = await transport.request("GET", `${base}/element/${elementId}/text`);
2218
+ return typeof value === "string" ? value : value == null ? null : String(value);
2219
+ },
2220
+ async sendKeys(keys) {
2221
+ await transport.request("POST", `${base}/wda/keys`, { value: keys });
2222
+ },
2223
+ async homescreen() {
2224
+ await transport.request("POST", "/wda/homescreen", {});
2225
+ },
2226
+ async close() {
2227
+ await transport.request("DELETE", base).catch(() => void 0);
594
2228
  }
2229
+ };
2230
+ }
2231
+
2232
+ // src/browser/ios-helper.ts
2233
+ var import_meta3 = {};
2234
+ var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
2235
+ var WDA_RUNNER_APP_NAME = "WebDriverAgentRunner-Runner.app";
2236
+ var WDA_USE_PORT_ENV = "USE_PORT";
2237
+ var WDA_STARTUP_ATTEMPTS = 2;
2238
+ var defaultIosAgentConnector = async ({
2239
+ host,
2240
+ port,
2241
+ bundleId,
2242
+ requestTimeoutMs,
2243
+ readyDeadlineMs
2244
+ }) => {
2245
+ const transport = new WdaTransport({ baseUrl: `http://${host}:${port}`, requestTimeoutMs });
2246
+ await waitForWdaReady(transport, { deadlineMs: readyDeadlineMs });
2247
+ const sessionId = await createWdaSession(transport, bundleId);
2248
+ return createWdaAgentClient(transport, sessionId);
2249
+ };
2250
+ function resolveWdaProject(requireFn = (0, import_node_module2.createRequire)(import_meta3.url)) {
2251
+ let pkgJsonPath;
2252
+ try {
2253
+ pkgJsonPath = requireFn.resolve("appium-webdriveragent/package.json");
595
2254
  } catch {
2255
+ throw new Error(
2256
+ "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"
2257
+ );
596
2258
  }
597
- if (process.platform !== "darwin" || !import_node_fs2.default.existsSync("/usr/bin/plutil")) {
598
- return null;
2259
+ const pkgDir = import_node_path5.default.dirname(pkgJsonPath);
2260
+ const version = requireFn(pkgJsonPath).version;
2261
+ const projectPath = import_node_path5.default.join(pkgDir, "WebDriverAgent.xcodeproj");
2262
+ if (!import_node_fs5.default.existsSync(projectPath)) {
2263
+ throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
599
2264
  }
600
- try {
601
- const output = (0, import_node_child_process2.execFileSync)(
602
- "/usr/bin/plutil",
603
- ["-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlistPath],
604
- { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1e3 }
2265
+ return { projectPath, version };
2266
+ }
2267
+ function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os2.default.homedir()) {
2268
+ return import_node_path5.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
2269
+ }
2270
+ function runnerAppPath(derivedDataPath) {
2271
+ return import_node_path5.default.join(derivedDataPath, "Build", "Products", "Debug-iphonesimulator", WDA_RUNNER_APP_NAME);
2272
+ }
2273
+ async function resolveWdaRunner(options = {}) {
2274
+ const runner = options.runner ?? execFileXcrunRunner;
2275
+ const env = options.env ?? process.env;
2276
+ const homeDir = options.homeDir ?? import_node_os2.default.homedir();
2277
+ const log = options.logger ?? ((message) => process.stderr.write(`${message}
2278
+ `));
2279
+ const override = env.PROWL_WDA_RUNNER;
2280
+ if (override) {
2281
+ if (!import_node_fs5.default.existsSync(override)) {
2282
+ throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
2283
+ }
2284
+ return override;
2285
+ }
2286
+ const { projectPath, version } = resolveWdaProject(options.requireFn);
2287
+ const xcode = await xcodeVersion(runner);
2288
+ if (!xcode) {
2289
+ throw new Error(
2290
+ "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."
2291
+ );
2292
+ }
2293
+ const cacheDir = wdaCacheDir(version, xcode, homeDir);
2294
+ const cachedRunner = runnerAppPath(cacheDir);
2295
+ if (import_node_fs5.default.existsSync(cachedRunner)) {
2296
+ return cachedRunner;
2297
+ }
2298
+ log(
2299
+ `Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
2300
+ );
2301
+ import_node_fs5.default.mkdirSync(cacheDir, { recursive: true });
2302
+ const result = await runner(
2303
+ [
2304
+ "xcodebuild",
2305
+ "build-for-testing",
2306
+ "-project",
2307
+ projectPath,
2308
+ "-scheme",
2309
+ "WebDriverAgentRunner",
2310
+ "-destination",
2311
+ "generic/platform=iOS Simulator",
2312
+ "-derivedDataPath",
2313
+ cacheDir,
2314
+ "CODE_SIGNING_ALLOWED=NO"
2315
+ ],
2316
+ { timeoutMs: 12e5 }
2317
+ );
2318
+ if (result.code !== 0) {
2319
+ throw new Error(
2320
+ `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)}`
605
2321
  );
606
- return output.trim() || null;
607
- } catch {
608
- return null;
609
2322
  }
2323
+ if (!import_node_fs5.default.existsSync(cachedRunner)) {
2324
+ throw new Error(
2325
+ `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.`
2326
+ );
2327
+ }
2328
+ return cachedRunner;
610
2329
  }
611
- function macosAppAllowedIdentities(app) {
612
- const identities = /* @__PURE__ */ new Set([app]);
613
- if (looksLikeMacosAppPath(app)) {
614
- const normalizedPath = normalizeAppPath(app);
615
- identities.add(trimTrailingPathSeparators(app));
616
- identities.add(normalizedPath);
617
- const bundleName = import_node_path2.default.basename(normalizedPath).replace(/\.app$/i, "");
618
- if (bundleName) {
619
- identities.add(bundleName);
620
- }
621
- const bundleId = readBundleIdentifier(app);
622
- if (bundleId) {
623
- identities.add(bundleId);
2330
+ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
2331
+ if (!looksLikeIosAppPath(app)) {
2332
+ assertIosAppAllowed(allowedApps, app);
2333
+ if (coldStart) {
2334
+ throw new Error(
2335
+ `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.`
2336
+ );
624
2337
  }
2338
+ return app;
625
2339
  }
626
- return [...identities];
627
- }
628
- function assertTargetAppAllowed(allowedApps, app) {
629
- if (allowedApps.length === 0) {
630
- return;
2340
+ const appPath = import_node_path5.default.resolve(app);
2341
+ if (!import_node_fs5.default.existsSync(appPath)) {
2342
+ throw new Error(`.app bundle not found: ${appPath}`);
631
2343
  }
632
- const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => macosAppAllowedIdentities(allowedApp)));
633
- if (macosAppAllowedIdentities(app).some((identity) => allowedIdentities.has(identity))) {
634
- return;
2344
+ const bundleId = readIosBundleIdentifier(appPath);
2345
+ if (!bundleId) {
2346
+ throw new Error(
2347
+ `Could not read CFBundleIdentifier from "${appPath}" (root Info.plist). Ensure target.app points at a built iOS .app bundle.`
2348
+ );
2349
+ }
2350
+ assertIosAppAllowed(allowedApps, appPath);
2351
+ if (coldStart) {
2352
+ await uninstallApp(runner, udid, bundleId);
2353
+ }
2354
+ await installApp(runner, udid, appPath);
2355
+ return bundleId;
2356
+ }
2357
+ async function launchIosSession(options) {
2358
+ const runner = options.runner ?? execFileXcrunRunner;
2359
+ const portAllocator = options.portAllocator ?? findFreePort;
2360
+ const connector = options.agentConnector ?? defaultIosAgentConnector;
2361
+ const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
2362
+ const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, 6e4);
2363
+ const devices = await listSimulators(runner);
2364
+ const udid = selectSimulatorUdid(devices, options.udid);
2365
+ const reservation = await reserveSimulatorUdid(udid, { lockRoot: options.simulatorLockRoot });
2366
+ let reservationReleased = false;
2367
+ const releaseReservation = async () => {
2368
+ if (reservationReleased) {
2369
+ return;
2370
+ }
2371
+ reservationReleased = true;
2372
+ await reservation.release();
2373
+ };
2374
+ try {
2375
+ const bundleId = await resolveBundleId(
2376
+ options.app,
2377
+ runner,
2378
+ udid,
2379
+ options.coldStart ?? false,
2380
+ options.allowedApps ?? []
2381
+ );
2382
+ const wdaRunnerApp = options.wdaRunnerApp ?? await resolveWdaRunner({ runner, logger: options.logger });
2383
+ await installApp(runner, udid, wdaRunnerApp);
2384
+ for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
2385
+ let client;
2386
+ let tornDown = false;
2387
+ let terminateWda = false;
2388
+ let terminateTarget = false;
2389
+ let stage = "port";
2390
+ const teardownAttempt = async () => {
2391
+ if (tornDown) {
2392
+ return;
2393
+ }
2394
+ tornDown = true;
2395
+ if (client) {
2396
+ await client.close().catch(() => void 0);
2397
+ }
2398
+ if (terminateWda) {
2399
+ await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
2400
+ }
2401
+ if (terminateTarget) {
2402
+ await terminateApp(runner, udid, bundleId);
2403
+ }
2404
+ };
2405
+ try {
2406
+ const port = await portAllocator();
2407
+ stage = "wda-launch";
2408
+ terminateWda = true;
2409
+ await launchApp(runner, udid, WDA_RUNNER_BUNDLE_ID, { [WDA_USE_PORT_ENV]: String(port) });
2410
+ stage = "target-launch";
2411
+ terminateTarget = true;
2412
+ await launchApp(runner, udid, bundleId);
2413
+ stage = "connect";
2414
+ client = await connector({ host: "127.0.0.1", port, bundleId, requestTimeoutMs, readyDeadlineMs });
2415
+ const driver = createIosDriver(client, {
2416
+ appLabel: bundleId,
2417
+ captureScreenshot: (outPath) => captureScreenshot(runner, udid, outPath)
2418
+ });
2419
+ const teardown = async () => {
2420
+ await teardownAttempt();
2421
+ await releaseReservation();
2422
+ };
2423
+ return { client, driver, bundleId, udid, teardown };
2424
+ } catch (error) {
2425
+ await teardownAttempt();
2426
+ if (stage === "target-launch" || attempt >= WDA_STARTUP_ATTEMPTS) {
2427
+ throw error instanceof Error ? error : new Error(String(error));
2428
+ }
2429
+ }
2430
+ }
2431
+ throw new Error("WebDriverAgent startup failed without an error.");
2432
+ } catch (error) {
2433
+ await releaseReservation().catch(() => void 0);
2434
+ throw error;
635
2435
  }
636
- throw new Error(
637
- `Target app "${app}" is not in guardrails.allowedApps (${allowedApps.join(", ")}).`
638
- );
2436
+ }
2437
+ async function closeIosSession(session) {
2438
+ await session.teardown();
639
2439
  }
640
2440
 
641
2441
  // src/runner/index.ts
642
- var import_node_fs11 = __toESM(require("fs"), 1);
643
- var import_node_path11 = __toESM(require("path"), 1);
2442
+ var import_node_fs14 = __toESM(require("fs"), 1);
2443
+ var import_node_path14 = __toESM(require("path"), 1);
644
2444
 
645
2445
  // src/config/loader.ts
646
- var import_node_fs3 = __toESM(require("fs"), 1);
647
- var import_node_path3 = __toESM(require("path"), 1);
2446
+ var import_node_fs6 = __toESM(require("fs"), 1);
2447
+ var import_node_path6 = __toESM(require("path"), 1);
648
2448
  var import_yaml = __toESM(require("yaml"), 1);
649
2449
  var import_dotenv = __toESM(require("dotenv"), 1);
650
2450
 
@@ -676,7 +2476,24 @@ var macosTargetSchema = import_zod.z.object({
676
2476
  type: import_zod.z.literal("macos"),
677
2477
  app: import_zod.z.string().min(1)
678
2478
  }).strict();
679
- var targetSchema = import_zod.z.union([macosTargetSchema, webTargetSchema]);
2479
+ var androidTargetSchema = import_zod.z.object({
2480
+ type: import_zod.z.literal("android"),
2481
+ app: import_zod.z.string().min(1),
2482
+ deviceSerial: import_zod.z.string().min(1).optional(),
2483
+ coldStart: import_zod.z.boolean().optional()
2484
+ }).strict();
2485
+ var iosTargetSchema = import_zod.z.object({
2486
+ type: import_zod.z.literal("ios"),
2487
+ app: import_zod.z.string().min(1),
2488
+ udid: import_zod.z.string().min(1).optional(),
2489
+ coldStart: import_zod.z.boolean().optional()
2490
+ }).strict();
2491
+ var targetSchema = import_zod.z.union([
2492
+ macosTargetSchema,
2493
+ androidTargetSchema,
2494
+ iosTargetSchema,
2495
+ webTargetSchema
2496
+ ]);
680
2497
  var configSchema = import_zod.z.object({
681
2498
  target: targetSchema,
682
2499
  browser: import_zod.z.object({
@@ -1018,12 +2835,12 @@ function findConfigPath(startDir) {
1018
2835
  let current = startDir;
1019
2836
  while (current) {
1020
2837
  for (const dir of [CONFIG_DIR, LEGACY_CONFIG_DIR]) {
1021
- const candidate = import_node_path3.default.join(current, dir, "config.yml");
1022
- if (import_node_fs3.default.existsSync(candidate)) {
2838
+ const candidate = import_node_path6.default.join(current, dir, "config.yml");
2839
+ if (import_node_fs6.default.existsSync(candidate)) {
1023
2840
  return candidate;
1024
2841
  }
1025
2842
  }
1026
- const parent = import_node_path3.default.dirname(current);
2843
+ const parent = import_node_path6.default.dirname(current);
1027
2844
  if (parent === current) {
1028
2845
  break;
1029
2846
  }
@@ -1050,9 +2867,28 @@ function resolveViewport(value) {
1050
2867
  return value;
1051
2868
  }
1052
2869
  function resolveTarget(target) {
1053
- if (target && target.type === "macos") {
2870
+ const type = target?.type;
2871
+ if (type === "macos") {
1054
2872
  return { type: "macos", app: target.app };
1055
2873
  }
2874
+ if (type === "android") {
2875
+ const androidTarget = target;
2876
+ return {
2877
+ type: "android",
2878
+ app: androidTarget.app,
2879
+ ...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
2880
+ ...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
2881
+ };
2882
+ }
2883
+ if (type === "ios") {
2884
+ const iosTarget = target;
2885
+ return {
2886
+ type: "ios",
2887
+ app: iosTarget.app,
2888
+ ...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
2889
+ ...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
2890
+ };
2891
+ }
1056
2892
  return {
1057
2893
  type: "web",
1058
2894
  url: target?.url ?? DEFAULT_WEB_URL
@@ -1111,19 +2947,19 @@ function ensureAllowedDomain(allowed, urlValue) {
1111
2947
  return allowed;
1112
2948
  }
1113
2949
  function loadConfig(configPath) {
1114
- const resolvedPath = configPath ? import_node_path3.default.resolve(configPath) : findConfigPath(process.cwd());
2950
+ const resolvedPath = configPath ? import_node_path6.default.resolve(configPath) : findConfigPath(process.cwd());
1115
2951
  if (!resolvedPath) {
1116
2952
  throw new Error("Could not find .prowl/config.yml. Run `prowl init` first.");
1117
2953
  }
1118
- if (!import_node_fs3.default.existsSync(resolvedPath)) {
2954
+ if (!import_node_fs6.default.existsSync(resolvedPath)) {
1119
2955
  throw new Error(`Config file not found at ${resolvedPath}`);
1120
2956
  }
1121
- const configDir = import_node_path3.default.dirname(resolvedPath);
1122
- if (import_node_path3.default.basename(configDir) === LEGACY_CONFIG_DIR) {
2957
+ const configDir = import_node_path6.default.dirname(resolvedPath);
2958
+ if (import_node_path6.default.basename(configDir) === LEGACY_CONFIG_DIR) {
1123
2959
  warnLegacyConfigDir();
1124
2960
  }
1125
- import_dotenv.default.config({ path: import_node_path3.default.join(configDir, ".env"), override: false });
1126
- const raw = import_node_fs3.default.readFileSync(resolvedPath, "utf-8");
2961
+ import_dotenv.default.config({ path: import_node_path6.default.join(configDir, ".env"), override: false });
2962
+ const raw = import_node_fs6.default.readFileSync(resolvedPath, "utf-8");
1127
2963
  const parsed = import_yaml.default.parse(raw) ?? {};
1128
2964
  const validated = configSchema.parse(parsed);
1129
2965
  const config = mergeConfig(validated);
@@ -1137,32 +2973,32 @@ function loadConfig(configPath) {
1137
2973
  }
1138
2974
  function loadHunt(huntName, configDir) {
1139
2975
  assertValidHuntName(huntName);
1140
- const huntPath = import_node_path3.default.join(configDir, "hunts", `${huntName}.yml`);
1141
- if (!import_node_fs3.default.existsSync(huntPath)) {
2976
+ const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
2977
+ if (!import_node_fs6.default.existsSync(huntPath)) {
1142
2978
  throw new Error(`Hunt file not found: ${huntPath}`);
1143
2979
  }
1144
- const raw = import_node_fs3.default.readFileSync(huntPath, "utf-8");
2980
+ const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
1145
2981
  const parsed = import_yaml.default.parse(raw) ?? {};
1146
2982
  const validated = huntSchema.parse(parsed);
1147
2983
  return validated;
1148
2984
  }
1149
2985
  function loadHuntTags(huntName, configDir) {
1150
2986
  assertValidHuntName(huntName);
1151
- const huntPath = import_node_path3.default.join(configDir, "hunts", `${huntName}.yml`);
1152
- if (!import_node_fs3.default.existsSync(huntPath)) {
2987
+ const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
2988
+ if (!import_node_fs6.default.existsSync(huntPath)) {
1153
2989
  return [];
1154
2990
  }
1155
- const raw = import_node_fs3.default.readFileSync(huntPath, "utf-8");
2991
+ const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
1156
2992
  const parsed = import_yaml.default.parse(raw) ?? {};
1157
2993
  return Array.isArray(parsed.tags) ? parsed.tags : [];
1158
2994
  }
1159
2995
  function loadHuntMeta(huntName, configDir) {
1160
2996
  assertValidHuntName(huntName);
1161
- const huntPath = import_node_path3.default.join(configDir, "hunts", `${huntName}.yml`);
1162
- if (!import_node_fs3.default.existsSync(huntPath)) {
2997
+ const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
2998
+ if (!import_node_fs6.default.existsSync(huntPath)) {
1163
2999
  return { tags: [] };
1164
3000
  }
1165
- const raw = import_node_fs3.default.readFileSync(huntPath, "utf-8");
3001
+ const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
1166
3002
  const parsed = import_yaml.default.parse(raw) ?? {};
1167
3003
  return {
1168
3004
  description: typeof parsed.description === "string" ? parsed.description : void 0,
@@ -1170,24 +3006,24 @@ function loadHuntMeta(huntName, configDir) {
1170
3006
  };
1171
3007
  }
1172
3008
  function listHunts(configDir) {
1173
- const huntsDir = import_node_path3.default.join(configDir, "hunts");
1174
- if (!import_node_fs3.default.existsSync(huntsDir)) {
3009
+ const huntsDir = import_node_path6.default.join(configDir, "hunts");
3010
+ if (!import_node_fs6.default.existsSync(huntsDir)) {
1175
3011
  return [];
1176
3012
  }
1177
- const stats = import_node_fs3.default.statSync(huntsDir);
3013
+ const stats = import_node_fs6.default.statSync(huntsDir);
1178
3014
  if (!stats.isDirectory()) {
1179
3015
  throw new Error(`Hunts path is not a directory: ${huntsDir}`);
1180
3016
  }
1181
3017
  const results = [];
1182
3018
  function scanDir(dir) {
1183
- const entries = import_node_fs3.default.readdirSync(dir, { withFileTypes: true });
3019
+ const entries = import_node_fs6.default.readdirSync(dir, { withFileTypes: true });
1184
3020
  for (const entry of entries) {
1185
3021
  if (entry.isFile() && entry.name.endsWith(".yml")) {
1186
- const fullPath = import_node_path3.default.join(dir, entry.name);
1187
- const relative = import_node_path3.default.relative(huntsDir, fullPath);
3022
+ const fullPath = import_node_path6.default.join(dir, entry.name);
3023
+ const relative = import_node_path6.default.relative(huntsDir, fullPath);
1188
3024
  results.push(relative.replace(/\.yml$/, ""));
1189
3025
  } else if (entry.isDirectory()) {
1190
- scanDir(import_node_path3.default.join(dir, entry.name));
3026
+ scanDir(import_node_path6.default.join(dir, entry.name));
1191
3027
  }
1192
3028
  }
1193
3029
  }
@@ -1562,8 +3398,8 @@ function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
1562
3398
  }
1563
3399
 
1564
3400
  // src/browser/playwright-driver.ts
1565
- var import_node_fs4 = __toESM(require("fs"), 1);
1566
- var import_node_path4 = __toESM(require("path"), 1);
3401
+ var import_node_fs7 = __toESM(require("fs"), 1);
3402
+ var import_node_path7 = __toESM(require("path"), 1);
1567
3403
  var import_playwright = require("playwright");
1568
3404
  var ENGINES = { chromium: import_playwright.chromium, firefox: import_playwright.firefox, webkit: import_playwright.webkit };
1569
3405
  async function launchBrowser(options) {
@@ -1585,14 +3421,14 @@ async function launchBrowser(options) {
1585
3421
  contextOptions.viewport = options.viewport;
1586
3422
  }
1587
3423
  if (options.storageStatePath) {
1588
- if (import_node_fs4.default.existsSync(options.storageStatePath)) {
3424
+ if (import_node_fs7.default.existsSync(options.storageStatePath)) {
1589
3425
  contextOptions.storageState = options.storageStatePath;
1590
3426
  } else {
1591
3427
  console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
1592
3428
  }
1593
3429
  }
1594
3430
  if (options.recordHar) {
1595
- contextOptions.recordHar = { path: import_node_path4.default.join(options.runDir, "network.har") };
3431
+ contextOptions.recordHar = { path: import_node_path7.default.join(options.runDir, "network.har") };
1596
3432
  }
1597
3433
  const context = await browser.newContext(contextOptions);
1598
3434
  const page = await context.newPage();
@@ -1600,7 +3436,7 @@ async function launchBrowser(options) {
1600
3436
  page.setDefaultNavigationTimeout(options.timeout);
1601
3437
  let tracePath;
1602
3438
  if (options.trace) {
1603
- tracePath = import_node_path4.default.join(options.runDir, "trace.zip");
3439
+ tracePath = import_node_path7.default.join(options.runDir, "trace.zip");
1604
3440
  await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
1605
3441
  }
1606
3442
  return { browser, context, page, tracePath };
@@ -1775,8 +3611,8 @@ function createPlaywrightDriver(page) {
1775
3611
  }
1776
3612
 
1777
3613
  // src/runner/steps.ts
1778
- var import_node_fs6 = __toESM(require("fs"), 1);
1779
- var import_node_path6 = __toESM(require("path"), 1);
3614
+ var import_node_fs9 = __toESM(require("fs"), 1);
3615
+ var import_node_path9 = __toESM(require("path"), 1);
1780
3616
 
1781
3617
  // src/runner/healing.ts
1782
3618
  var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
@@ -1820,8 +3656,8 @@ async function healSelector(probe, selector, options) {
1820
3656
  for (const candidate of buildHealCandidates(selector)) {
1821
3657
  let count;
1822
3658
  try {
1823
- const locator = probe.locator(candidate.selector);
1824
- count = await locator.count();
3659
+ const locator2 = probe.locator(candidate.selector);
3660
+ count = await locator2.count();
1825
3661
  } catch {
1826
3662
  continue;
1827
3663
  }
@@ -2266,7 +4102,7 @@ async function runInlineAssert(driver, policy, assertion) {
2266
4102
  throw new Error("assert step is missing an assertion type");
2267
4103
  }
2268
4104
  function screenshotPath(screenshotsDir, fileName) {
2269
- return import_node_path6.default.join(screenshotsDir, fileName);
4105
+ return import_node_path9.default.join(screenshotsDir, fileName);
2270
4106
  }
2271
4107
  function stepPath(prefix, index) {
2272
4108
  return prefix ? `${prefix}.${index}` : `${index}`;
@@ -2283,12 +4119,12 @@ function validateDownloadFilename(suggestedFilename) {
2283
4119
  const safeFilename = suggestedFilename.trim();
2284
4120
  const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
2285
4121
  const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
2286
- if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path6.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
4122
+ if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path9.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
2287
4123
  throw new Error(`Invalid download filename: "${suggestedFilename}"`);
2288
4124
  }
2289
4125
  return safeFilename;
2290
4126
  }
2291
- async function captureScreenshot(taker, filePath) {
4127
+ async function captureScreenshot2(taker, filePath) {
2292
4128
  try {
2293
4129
  await taker.screenshot({ path: filePath, fullPage: true });
2294
4130
  } catch (error) {
@@ -2463,7 +4299,7 @@ var STEP_HANDLERS = {
2463
4299
  if (!("setInputFiles" in h.step)) unknownStep();
2464
4300
  const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
2465
4301
  const rawFiles = h.step.setInputFiles.files;
2466
- const resolveFile = (f) => import_node_path6.default.isAbsolute(f) ? f : import_node_path6.default.join(h.context.configDir, f);
4302
+ const resolveFile = (f) => import_node_path9.default.isAbsolute(f) ? f : import_node_path9.default.join(h.context.configDir, f);
2467
4303
  const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
2468
4304
  await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
2469
4305
  h.policy.ensureLocationAllowed(h.driver);
@@ -2500,7 +4336,7 @@ var STEP_HANDLERS = {
2500
4336
  redactedFillSteps: subRedacted,
2501
4337
  randomVars
2502
4338
  } = interpolateHunt(subHunt, process.env, h.context.randomVars);
2503
- const subTargetType = h.driver.capabilities.has("navigate") ? "web" : "macos";
4339
+ const subTargetType = h.context.targetType ?? (h.driver.capabilities.has("navigate") ? "web" : "macos");
2504
4340
  assertStepsSupportedByTarget(interpolatedSubHunt.steps, subTargetType);
2505
4341
  assertHuntAssertionsSupportedByTarget(interpolatedSubHunt.assertions, subTargetType);
2506
4342
  h.policy.assertWithinMaxSteps(interpolatedSubHunt.steps.length, huntName);
@@ -2825,15 +4661,15 @@ var STEP_HANDLERS = {
2825
4661
  if (!responseFile) {
2826
4662
  throw new Error("mock.response must include either body or file");
2827
4663
  }
2828
- const candidateFilePath = import_node_path6.default.isAbsolute(responseFile) ? responseFile : import_node_path6.default.join(h.context.configDir, responseFile);
2829
- const resolvedConfigDir = import_node_path6.default.resolve(h.context.configDir);
2830
- const resolvedFilePath = import_node_path6.default.resolve(candidateFilePath);
2831
- const relativePath = import_node_path6.default.relative(resolvedConfigDir, resolvedFilePath);
2832
- const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path6.default.sep}`) && !import_node_path6.default.isAbsolute(relativePath);
4664
+ const candidateFilePath = import_node_path9.default.isAbsolute(responseFile) ? responseFile : import_node_path9.default.join(h.context.configDir, responseFile);
4665
+ const resolvedConfigDir = import_node_path9.default.resolve(h.context.configDir);
4666
+ const resolvedFilePath = import_node_path9.default.resolve(candidateFilePath);
4667
+ const relativePath = import_node_path9.default.relative(resolvedConfigDir, resolvedFilePath);
4668
+ const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path9.default.sep}`) && !import_node_path9.default.isAbsolute(relativePath);
2833
4669
  if (!isWithinConfigDir) {
2834
4670
  throw new Error("mock.response.file must resolve within config directory");
2835
4671
  }
2836
- responseBody = await import_node_fs6.default.promises.readFile(resolvedFilePath, "utf-8");
4672
+ responseBody = await import_node_fs9.default.promises.readFile(resolvedFilePath, "utf-8");
2837
4673
  }
2838
4674
  const contentType = mock.response.contentType ?? "application/json";
2839
4675
  const status = mock.response.status;
@@ -2896,8 +4732,8 @@ var STEP_HANDLERS = {
2896
4732
  capabilities: ["evaluate"],
2897
4733
  run: async (h) => {
2898
4734
  if (!("runScript" in h.step)) unknownStep();
2899
- const filePath = import_node_path6.default.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : import_node_path6.default.join(h.context.configDir, h.step.runScript.file);
2900
- const fileContents = import_node_fs6.default.readFileSync(filePath, "utf-8");
4735
+ const filePath = import_node_path9.default.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : import_node_path9.default.join(h.context.configDir, h.step.runScript.file);
4736
+ const fileContents = import_node_fs9.default.readFileSync(filePath, "utf-8");
2901
4737
  await h.driver.evaluate(fileContents);
2902
4738
  return {
2903
4739
  kind: "result",
@@ -2918,19 +4754,19 @@ var STEP_HANDLERS = {
2918
4754
  const name = h.step.assertScreenshot.name;
2919
4755
  const threshold = h.step.assertScreenshot.threshold ?? 0.1;
2920
4756
  const baselineDir = ensureBaselineDir2(h.context.configDir);
2921
- const baselinePath = import_node_path6.default.join(baselineDir, `${name}.png`);
2922
- const currentScreenshotPath = import_node_path6.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
2923
- import_node_fs6.default.mkdirSync(import_node_path6.default.dirname(currentScreenshotPath), { recursive: true });
4757
+ const baselinePath = import_node_path9.default.join(baselineDir, `${name}.png`);
4758
+ const currentScreenshotPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
4759
+ import_node_fs9.default.mkdirSync(import_node_path9.default.dirname(currentScreenshotPath), { recursive: true });
2924
4760
  await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
2925
- h.screenshots.push(import_node_path6.default.join("screenshots", `${name}-current.png`));
2926
- if (!import_node_fs6.default.existsSync(baselinePath)) {
2927
- import_node_fs6.default.copyFileSync(currentScreenshotPath, baselinePath);
4761
+ h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-current.png`));
4762
+ if (!import_node_fs9.default.existsSync(baselinePath)) {
4763
+ import_node_fs9.default.copyFileSync(currentScreenshotPath, baselinePath);
2928
4764
  return {
2929
4765
  kind: "result",
2930
4766
  result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
2931
4767
  };
2932
4768
  }
2933
- const diffPath = import_node_path6.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
4769
+ const diffPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
2934
4770
  const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
2935
4771
  if (comparison.match) {
2936
4772
  return {
@@ -2943,7 +4779,7 @@ var STEP_HANDLERS = {
2943
4779
  }
2944
4780
  };
2945
4781
  }
2946
- h.screenshots.push(import_node_path6.default.join("screenshots", `${name}-diff.png`));
4782
+ h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-diff.png`));
2947
4783
  throw new Error(
2948
4784
  `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
2949
4785
  );
@@ -2985,7 +4821,7 @@ var STEP_HANDLERS = {
2985
4821
  `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
2986
4822
  );
2987
4823
  }
2988
- const savePath = import_node_path6.default.join(h.context.runDir, suggestedFilename);
4824
+ const savePath = import_node_path9.default.join(h.context.runDir, suggestedFilename);
2989
4825
  await download.saveAs(savePath);
2990
4826
  return {
2991
4827
  kind: "result",
@@ -3014,8 +4850,8 @@ async function executeSteps(context) {
3014
4850
  maxSteps: context.maxSteps,
3015
4851
  selfHealing: context.selfHealing
3016
4852
  });
3017
- const screenshotsDir = import_node_path6.default.join(context.runDir, "screenshots");
3018
- import_node_fs6.default.mkdirSync(screenshotsDir, { recursive: true });
4853
+ const screenshotsDir = import_node_path9.default.join(context.runDir, "screenshots");
4854
+ import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
3019
4855
  const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
3020
4856
  policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
3021
4857
  const results = [];
@@ -3024,8 +4860,8 @@ async function executeSteps(context) {
3024
4860
  context.runStartedAtMs = runStartedAtMs;
3025
4861
  const addScreenshot = async (fileName) => {
3026
4862
  const fullPath = screenshotPath(screenshotsDir, fileName);
3027
- await captureScreenshot(driver, fullPath);
3028
- const relative = import_node_path6.default.join("screenshots", fileName);
4863
+ await captureScreenshot2(driver, fullPath);
4864
+ const relative = import_node_path9.default.join("screenshots", fileName);
3029
4865
  screenshots.push(relative);
3030
4866
  return relative;
3031
4867
  };
@@ -3113,12 +4949,12 @@ async function executeSteps(context) {
3113
4949
  return { results, screenshots, failed: false };
3114
4950
  }
3115
4951
  async function captureFinalScreenshot(page, runDir) {
3116
- const screenshotsDir = import_node_path6.default.join(runDir, "screenshots");
3117
- import_node_fs6.default.mkdirSync(screenshotsDir, { recursive: true });
4952
+ const screenshotsDir = import_node_path9.default.join(runDir, "screenshots");
4953
+ import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
3118
4954
  const fileName = "final.png";
3119
4955
  const filePath = screenshotPath(screenshotsDir, fileName);
3120
- await captureScreenshot(page, filePath);
3121
- return import_node_path6.default.join("screenshots", fileName);
4956
+ await captureScreenshot2(page, filePath);
4957
+ return import_node_path9.default.join("screenshots", fileName);
3122
4958
  }
3123
4959
 
3124
4960
  // src/runner/assertions.ts
@@ -3278,18 +5114,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
3278
5114
  }
3279
5115
 
3280
5116
  // src/reporter/result.ts
3281
- var import_node_fs7 = __toESM(require("fs"), 1);
3282
- var import_node_path7 = __toESM(require("path"), 1);
5117
+ var import_node_fs10 = __toESM(require("fs"), 1);
5118
+ var import_node_path10 = __toESM(require("path"), 1);
3283
5119
  function writeResult(runDir, result) {
3284
5120
  const fileName = "result.json";
3285
- const fullPath = import_node_path7.default.join(runDir, fileName);
3286
- import_node_fs7.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
5121
+ const fullPath = import_node_path10.default.join(runDir, fileName);
5122
+ import_node_fs10.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
3287
5123
  return fileName;
3288
5124
  }
3289
5125
 
3290
5126
  // src/reporter/summary.ts
3291
- var import_node_fs8 = __toESM(require("fs"), 1);
3292
- var import_node_path8 = __toESM(require("path"), 1);
5127
+ var import_node_fs11 = __toESM(require("fs"), 1);
5128
+ var import_node_path11 = __toESM(require("path"), 1);
3293
5129
  function escapeMd(text) {
3294
5130
  return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
3295
5131
  }
@@ -3367,15 +5203,15 @@ function writeSummary(runDir, result) {
3367
5203
  }
3368
5204
  }
3369
5205
  const fileName = "summary.md";
3370
- const fullPath = import_node_path8.default.join(runDir, fileName);
3371
- import_node_fs8.default.writeFileSync(fullPath, `${lines.join("\n")}
5206
+ const fullPath = import_node_path11.default.join(runDir, fileName);
5207
+ import_node_fs11.default.writeFileSync(fullPath, `${lines.join("\n")}
3372
5208
  `);
3373
5209
  return fileName;
3374
5210
  }
3375
5211
 
3376
5212
  // src/reporter/junit.ts
3377
- var import_node_fs9 = __toESM(require("fs"), 1);
3378
- var import_node_path9 = __toESM(require("path"), 1);
5213
+ var import_node_fs12 = __toESM(require("fs"), 1);
5214
+ var import_node_path12 = __toESM(require("path"), 1);
3379
5215
  function escapeXml(text) {
3380
5216
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3381
5217
  }
@@ -3419,8 +5255,8 @@ function writeJunit(runDir, result) {
3419
5255
  lines.push(" </testsuite>");
3420
5256
  lines.push("</testsuites>");
3421
5257
  const fileName = "junit.xml";
3422
- const fullPath = import_node_path9.default.join(runDir, fileName);
3423
- import_node_fs9.default.writeFileSync(fullPath, `${lines.join("\n")}
5258
+ const fullPath = import_node_path12.default.join(runDir, fileName);
5259
+ import_node_fs12.default.writeFileSync(fullPath, `${lines.join("\n")}
3424
5260
  `);
3425
5261
  return fileName;
3426
5262
  }
@@ -3454,15 +5290,15 @@ function timestamp(prefix) {
3454
5290
  }
3455
5291
 
3456
5292
  // src/runner/history.ts
3457
- var import_node_fs10 = __toESM(require("fs"), 1);
3458
- var import_node_path10 = __toESM(require("path"), 1);
5293
+ var import_node_fs13 = __toESM(require("fs"), 1);
5294
+ var import_node_path13 = __toESM(require("path"), 1);
3459
5295
  var HISTORY_FILE = "history.json";
3460
5296
  var LOCK_FILE_SUFFIX = ".lock";
3461
5297
  var LOCK_RETRY_MS = 10;
3462
5298
  var LOCK_TIMEOUT_MS = 5e3;
3463
5299
  var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
3464
5300
  function historyPath(configDir) {
3465
- return import_node_path10.default.join(configDir, HISTORY_FILE);
5301
+ return import_node_path13.default.join(configDir, HISTORY_FILE);
3466
5302
  }
3467
5303
  function isHistoryEntry(value) {
3468
5304
  if (!value || typeof value !== "object") {
@@ -3473,11 +5309,11 @@ function isHistoryEntry(value) {
3473
5309
  }
3474
5310
  function readHistory(configDir) {
3475
5311
  const filePath = historyPath(configDir);
3476
- if (!import_node_fs10.default.existsSync(filePath)) {
5312
+ if (!import_node_fs13.default.existsSync(filePath)) {
3477
5313
  return { entries: [] };
3478
5314
  }
3479
5315
  try {
3480
- const raw = import_node_fs10.default.readFileSync(filePath, "utf-8");
5316
+ const raw = import_node_fs13.default.readFileSync(filePath, "utf-8");
3481
5317
  const parsed = JSON.parse(raw);
3482
5318
  if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
3483
5319
  const validatedEntries = parsed.entries.filter(isHistoryEntry);
@@ -3516,12 +5352,12 @@ function sleepSync(ms) {
3516
5352
  function withHistoryLock(configDir, fn) {
3517
5353
  const filePath = historyPath(configDir);
3518
5354
  const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
3519
- import_node_fs10.default.mkdirSync(import_node_path10.default.dirname(filePath), { recursive: true });
5355
+ import_node_fs13.default.mkdirSync(import_node_path13.default.dirname(filePath), { recursive: true });
3520
5356
  const startedAt = Date.now();
3521
5357
  while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
3522
5358
  let fd;
3523
5359
  try {
3524
- fd = import_node_fs10.default.openSync(lockPath, "wx");
5360
+ fd = import_node_fs13.default.openSync(lockPath, "wx");
3525
5361
  } catch (error) {
3526
5362
  if (error.code === "EEXIST") {
3527
5363
  sleepSync(LOCK_RETRY_MS);
@@ -3533,10 +5369,10 @@ function withHistoryLock(configDir, fn) {
3533
5369
  return fn();
3534
5370
  } finally {
3535
5371
  try {
3536
- import_node_fs10.default.closeSync(fd);
5372
+ import_node_fs13.default.closeSync(fd);
3537
5373
  } catch {
3538
5374
  }
3539
- import_node_fs10.default.rmSync(lockPath, { force: true });
5375
+ import_node_fs13.default.rmSync(lockPath, { force: true });
3540
5376
  }
3541
5377
  }
3542
5378
  throw new Error(
@@ -3549,9 +5385,9 @@ function appendEntry(configDir, entry, maxRuns) {
3549
5385
  const current = readHistory(configDir);
3550
5386
  const next = pruneEntries([...current.entries, entry], maxRuns);
3551
5387
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
3552
- import_node_fs10.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
5388
+ import_node_fs13.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
3553
5389
  `);
3554
- import_node_fs10.default.renameSync(tempPath, filePath);
5390
+ import_node_fs13.default.renameSync(tempPath, filePath);
3555
5391
  });
3556
5392
  }
3557
5393
 
@@ -3564,11 +5400,11 @@ function parseViewportFlag(value) {
3564
5400
  return value;
3565
5401
  }
3566
5402
  function resolvePath(configDir, inputPath) {
3567
- if (import_node_path11.default.isAbsolute(inputPath)) {
5403
+ if (import_node_path14.default.isAbsolute(inputPath)) {
3568
5404
  return inputPath;
3569
5405
  }
3570
- const projectRoot = import_node_path11.default.dirname(configDir);
3571
- return import_node_path11.default.join(projectRoot, inputPath);
5406
+ const projectRoot = import_node_path14.default.dirname(configDir);
5407
+ return import_node_path14.default.join(projectRoot, inputPath);
3572
5408
  }
3573
5409
  function buildRunResult(options) {
3574
5410
  return {
@@ -3587,12 +5423,12 @@ function buildRunResult(options) {
3587
5423
  }
3588
5424
  function writeConsoleLog(runDir, entries) {
3589
5425
  const fileName = "console.log";
3590
- const filePath = import_node_path11.default.join(runDir, fileName);
5426
+ const filePath = import_node_path14.default.join(runDir, fileName);
3591
5427
  const lines = entries.map((entry) => {
3592
5428
  const location = entry.location ? ` (${entry.location})` : "";
3593
5429
  return `[${entry.type}] ${entry.text}${location}`;
3594
5430
  });
3595
- import_node_fs11.default.writeFileSync(filePath, `${lines.join("\n")}
5431
+ import_node_fs14.default.writeFileSync(filePath, `${lines.join("\n")}
3596
5432
  `);
3597
5433
  return fileName;
3598
5434
  }
@@ -3600,8 +5436,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
3600
5436
  const headless = options.headed ? false : config.browser.headless;
3601
5437
  const slowMo = options.slowMo ?? config.browser.slowMo;
3602
5438
  const maxSteps = config.guardrails.maxSteps;
3603
- const runDir = import_node_path11.default.join(configDir, "runs", timestamp());
3604
- import_node_fs11.default.mkdirSync(runDir, { recursive: true });
5439
+ const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
5440
+ import_node_fs14.default.mkdirSync(runDir, { recursive: true });
3605
5441
  const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
3606
5442
  const engine = options.browser ?? config.browser.engine;
3607
5443
  const channel = options.channel ?? config.browser.channel;
@@ -3718,7 +5554,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
3718
5554
  }
3719
5555
  return { result, runDir, steps: interpolatedHunt.steps };
3720
5556
  }
3721
- function delay(ms) {
5557
+ function delay3(ms) {
3722
5558
  return new Promise((resolve) => setTimeout(resolve, ms));
3723
5559
  }
3724
5560
  async function runHunt(options) {
@@ -3726,6 +5562,12 @@ async function runHunt(options) {
3726
5562
  if (config.target.type === "macos") {
3727
5563
  return runMacHunt(options, config, configDir, config.target);
3728
5564
  }
5565
+ if (config.target.type === "android") {
5566
+ return runAndroidHunt(options, config, configDir, config.target);
5567
+ }
5568
+ if (config.target.type === "ios") {
5569
+ return runIosHunt(options, config, configDir, config.target);
5570
+ }
3729
5571
  const hunt = loadHunt(options.huntName, configDir);
3730
5572
  const {
3731
5573
  hunt: interpolatedHunt,
@@ -3747,7 +5589,7 @@ async function runHunt(options) {
3747
5589
  let lastResult;
3748
5590
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
3749
5591
  if (attempt > 0 && retryDelay > 0) {
3750
- await delay(retryDelay);
5592
+ await delay3(retryDelay);
3751
5593
  }
3752
5594
  lastResult = await executeHuntAttempt(
3753
5595
  options,
@@ -3776,19 +5618,17 @@ async function runHunt(options) {
3776
5618
  }
3777
5619
  return lastResult;
3778
5620
  }
3779
- async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
5621
+ async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
3780
5622
  const maxSteps = config.guardrails.maxSteps;
3781
- const runDir = import_node_path11.default.join(configDir, "runs", timestamp());
3782
- import_node_fs11.default.mkdirSync(runDir, { recursive: true });
3783
- const session = await launchMacSession({
3784
- app: target.app,
3785
- timeoutMs: config.browser.timeout,
3786
- clientFactory: options.macClientFactory
3787
- });
5623
+ const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
5624
+ import_node_fs14.default.mkdirSync(runDir, { recursive: true });
5625
+ const session = await native.launchSession();
3788
5626
  let result;
3789
5627
  try {
3790
- const targetLabel = `macos:${session.bundleId}`;
3791
- const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, target.app, session.bundleId])];
5628
+ const driver = native.sessionDriver(session);
5629
+ const appIdentity = native.sessionAppIdentity(session);
5630
+ const targetLabel = `${native.targetType}:${appIdentity}`;
5631
+ const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
3792
5632
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3793
5633
  const startTime = Date.now();
3794
5634
  let stepResults = [];
@@ -3796,7 +5636,8 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3796
5636
  let stepFailed = false;
3797
5637
  try {
3798
5638
  const stepExecution = await executeSteps({
3799
- driver: session.driver,
5639
+ driver,
5640
+ targetType: native.targetType,
3800
5641
  steps: interpolatedHunt.steps,
3801
5642
  targetUrl: targetLabel,
3802
5643
  runDir,
@@ -3823,7 +5664,7 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3823
5664
  }
3824
5665
  let finalScreenshot;
3825
5666
  try {
3826
- finalScreenshot = await captureFinalScreenshot(session.driver, runDir);
5667
+ finalScreenshot = await captureFinalScreenshot(driver, runDir);
3827
5668
  } catch {
3828
5669
  finalScreenshot = void 0;
3829
5670
  }
@@ -3844,16 +5685,116 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3844
5685
  });
3845
5686
  result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
3846
5687
  } finally {
3847
- await closeMacSession(session);
5688
+ await native.closeSession(session);
3848
5689
  }
3849
5690
  return { result, runDir, steps: interpolatedHunt.steps };
3850
5691
  }
5692
+ async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
5693
+ return executeNativeHuntAttempt(
5694
+ options,
5695
+ config,
5696
+ configDir,
5697
+ interpolatedHunt,
5698
+ redactedFillSteps,
5699
+ randomVars,
5700
+ allowedApps,
5701
+ {
5702
+ targetType: "macos",
5703
+ targetApp: target.app,
5704
+ launchSession: () => launchMacSession({
5705
+ app: target.app,
5706
+ timeoutMs: config.browser.timeout,
5707
+ clientFactory: options.macClientFactory
5708
+ }),
5709
+ closeSession: closeMacSession,
5710
+ sessionDriver: (session) => session.driver,
5711
+ sessionAppIdentity: (session) => session.bundleId
5712
+ }
5713
+ );
5714
+ }
3851
5715
  async function runMacHunt(options, config, configDir, target) {
5716
+ return runNativeHunt(options, config, configDir, target, {
5717
+ targetType: "macos",
5718
+ assertAppAllowed: (allowedApps, nativeTarget) => assertTargetAppAllowed(allowedApps, nativeTarget.app),
5719
+ attempt: executeMacHuntAttempt
5720
+ });
5721
+ }
5722
+ async function executeAndroidHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
5723
+ const launch = options.androidSessionFactory ?? launchAndroidSession;
5724
+ return executeNativeHuntAttempt(
5725
+ options,
5726
+ config,
5727
+ configDir,
5728
+ interpolatedHunt,
5729
+ redactedFillSteps,
5730
+ randomVars,
5731
+ allowedApps,
5732
+ {
5733
+ targetType: "android",
5734
+ targetApp: target.app,
5735
+ launchSession: () => launch({
5736
+ app: target.app,
5737
+ deviceSerial: target.deviceSerial,
5738
+ coldStart: target.coldStart,
5739
+ timeoutMs: config.browser.timeout,
5740
+ allowedApps
5741
+ }),
5742
+ closeSession: closeAndroidSession,
5743
+ sessionDriver: (session) => session.driver,
5744
+ sessionAppIdentity: (session) => session.package
5745
+ }
5746
+ );
5747
+ }
5748
+ async function runAndroidHunt(options, config, configDir, target) {
5749
+ return runNativeHunt(options, config, configDir, target, {
5750
+ targetType: "android",
5751
+ assertAppAllowed: (allowedApps, nativeTarget) => {
5752
+ if (!nativeTarget.app.toLowerCase().endsWith(".apk")) {
5753
+ assertAndroidAppAllowed(allowedApps, nativeTarget.app);
5754
+ }
5755
+ },
5756
+ attempt: executeAndroidHuntAttempt
5757
+ });
5758
+ }
5759
+ async function executeIosHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
5760
+ const launch = options.iosSessionFactory ?? launchIosSession;
5761
+ return executeNativeHuntAttempt(
5762
+ options,
5763
+ config,
5764
+ configDir,
5765
+ interpolatedHunt,
5766
+ redactedFillSteps,
5767
+ randomVars,
5768
+ allowedApps,
5769
+ {
5770
+ targetType: "ios",
5771
+ targetApp: target.app,
5772
+ launchSession: () => launch({
5773
+ app: target.app,
5774
+ udid: target.udid,
5775
+ coldStart: target.coldStart,
5776
+ timeoutMs: config.browser.timeout,
5777
+ allowedApps
5778
+ }),
5779
+ closeSession: closeIosSession,
5780
+ sessionDriver: (session) => session.driver,
5781
+ sessionAppIdentity: (session) => session.bundleId
5782
+ }
5783
+ );
5784
+ }
5785
+ async function runIosHunt(options, config, configDir, target) {
5786
+ return runNativeHunt(options, config, configDir, target, {
5787
+ targetType: "ios",
5788
+ assertAppAllowed: (allowedApps, nativeTarget) => assertIosAppAllowed(allowedApps, nativeTarget.app),
5789
+ attempt: executeIosHuntAttempt
5790
+ });
5791
+ }
5792
+ async function runNativeHunt(options, config, configDir, target, native) {
3852
5793
  const hunt = loadHunt(options.huntName, configDir);
3853
5794
  const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
3854
- assertStepsSupportedByTarget(interpolatedHunt.steps, "macos");
3855
- assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, "macos");
3856
- assertTargetAppAllowed(config.guardrails.allowedApps, target.app);
5795
+ assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
5796
+ assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
5797
+ native.assertAppAllowed(config.guardrails.allowedApps, target);
3857
5798
  const maxSteps = config.guardrails.maxSteps;
3858
5799
  if (interpolatedHunt.steps.length > maxSteps) {
3859
5800
  throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
@@ -3863,9 +5804,9 @@ async function runMacHunt(options, config, configDir, target) {
3863
5804
  let lastResult;
3864
5805
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
3865
5806
  if (attempt > 0 && retryDelay > 0) {
3866
- await delay(retryDelay);
5807
+ await delay3(retryDelay);
3867
5808
  }
3868
- lastResult = await executeMacHuntAttempt(
5809
+ lastResult = await native.attempt(
3869
5810
  options,
3870
5811
  config,
3871
5812
  configDir,
@@ -3893,7 +5834,7 @@ async function runMacHunt(options, config, configDir, target) {
3893
5834
  }
3894
5835
  function recordHistory(configDir, outcome, maxRuns) {
3895
5836
  try {
3896
- const relativeRunDir = import_node_path11.default.relative(configDir, outcome.runDir);
5837
+ const relativeRunDir = import_node_path14.default.relative(configDir, outcome.runDir);
3897
5838
  appendEntry(
3898
5839
  configDir,
3899
5840
  {
@@ -3910,11 +5851,11 @@ function recordHistory(configDir, outcome, maxRuns) {
3910
5851
  }
3911
5852
 
3912
5853
  // src/runner/suite.ts
3913
- var import_node_path14 = __toESM(require("path"), 1);
5854
+ var import_node_path17 = __toESM(require("path"), 1);
3914
5855
 
3915
5856
  // src/reporter/ci-summary.ts
3916
- var import_node_fs12 = __toESM(require("fs"), 1);
3917
- var import_node_path12 = __toESM(require("path"), 1);
5857
+ var import_node_fs15 = __toESM(require("fs"), 1);
5858
+ var import_node_path15 = __toESM(require("path"), 1);
3918
5859
  var import_chalk = __toESM(require("chalk"), 1);
3919
5860
  function countCiResults(results) {
3920
5861
  return {
@@ -3944,9 +5885,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
3944
5885
  ...flaky.length > 0 ? { flaky } : {},
3945
5886
  ...clusters.length > 0 ? { clusters } : {}
3946
5887
  };
3947
- import_node_fs12.default.mkdirSync(ciRunDir, { recursive: true });
3948
- const filePath = import_node_path12.default.join(ciRunDir, "ci-result.json");
3949
- import_node_fs12.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
5888
+ import_node_fs15.default.mkdirSync(ciRunDir, { recursive: true });
5889
+ const filePath = import_node_path15.default.join(ciRunDir, "ci-result.json");
5890
+ import_node_fs15.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
3950
5891
  return filePath;
3951
5892
  }
3952
5893
 
@@ -4080,8 +6021,8 @@ function clusterFailures(failures) {
4080
6021
  }
4081
6022
 
4082
6023
  // src/backlog/index.ts
4083
- var import_node_fs13 = __toESM(require("fs"), 1);
4084
- var import_node_path13 = __toESM(require("path"), 1);
6024
+ var import_node_fs16 = __toESM(require("fs"), 1);
6025
+ var import_node_path16 = __toESM(require("path"), 1);
4085
6026
 
4086
6027
  // src/backlog/parse.ts
4087
6028
  var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
@@ -4171,7 +6112,7 @@ ${after}`;
4171
6112
  // src/backlog/index.ts
4172
6113
  function readFileOrEmpty(filePath) {
4173
6114
  try {
4174
- return import_node_fs13.default.readFileSync(filePath, "utf-8");
6115
+ return import_node_fs16.default.readFileSync(filePath, "utf-8");
4175
6116
  } catch (error) {
4176
6117
  const err = error;
4177
6118
  if (err.code === "ENOENT") return "";
@@ -4187,7 +6128,7 @@ function buildFailure(hunt) {
4187
6128
  if (!hunt.runDir) return failure;
4188
6129
  let run;
4189
6130
  try {
4190
- const resultJson = readFileOrEmpty(import_node_path13.default.join(hunt.runDir, "result.json"));
6131
+ const resultJson = readFileOrEmpty(import_node_path16.default.join(hunt.runDir, "result.json"));
4191
6132
  if (!resultJson) return failure;
4192
6133
  run = JSON.parse(resultJson);
4193
6134
  } catch (error) {
@@ -4216,8 +6157,8 @@ function extractFailures(suiteResult) {
4216
6157
  }
4217
6158
  function updateBacklogFromSuite(suiteResult, options = {}) {
4218
6159
  const projectRoot = options.projectRoot ?? process.cwd();
4219
- const backlogPath = options.backlogPath ?? import_node_path13.default.join(projectRoot, "docs", "backlog.md");
4220
- const resolvedPath = options.resolvedPath ?? import_node_path13.default.join(projectRoot, "docs", "resolved.md");
6160
+ const backlogPath = options.backlogPath ?? import_node_path16.default.join(projectRoot, "docs", "backlog.md");
6161
+ const resolvedPath = options.resolvedPath ?? import_node_path16.default.join(projectRoot, "docs", "resolved.md");
4221
6162
  const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
4222
6163
  const summary = { created: [], regressions: [], skipped: [], backlogPath };
4223
6164
  const failures = extractFailures(suiteResult);
@@ -4249,8 +6190,8 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
4249
6190
  }
4250
6191
  }
4251
6192
  if (ticketsToAdd.length > 0) {
4252
- import_node_fs13.default.mkdirSync(import_node_path13.default.dirname(backlogPath), { recursive: true });
4253
- import_node_fs13.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
6193
+ import_node_fs16.default.mkdirSync(import_node_path16.default.dirname(backlogPath), { recursive: true });
6194
+ import_node_fs16.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
4254
6195
  }
4255
6196
  return summary;
4256
6197
  }
@@ -4411,7 +6352,7 @@ async function runSuite(options = {}) {
4411
6352
  const clusters = clusterFailures(
4412
6353
  extractFailures({ result: { hunts: results }, resultPath: null })
4413
6354
  ).filter((cluster) => cluster.count > 1);
4414
- const ciRunDir = import_node_path14.default.join(configDir, "runs", timestamp("ci"));
6355
+ const ciRunDir = import_node_path17.default.join(configDir, "runs", timestamp("ci"));
4415
6356
  const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
4416
6357
  const { passed, failed, skipped } = countCiResults(results);
4417
6358
  return {
@@ -4546,6 +6487,134 @@ async function analyzePage(page) {
4546
6487
  };
4547
6488
  }
4548
6489
 
6490
+ // src/analyzer/mac.ts
6491
+ var INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
6492
+ "AXButton",
6493
+ "AXTextField",
6494
+ "AXSecureTextField",
6495
+ "AXTextArea",
6496
+ "AXCheckBox",
6497
+ "AXRadioButton",
6498
+ "AXPopUpButton",
6499
+ "AXMenuButton",
6500
+ "AXLink",
6501
+ "AXMenuItem",
6502
+ "AXComboBox",
6503
+ "AXSlider",
6504
+ "AXDisclosureTriangle"
6505
+ ]);
6506
+ var DEFAULT_ANALYZE_TREE_DEPTH = 20;
6507
+ function str(value) {
6508
+ if (typeof value !== "string") {
6509
+ return void 0;
6510
+ }
6511
+ const trimmed = value.trim();
6512
+ return trimmed.length > 0 ? trimmed : void 0;
6513
+ }
6514
+ function toNode(raw) {
6515
+ const node = raw ?? {};
6516
+ const children = Array.isArray(node.children) ? node.children.map(toNode) : void 0;
6517
+ return {
6518
+ role: str(node.role),
6519
+ title: str(node.title),
6520
+ description: str(node.description),
6521
+ value: str(node.value),
6522
+ identifier: str(node.identifier),
6523
+ enabled: typeof node.enabled === "boolean" ? node.enabled : void 0,
6524
+ ...children ? { children } : {}
6525
+ };
6526
+ }
6527
+ function quote(value) {
6528
+ return `"${value}"`;
6529
+ }
6530
+ function rankMacSelectors(node) {
6531
+ const selectors = [];
6532
+ const identifier = node.identifier;
6533
+ const exactLabel = node.title ?? node.description;
6534
+ const name = node.title ?? node.description ?? node.value;
6535
+ if (identifier) {
6536
+ selectors.push(`id=${identifier}`);
6537
+ }
6538
+ if (exactLabel) {
6539
+ selectors.push(`label=${quote(exactLabel)}`);
6540
+ }
6541
+ if (node.role && name) {
6542
+ selectors.push(`role=${node.role}[name=${quote(name)}]`);
6543
+ }
6544
+ if (name) {
6545
+ selectors.push(`text=${quote(name)}`);
6546
+ }
6547
+ if (selectors.length === 0 && node.role) {
6548
+ selectors.push(`role=${node.role}`);
6549
+ }
6550
+ return selectors;
6551
+ }
6552
+ function toElement(node, source) {
6553
+ return {
6554
+ role: node.role ?? "?",
6555
+ ...node.title ? { title: node.title } : {},
6556
+ ...node.description ? { description: node.description } : {},
6557
+ ...node.value ? { value: node.value } : {},
6558
+ ...node.identifier ? { identifier: node.identifier } : {},
6559
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
6560
+ source,
6561
+ selectors: rankMacSelectors(node)
6562
+ };
6563
+ }
6564
+ function collectInteractive(root) {
6565
+ const out = [];
6566
+ const visit = (node) => {
6567
+ if (node.role && INTERACTIVE_ROLES.has(node.role)) {
6568
+ out.push(toElement(node, "window"));
6569
+ }
6570
+ for (const child of node.children ?? []) {
6571
+ visit(child);
6572
+ }
6573
+ };
6574
+ visit(root);
6575
+ return out;
6576
+ }
6577
+ function toWindow(node) {
6578
+ const [best] = rankMacSelectors(node);
6579
+ return {
6580
+ ...node.title ? { title: node.title } : {},
6581
+ ...node.identifier ? { identifier: node.identifier } : {},
6582
+ selector: best ?? "role=AXWindow"
6583
+ };
6584
+ }
6585
+ async function analyzeMacApp(client, options) {
6586
+ const depth = options.treeDepth ?? DEFAULT_ANALYZE_TREE_DEPTH;
6587
+ const treeResult = await client.request("tree", { depth });
6588
+ const elements = collectInteractive(toNode(treeResult.tree));
6589
+ const windowsResult = await client.request("windows");
6590
+ const rawWindows = Array.isArray(windowsResult.windows) ? windowsResult.windows : [];
6591
+ const windows = rawWindows.map((raw) => toWindow(toNode(raw)));
6592
+ const menuItems = await readStatusMenu(client, options.menuTimeoutSeconds);
6593
+ return { app: options.app, elements, windows, menuItems };
6594
+ }
6595
+ async function readStatusMenu(client, menuTimeoutSeconds) {
6596
+ let statusItems;
6597
+ try {
6598
+ const status = await client.request("statusItems");
6599
+ statusItems = Array.isArray(status.items) ? status.items : [];
6600
+ } catch {
6601
+ return [];
6602
+ }
6603
+ if (statusItems.length === 0) {
6604
+ return [];
6605
+ }
6606
+ const params = menuTimeoutSeconds !== void 0 ? { timeout: menuTimeoutSeconds } : {};
6607
+ try {
6608
+ const menu = await client.request("openMenu", params);
6609
+ const rawItems = Array.isArray(menu.items) ? menu.items : [];
6610
+ return rawItems.map((raw) => toNode(raw)).filter((node) => node.role !== "AXMenuItem" || node.title || node.identifier || node.description).map((node) => toElement(node, "menu"));
6611
+ } catch {
6612
+ return [];
6613
+ } finally {
6614
+ await client.request("closeMenu").catch(() => void 0);
6615
+ }
6616
+ }
6617
+
4549
6618
  // src/generator/index.ts
4550
6619
  var import_yaml2 = __toESM(require("yaml"), 1);
4551
6620
 
@@ -4766,41 +6835,103 @@ async function generateHunt(options) {
4766
6835
  }
4767
6836
  // Annotate the CommonJS export names for ESM import in node:
4768
6837
  0 && (module.exports = {
6838
+ ANDROID_KEYCODES,
6839
+ DEFAULT_AGENT_REQUEST_TIMEOUT_MS,
6840
+ DEFAULT_ANALYZE_TREE_DEPTH,
4769
6841
  DEFAULT_FLAKY_THRESHOLD,
4770
6842
  DEFAULT_REQUEST_TIMEOUT_MS,
6843
+ DEFAULT_SIMULATOR_LOCK_ROOT,
6844
+ DEFAULT_WDA_REQUEST_TIMEOUT_MS,
6845
+ INTERACTIVE_ROLES,
6846
+ IOS_PRESS_KEYS,
4771
6847
  SpawnMacHelperClient,
6848
+ UIA2_REMOTE_PORT,
6849
+ Uia2HttpError,
6850
+ Uia2Transport,
6851
+ WDA_RUNNER_APP_NAME,
6852
+ WDA_RUNNER_BUNDLE_ID,
4772
6853
  WEB_ONLY_STEP_TYPES,
6854
+ WdaHttpError,
6855
+ WdaTransport,
6856
+ analyzeMacApp,
4773
6857
  analyzePage,
6858
+ androidAppAllowedIdentities,
6859
+ androidQueryToLocator,
6860
+ assertAndroidAppAllowed,
6861
+ assertIosAppAllowed,
4774
6862
  assertStepsSupportedByTarget,
4775
6863
  assertTargetAppAllowed,
6864
+ bootedDevices,
6865
+ bootedSimulators,
4776
6866
  buildHealCandidates,
6867
+ closeAndroidSession,
6868
+ closeIosSession,
4777
6869
  closeMacSession,
4778
6870
  clusterFailures,
4779
6871
  computeFlakeScore,
4780
6872
  configSchema,
6873
+ createAndroidDriver,
6874
+ createIosDriver,
4781
6875
  createMacDriver,
6876
+ createUia2AgentClient,
6877
+ createUia2Session,
6878
+ createWdaAgentClient,
6879
+ createWdaSession,
6880
+ defaultAgentConnector,
6881
+ defaultIosAgentConnector,
6882
+ escapePredicateArg,
6883
+ escapeUiSelectorArg,
6884
+ extractElementId,
4782
6885
  extractFailures,
4783
6886
  extractSelectorIntent,
6887
+ findFreePort,
4784
6888
  generateHunt,
4785
6889
  healSelector,
4786
6890
  huntSchema,
4787
6891
  interpolateHunt,
6892
+ iosAppAllowedIdentities,
6893
+ iosQueryToLocator,
6894
+ launchAndroidSession,
6895
+ launchIosSession,
4788
6896
  launchMacSession,
6897
+ listDevices,
4789
6898
  listHunts,
6899
+ listSimulators,
4790
6900
  loadConfig,
4791
6901
  loadHunt,
4792
6902
  loadHuntMeta,
4793
6903
  loadHuntTags,
4794
6904
  macdriverBuildInstructions,
6905
+ normalizeXcuiClassName,
6906
+ parseAaptPackage,
6907
+ parseAdbDevices,
6908
+ parseAndroidSelector,
6909
+ parseForwardPort,
6910
+ parseIosSelector,
4795
6911
  parseMacSelector,
6912
+ parseSimctlDevices,
6913
+ parseXcodeVersion,
4796
6914
  rankFlaky,
6915
+ rankMacSelectors,
4797
6916
  readHistory,
4798
6917
  readHuntHistory,
6918
+ readIosBundleIdentifier,
6919
+ reserveSimulatorUdid,
6920
+ resolveAgentApks,
4799
6921
  resolveHelperBinary,
6922
+ resolveWdaProject,
6923
+ resolveWdaRunner,
4800
6924
  runHunt,
4801
6925
  runSuite,
6926
+ selectDeviceSerial,
6927
+ selectSimulatorUdid,
4802
6928
  stepSchema,
6929
+ unwrapAndroidTextSelector,
6930
+ unwrapIosTextSelector,
4803
6931
  updateBacklogFromSuite,
6932
+ waitForAgentReady,
6933
+ waitForWdaReady,
6934
+ wdaCacheDir,
4804
6935
  webOnlyReason
4805
6936
  });
4806
6937
  //# sourceMappingURL=lib.cjs.map