prowl-tools 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,137 @@ var init_visual = __esm({
86
86
  // src/index.ts
87
87
  var src_exports = {};
88
88
  __export(src_exports, {
89
+ ANDROID_INTERACTIVE_CLASSES: () => ANDROID_INTERACTIVE_CLASSES,
90
+ ANDROID_KEYCODES: () => ANDROID_KEYCODES,
91
+ ANDROID_MATCH_DIALECT: () => ANDROID_MATCH_DIALECT,
92
+ DEFAULT_AGENT_REQUEST_TIMEOUT_MS: () => DEFAULT_AGENT_REQUEST_TIMEOUT_MS,
93
+ DEFAULT_ANALYZE_TREE_DEPTH: () => DEFAULT_ANALYZE_TREE_DEPTH,
89
94
  DEFAULT_FLAKY_THRESHOLD: () => DEFAULT_FLAKY_THRESHOLD,
90
95
  DEFAULT_REQUEST_TIMEOUT_MS: () => DEFAULT_REQUEST_TIMEOUT_MS,
96
+ DEFAULT_SIMULATOR_LOCK_ROOT: () => DEFAULT_SIMULATOR_LOCK_ROOT,
97
+ DEFAULT_WDA_REQUEST_TIMEOUT_MS: () => DEFAULT_WDA_REQUEST_TIMEOUT_MS,
98
+ INTERACTIVE_ROLES: () => INTERACTIVE_ROLES,
99
+ IOS_INTERACTIVE_TYPES: () => IOS_INTERACTIVE_TYPES,
100
+ IOS_MATCH_DIALECT: () => IOS_MATCH_DIALECT,
101
+ IOS_PRESS_KEYS: () => IOS_PRESS_KEYS,
102
+ IOS_WINDOW_TYPE: () => IOS_WINDOW_TYPE,
103
+ MACOS_MATCH_DIALECT: () => MACOS_MATCH_DIALECT,
104
+ NATIVE_ATTRIBUTE_MAP: () => NATIVE_ATTRIBUTE_MAP,
91
105
  SpawnMacHelperClient: () => SpawnMacHelperClient,
106
+ UIA2_REMOTE_PORT: () => UIA2_REMOTE_PORT,
107
+ Uia2HttpError: () => Uia2HttpError,
108
+ Uia2Transport: () => Uia2Transport,
109
+ WDA_RUNNER_BUNDLE_ID: () => WDA_RUNNER_BUNDLE_ID,
110
+ WDA_USE_PORT_ENV: () => WDA_USE_PORT_ENV,
92
111
  WEB_ONLY_STEP_TYPES: () => WEB_ONLY_STEP_TYPES,
112
+ WdaHttpError: () => WdaHttpError,
113
+ WdaTransport: () => WdaTransport,
114
+ analyzeAndroidApp: () => analyzeAndroidApp,
115
+ analyzeIosApp: () => analyzeIosApp,
116
+ analyzeMacApp: () => analyzeMacApp,
93
117
  analyzePage: () => analyzePage,
118
+ androidAppAllowedIdentities: () => androidAppAllowedIdentities,
119
+ androidNodeToNative: () => androidNodeToNative,
120
+ androidQueryToLocator: () => androidQueryToLocator,
121
+ assertAndroidAppAllowed: () => assertAndroidAppAllowed,
122
+ assertIosAppAllowed: () => assertIosAppAllowed,
94
123
  assertStepsSupportedByTarget: () => assertStepsSupportedByTarget,
95
124
  assertTargetAppAllowed: () => assertTargetAppAllowed,
125
+ bootedDevices: () => bootedDevices,
126
+ bootedSimulators: () => bootedSimulators,
96
127
  buildHealCandidates: () => buildHealCandidates,
128
+ closeAndroidSession: () => closeAndroidSession,
129
+ closeIosSession: () => closeIosSession,
97
130
  closeMacSession: () => closeMacSession,
98
131
  clusterFailures: () => clusterFailures,
99
132
  computeFlakeScore: () => computeFlakeScore,
100
133
  configSchema: () => configSchema,
134
+ createAndroidDriver: () => createAndroidDriver,
135
+ createIosDriver: () => createIosDriver,
101
136
  createMacDriver: () => createMacDriver,
137
+ createUia2AgentClient: () => createUia2AgentClient,
138
+ createUia2Session: () => createUia2Session,
139
+ createWdaAgentClient: () => createWdaAgentClient,
140
+ createWdaSession: () => createWdaSession,
141
+ decodeXmlEntities: () => decodeXmlEntities,
142
+ defaultAgentConnector: () => defaultAgentConnector,
143
+ defaultIosAgentConnector: () => defaultIosAgentConnector,
144
+ defaultWdaTestRunPreparer: () => defaultWdaTestRunPreparer,
145
+ escapePredicateArg: () => escapePredicateArg,
146
+ escapeUiSelectorArg: () => escapeUiSelectorArg,
147
+ extractElementId: () => extractElementId,
102
148
  extractFailures: () => extractFailures,
103
149
  extractSelectorIntent: () => extractSelectorIntent,
150
+ findFreePort: () => findFreePort,
104
151
  generateHunt: () => generateHunt,
105
152
  healSelector: () => healSelector,
106
153
  huntSchema: () => huntSchema,
154
+ injectUsePortIntoXctestrun: () => injectUsePortIntoXctestrun,
107
155
  interpolateHunt: () => interpolateHunt,
156
+ iosAppAllowedIdentities: () => iosAppAllowedIdentities,
157
+ iosNodeToNative: () => iosNodeToNative,
158
+ iosQueryToLocator: () => iosQueryToLocator,
159
+ isAndroidInteractive: () => isAndroidInteractive,
160
+ isIosInteractive: () => isIosInteractive,
161
+ launchAndroidSession: () => launchAndroidSession,
162
+ launchIosSession: () => launchIosSession,
108
163
  launchMacSession: () => launchMacSession,
164
+ listDevices: () => listDevices,
109
165
  listHunts: () => listHunts,
166
+ listSimulators: () => listSimulators,
110
167
  loadConfig: () => loadConfig,
111
168
  loadHunt: () => loadHunt,
112
169
  loadHuntMeta: () => loadHuntMeta,
113
170
  loadHuntTags: () => loadHuntTags,
114
171
  macdriverBuildInstructions: () => macdriverBuildInstructions,
172
+ matchAndroidSelector: () => matchAndroidSelector,
173
+ matchIosSelector: () => matchIosSelector,
174
+ matchNativeTree: () => matchNativeTree,
175
+ nodeMatchesSelector: () => nodeMatchesSelector,
176
+ normalizeXcuiClassName: () => normalizeXcuiClassName,
177
+ parseAaptPackage: () => parseAaptPackage,
178
+ parseAdbDevices: () => parseAdbDevices,
179
+ parseAndroidHierarchy: () => parseAndroidHierarchy,
180
+ parseAndroidSelector: () => parseAndroidSelector,
181
+ parseForwardPort: () => parseForwardPort,
182
+ parseIosHierarchy: () => parseIosHierarchy,
183
+ parseIosSelector: () => parseIosSelector,
115
184
  parseMacSelector: () => parseMacSelector,
185
+ parseNativeSelector: () => parseNativeSelector,
186
+ parseSimctlDevices: () => parseSimctlDevices,
187
+ parseSnapshot: () => parseSnapshot,
188
+ parseXcodeVersion: () => parseXcodeVersion,
189
+ parseXml: () => parseXml,
190
+ qualifyResourceId: () => qualifyResourceId,
191
+ quoteSelectorValue: () => quoteSelectorValue,
192
+ rankAndroidSelectors: () => rankAndroidSelectors,
116
193
  rankFlaky: () => rankFlaky,
194
+ rankIosSelectors: () => rankIosSelectors,
195
+ rankMacSelectors: () => rankMacSelectors,
196
+ rankNativeSelectors: () => rankNativeSelectors,
117
197
  readHistory: () => readHistory,
118
198
  readHuntHistory: () => readHuntHistory,
199
+ readIosBundleIdentifier: () => readIosBundleIdentifier,
200
+ reserveSimulatorUdid: () => reserveSimulatorUdid,
201
+ resolveAgentApks: () => resolveAgentApks,
119
202
  resolveHelperBinary: () => resolveHelperBinary,
203
+ resolveWdaProject: () => resolveWdaProject,
204
+ resolveWdaTestRun: () => resolveWdaTestRun,
120
205
  runHunt: () => runHunt,
121
206
  runSuite: () => runSuite,
207
+ selectDeviceSerial: () => selectDeviceSerial,
208
+ selectSimulatorUdid: () => selectSimulatorUdid,
209
+ shortIosType: () => shortIosType,
122
210
  stepSchema: () => stepSchema,
211
+ unquoteSelectorValue: () => unquoteSelectorValue,
212
+ unwrapAndroidTextSelector: () => unwrapAndroidTextSelector,
213
+ unwrapIosTextSelector: () => unwrapIosTextSelector,
214
+ unwrapNativeTextSelector: () => unwrapNativeTextSelector,
123
215
  updateBacklogFromSuite: () => updateBacklogFromSuite,
216
+ waitForAgentReady: () => waitForAgentReady,
217
+ waitForWdaReady: () => waitForWdaReady,
218
+ wdaCacheDir: () => wdaCacheDir,
219
+ wdaTestRunArgs: () => wdaTestRunArgs,
124
220
  webOnlyReason: () => webOnlyReason
125
221
  });
126
222
  module.exports = __toCommonJS(src_exports);
@@ -273,7 +369,11 @@ function createMacDriver(client, options = {}) {
273
369
  return rejectUnsupported("evalScript");
274
370
  },
275
371
  async screenshot(screenshotOptions) {
276
- await client.request("screenshot", { path: screenshotOptions.path });
372
+ const result = await client.request("screenshot", { path: screenshotOptions.path });
373
+ const warning = result.warning;
374
+ if (typeof warning === "string" && warning.length > 0) {
375
+ console.warn(`macOS screenshot fell back to full screen: ${warning}`);
376
+ }
277
377
  },
278
378
  // network / dialogs / downloads (all web-only) -------------------------
279
379
  onResponse(_handler) {
@@ -504,147 +604,2313 @@ async function closeMacSession(session) {
504
604
  }
505
605
  }
506
606
 
507
- // src/config/target.ts
508
- var import_node_child_process2 = require("child_process");
607
+ // src/browser/android-driver.ts
509
608
  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
609
+
610
+ // src/analyzer/xml.ts
611
+ var ENTITIES = {
612
+ amp: "&",
613
+ lt: "<",
614
+ gt: ">",
615
+ quot: '"',
616
+ apos: "'"
617
+ };
618
+ function decodeXmlEntities(value) {
619
+ if (!value.includes("&")) {
620
+ return value;
621
+ }
622
+ return value.replace(/&(#(?:[xX][0-9a-fA-F]+|[0-9]+)|[a-zA-Z]+);/g, (match, code) => {
623
+ if (code[0] === "#") {
624
+ const hex = code[1] === "x" || code[1] === "X";
625
+ const num2 = Number.parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10);
626
+ return Number.isSafeInteger(num2) && num2 <= 1114111 ? String.fromCodePoint(num2) : match;
627
+ }
628
+ const named = ENTITIES[code];
629
+ return named ?? match;
630
+ });
631
+ }
632
+ var ATTR_RE = /([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
633
+ function parseTagBody(body) {
634
+ const trimmed = body.trim();
635
+ const nameMatch = /^([^\s/>]+)/.exec(trimmed);
636
+ const tag = nameMatch ? nameMatch[1] : "";
637
+ const attrs = {};
638
+ const rest = trimmed.slice(tag.length);
639
+ ATTR_RE.lastIndex = 0;
640
+ let m;
641
+ while ((m = ATTR_RE.exec(rest)) !== null) {
642
+ const rawValue = m[3] !== void 0 ? m[3] : m[4] ?? "";
643
+ attrs[m[1]] = decodeXmlEntities(rawValue);
644
+ }
645
+ return { tag, attrs };
646
+ }
647
+ function parseXml(input) {
648
+ const n = input.length;
649
+ const stack = [];
650
+ let root = null;
651
+ let i = 0;
652
+ while (i < n) {
653
+ const lt = input.indexOf("<", i);
654
+ if (lt < 0) {
655
+ break;
656
+ }
657
+ i = lt + 1;
658
+ const ch = input[i];
659
+ if (ch === "?") {
660
+ const end = input.indexOf("?>", i);
661
+ i = end < 0 ? n : end + 2;
662
+ continue;
663
+ }
664
+ if (ch === "!") {
665
+ if (input.startsWith("!--", i)) {
666
+ const end = input.indexOf("-->", i);
667
+ i = end < 0 ? n : end + 3;
668
+ } else {
669
+ const end = input.indexOf(">", i);
670
+ i = end < 0 ? n : end + 1;
671
+ }
672
+ continue;
673
+ }
674
+ if (ch === "/") {
675
+ const gt = input.indexOf(">", i);
676
+ i = gt < 0 ? n : gt + 1;
677
+ stack.pop();
678
+ continue;
679
+ }
680
+ let j = i;
681
+ let quote2 = null;
682
+ while (j < n) {
683
+ const c = input[j];
684
+ if (quote2 !== null) {
685
+ if (c === quote2) {
686
+ quote2 = null;
687
+ }
688
+ } else if (c === '"' || c === "'") {
689
+ quote2 = c;
690
+ } else if (c === ">") {
691
+ break;
692
+ }
693
+ j += 1;
694
+ }
695
+ const inner = input.slice(i, j);
696
+ i = j + 1;
697
+ const selfClose = inner.endsWith("/");
698
+ const body = selfClose ? inner.slice(0, -1) : inner;
699
+ const { tag, attrs } = parseTagBody(body);
700
+ if (tag.length === 0) {
701
+ continue;
702
+ }
703
+ const element = { tag, attrs, children: [] };
704
+ const parent = stack[stack.length - 1];
705
+ if (parent) {
706
+ parent.children.push(element);
707
+ }
708
+ if (root === null) {
709
+ root = element;
710
+ }
711
+ if (!selfClose) {
712
+ stack.push(element);
713
+ }
714
+ }
715
+ return root;
716
+ }
717
+
718
+ // src/selector/native.ts
719
+ function unquoteSelectorValue(value) {
720
+ const trimmed = value.trim();
721
+ const first = trimmed[0];
722
+ if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
723
+ return trimmed.slice(1, -1);
724
+ }
725
+ return trimmed;
726
+ }
727
+ function quoteSelectorValue(value) {
728
+ return `"${value}"`;
729
+ }
730
+ var ROLE_SELECTOR_RE = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s;
731
+ var NATIVE_SELECTOR_PREFIX_RE = /^(id|label|text|role)=/;
732
+ function invalidSelectorMessage(selector, reason) {
733
+ return `Invalid native selector ${JSON.stringify(selector)}: ${reason}.`;
734
+ }
735
+ function parseNativeSelector(selector) {
736
+ const trimmed = selector.trim();
737
+ if (trimmed.length === 0) {
738
+ throw new Error(
739
+ invalidSelectorMessage(
740
+ selector,
741
+ "selector is empty; use id=, label=, text=, role=, :focus, or a bare text value"
742
+ )
743
+ );
744
+ }
745
+ if (trimmed === ":focus") {
746
+ return { kind: "focused" };
747
+ }
748
+ const idMatch = /^id=(.+)$/s.exec(trimmed);
749
+ if (idMatch) {
750
+ return { kind: "id", value: unquoteSelectorValue(idMatch[1]) };
751
+ }
752
+ const roleMatch = ROLE_SELECTOR_RE.exec(trimmed);
753
+ if (roleMatch) {
754
+ const name = roleMatch[2] !== void 0 ? unquoteSelectorValue(roleMatch[2]) : void 0;
755
+ return name !== void 0 && name.length > 0 ? { kind: "role", role: roleMatch[1], name } : { kind: "role", role: roleMatch[1] };
756
+ }
757
+ const labelMatch = /^label=(.+)$/s.exec(trimmed);
758
+ if (labelMatch) {
759
+ return { kind: "label", value: unquoteSelectorValue(labelMatch[1]) };
760
+ }
761
+ const textMatch = /^text=(.+)$/s.exec(trimmed);
762
+ if (textMatch) {
763
+ return { kind: "text", value: unquoteSelectorValue(textMatch[1]) };
764
+ }
765
+ const prefix = NATIVE_SELECTOR_PREFIX_RE.exec(trimmed);
766
+ if (prefix) {
767
+ throw new Error(
768
+ invalidSelectorMessage(
769
+ selector,
770
+ `malformed ${prefix[1]}= selector; expected id=<value>, label=<value>, text=<value>, or role=<Type>[name=<value>]`
771
+ )
772
+ );
773
+ }
774
+ return { kind: "text", value: trimmed };
775
+ }
776
+ function unwrapNativeTextSelector(selector) {
777
+ const trimmed = selector.trim();
778
+ if (!trimmed.startsWith("text=")) {
779
+ return null;
780
+ }
781
+ return unquoteSelectorValue(trimmed.slice("text=".length));
782
+ }
783
+ function qualifyResourceId(value, appPackage) {
784
+ if (value.includes(":") || !appPackage) {
785
+ return value;
786
+ }
787
+ return `${appPackage}:id/${value}`;
788
+ }
789
+ function normalizeXcuiClassName(role) {
790
+ return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
791
+ }
792
+ function shortIosType(type) {
793
+ return type.startsWith("XCUIElementType") ? type.slice("XCUIElementType".length) : type;
794
+ }
795
+ function rankNativeSelectors(fields) {
796
+ const selectors = [];
797
+ if (fields.id) {
798
+ selectors.push(`id=${fields.id}`);
799
+ }
800
+ if (fields.label) {
801
+ selectors.push(`label=${quoteSelectorValue(fields.label)}`);
802
+ }
803
+ if (fields.role && fields.name) {
804
+ selectors.push(`role=${fields.role}[name=${quoteSelectorValue(fields.name)}]`);
805
+ }
806
+ if (fields.name) {
807
+ selectors.push(`text=${quoteSelectorValue(fields.name)}`);
808
+ }
809
+ if (selectors.length === 0 && fields.role) {
810
+ selectors.push(`role=${fields.role}`);
811
+ }
812
+ return selectors;
813
+ }
814
+ var NATIVE_ATTRIBUTE_MAP = {
815
+ android: {
816
+ id: { attribute: "resource-id", match: "exact (package-qualified)" },
817
+ label: { attribute: "content-desc", match: "exact" },
818
+ text: { attribute: "text", match: "substring" },
819
+ role: { attribute: "class", match: "exact" }
820
+ },
821
+ ios: {
822
+ id: { attribute: "accessibility id (name)", match: "exact" },
823
+ label: { attribute: "label", match: "exact" },
824
+ text: { attribute: "label | value", match: "substring" },
825
+ role: { attribute: "type (XCUIElementType\u2026)", match: "exact" }
826
+ },
827
+ macos: {
828
+ id: { attribute: "AXIdentifier", match: "exact" },
829
+ label: { attribute: "title | description", match: "exact" },
830
+ text: { attribute: "title | description | value", match: "substring" },
831
+ role: { attribute: "AXRole", match: "exact" }
832
+ }
833
+ };
834
+ var ANDROID_MATCH_DIALECT = {
835
+ platform: "android",
836
+ normalizeRole: (role) => role,
837
+ normalizeId: (value, options) => qualifyResourceId(value, options.appPackage)
838
+ };
839
+ var IOS_MATCH_DIALECT = {
840
+ platform: "ios",
841
+ normalizeRole: (role) => normalizeXcuiClassName(role),
842
+ normalizeId: (value) => value
843
+ };
844
+ var MACOS_MATCH_DIALECT = {
845
+ platform: "macos",
846
+ normalizeRole: (role) => role,
847
+ normalizeId: (value) => value
848
+ };
849
+ function nodeMatchesSelector(dialect, selector, node, options = {}) {
850
+ switch (selector.kind) {
851
+ case "id":
852
+ return node.id !== void 0 && node.id === dialect.normalizeId(selector.value, options);
853
+ case "label":
854
+ return node.label !== void 0 && node.label === selector.value;
855
+ case "text":
856
+ return node.textValues.some((t) => t.includes(selector.value));
857
+ case "focused":
858
+ return node.focused === true;
859
+ case "role": {
860
+ if (node.role === void 0) {
861
+ return false;
862
+ }
863
+ if (dialect.normalizeRole(node.role) !== dialect.normalizeRole(selector.role)) {
864
+ return false;
865
+ }
866
+ if (selector.name === void 0 || selector.name.length === 0) {
867
+ return true;
868
+ }
869
+ const name = selector.name;
870
+ return node.textValues.some((t) => t.includes(name));
871
+ }
872
+ }
873
+ }
874
+ function matchNativeTree(dialect, selector, root, project, children, options = {}) {
875
+ const out = [];
876
+ const visit = (node) => {
877
+ if (nodeMatchesSelector(dialect, selector, project(node), options)) {
878
+ out.push(node);
879
+ }
880
+ for (const child of children(node)) {
881
+ visit(child);
882
+ }
883
+ };
884
+ visit(root);
885
+ return out;
886
+ }
887
+ function parseSnapshot(xml) {
888
+ return parseXml(xml);
889
+ }
890
+
891
+ // src/browser/android-driver.ts
892
+ var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
893
+ "query",
894
+ "interact",
895
+ "wait",
896
+ "screenshot"
526
897
  ]);
527
- function webOnlyReason(step) {
528
- for (const type of WEB_ONLY_STEP_TYPES) {
529
- if (type in step) {
530
- return type;
898
+ var WAIT_POLL_INTERVAL_MS = 250;
899
+ var DEFAULT_WAIT_TIMEOUT_MS = 5e3;
900
+ var ANDROID_KEYCODES = {
901
+ enter: 66,
902
+ return: 66,
903
+ tab: 61,
904
+ space: 62,
905
+ backspace: 67,
906
+ delete: 67,
907
+ del: 67,
908
+ escape: 111,
909
+ esc: 111,
910
+ back: 4,
911
+ home: 3,
912
+ menu: 82,
913
+ search: 84,
914
+ up: 19,
915
+ arrowup: 19,
916
+ down: 20,
917
+ arrowdown: 20,
918
+ left: 21,
919
+ arrowleft: 21,
920
+ right: 22,
921
+ arrowright: 22,
922
+ pageup: 92,
923
+ pagedown: 93
924
+ };
925
+ function escapeUiSelectorArg(value) {
926
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
927
+ }
928
+ function toAndroidQuery(selector) {
929
+ switch (selector.kind) {
930
+ case "focused":
931
+ return { by: "focused" };
932
+ case "id":
933
+ return { by: "id", value: selector.value };
934
+ case "role":
935
+ return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
936
+ case "label":
937
+ return { by: "accessibilityId", value: selector.value };
938
+ case "text":
939
+ return { by: "text", value: selector.value };
940
+ }
941
+ }
942
+ function parseAndroidSelector(selector) {
943
+ return toAndroidQuery(parseNativeSelector(selector));
944
+ }
945
+ function locator(strategy, selector) {
946
+ return { strategy, selector, context: "" };
947
+ }
948
+ function androidQueryToLocator(query, options = {}) {
949
+ switch (query.by) {
950
+ case "id":
951
+ return locator("id", qualifyResourceId(query.value, options.appPackage));
952
+ case "accessibilityId":
953
+ return locator("accessibility id", query.value);
954
+ case "text":
955
+ return locator(
956
+ "-android uiautomator",
957
+ `new UiSelector().textContains("${escapeUiSelectorArg(query.value)}")`
958
+ );
959
+ case "focused":
960
+ return locator("-android uiautomator", "new UiSelector().focused(true)");
961
+ case "role": {
962
+ const className = escapeUiSelectorArg(query.role);
963
+ if (query.name === void 0 || query.name.length === 0) {
964
+ return locator("class name", query.role);
965
+ }
966
+ return locator(
967
+ "-android uiautomator",
968
+ `new UiSelector().className("${className}").textContains("${escapeUiSelectorArg(query.name)}")`
969
+ );
531
970
  }
532
971
  }
533
- if ("assert" in step) {
534
- const assertion = step.assert;
535
- if (assertion.urlIncludes !== void 0 || assertion.urlEquals !== void 0) {
536
- return "assert (url)";
972
+ }
973
+ function unwrapAndroidTextSelector(selector) {
974
+ return unwrapNativeTextSelector(selector);
975
+ }
976
+ function keyCodeFor(key) {
977
+ const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
978
+ if (code === void 0) {
979
+ throw new Error(
980
+ `Unsupported key "${key}" for the Android target. Supported keys: ${Object.keys(ANDROID_KEYCODES).sort().join(", ")}.`
981
+ );
982
+ }
983
+ return code;
984
+ }
985
+ function delay(ms) {
986
+ return new Promise((resolve) => {
987
+ setTimeout(resolve, ms);
988
+ });
989
+ }
990
+ function createAndroidDriver(client, options = {}) {
991
+ const unsupported = (verb) => new Error(`${verb} is not supported by the Android target`);
992
+ const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
993
+ async function resolveOne(selector) {
994
+ const id = await client.findElement(parseAndroidSelector(selector));
995
+ if (id === null) {
996
+ throw new Error(`No element matched selector: ${selector}`);
537
997
  }
998
+ return id;
538
999
  }
539
- return null;
1000
+ async function clickSelector(selector) {
1001
+ await client.click(await resolveOne(selector));
1002
+ }
1003
+ async function fillSelector(selector, value) {
1004
+ await client.setValue(await resolveOne(selector), value);
1005
+ }
1006
+ return {
1007
+ capabilities: ANDROID_CAPABILITIES,
1008
+ // navigation -----------------------------------------------------------
1009
+ goto(_url, _options) {
1010
+ return rejectUnsupported("navigate");
1011
+ },
1012
+ currentUrl() {
1013
+ return `android:${options.appLabel ?? ""}`;
1014
+ },
1015
+ // queries --------------------------------------------------------------
1016
+ async count(selector) {
1017
+ return (await client.findElements(parseAndroidSelector(selector))).length;
1018
+ },
1019
+ async textContent(selector) {
1020
+ const id = await client.findElement(parseAndroidSelector(selector));
1021
+ if (id === null) {
1022
+ return null;
1023
+ }
1024
+ return client.getText(id);
1025
+ },
1026
+ // interactions ---------------------------------------------------------
1027
+ click: clickSelector,
1028
+ clickFirst: clickSelector,
1029
+ fill: fillSelector,
1030
+ fillFirst: fillSelector,
1031
+ async press(_selector, key) {
1032
+ await client.pressKeyCode(keyCodeFor(key));
1033
+ },
1034
+ selectOption() {
1035
+ return rejectUnsupported("select");
1036
+ },
1037
+ selectOptionFirst() {
1038
+ return rejectUnsupported("select");
1039
+ },
1040
+ hover() {
1041
+ return rejectUnsupported("hover");
1042
+ },
1043
+ scrollIntoView() {
1044
+ return rejectUnsupported("scrollTo");
1045
+ },
1046
+ setInputFiles() {
1047
+ return rejectUnsupported("setInputFiles");
1048
+ },
1049
+ // semantic locators ----------------------------------------------------
1050
+ async countByRole(role, name) {
1051
+ return (await client.findElements({ by: "role", role, name })).length;
1052
+ },
1053
+ async clickFirstByRole(role, name) {
1054
+ const id = await client.findElement({ by: "role", role, name });
1055
+ if (id === null) {
1056
+ throw new Error(`No element matched role=${role}[name="${name}"]`);
1057
+ }
1058
+ await client.click(id);
1059
+ },
1060
+ async countByLabel(label) {
1061
+ return (await client.findElements({ by: "accessibilityId", value: label })).length;
1062
+ },
1063
+ async fillFirstByLabel(label, value) {
1064
+ const id = await client.findElement({ by: "accessibilityId", value: label });
1065
+ if (id === null) {
1066
+ throw new Error(`No element matched label="${label}"`);
1067
+ }
1068
+ await client.setValue(id, value);
1069
+ },
1070
+ selectOptionFirstByLabel() {
1071
+ return rejectUnsupported("select");
1072
+ },
1073
+ // waiting --------------------------------------------------------------
1074
+ async waitForSelector(selector, waitOptions) {
1075
+ const query = parseAndroidSelector(selector);
1076
+ const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS;
1077
+ const deadline = Date.now() + timeoutMs;
1078
+ for (; ; ) {
1079
+ if ((await client.findElements(query)).length > 0) {
1080
+ return;
1081
+ }
1082
+ if (Date.now() >= deadline) {
1083
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
1084
+ }
1085
+ await delay(Math.min(WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
1086
+ }
1087
+ },
1088
+ waitForUrl() {
1089
+ return rejectUnsupported("waitForUrl");
1090
+ },
1091
+ waitForNetworkIdle() {
1092
+ return rejectUnsupported("waitForNetworkIdle");
1093
+ },
1094
+ // scripting & artifacts ------------------------------------------------
1095
+ evaluate() {
1096
+ return rejectUnsupported("evalScript");
1097
+ },
1098
+ async screenshot(screenshotOptions) {
1099
+ const png = await client.screenshotPng();
1100
+ import_node_fs2.default.writeFileSync(screenshotOptions.path, png);
1101
+ },
1102
+ // network / dialogs / downloads (all web-only) -------------------------
1103
+ onResponse(_handler) {
1104
+ throw unsupported("onResponse");
1105
+ },
1106
+ route(_url, _handler) {
1107
+ return rejectUnsupported("mockRoute");
1108
+ },
1109
+ unroute() {
1110
+ return rejectUnsupported("unmockRoute");
1111
+ },
1112
+ onDialog(_action) {
1113
+ throw unsupported("onDialog");
1114
+ },
1115
+ waitForDownloadEvent() {
1116
+ return rejectUnsupported("waitForDownload");
1117
+ },
1118
+ parseTextSelector(selector) {
1119
+ return unwrapAndroidTextSelector(selector);
1120
+ }
1121
+ };
540
1122
  }
541
- function assertStepsSupportedByTarget(steps, target) {
542
- if (target !== "macos") {
543
- return;
1123
+
1124
+ // src/browser/android-helper.ts
1125
+ var import_node_module = require("module");
1126
+ var import_node_child_process4 = require("child_process");
1127
+ var import_node_fs4 = __toESM(require("fs"), 1);
1128
+ var import_node_path3 = __toESM(require("path"), 1);
1129
+
1130
+ // src/browser/android-adb.ts
1131
+ var import_node_child_process2 = require("child_process");
1132
+ var execFileAdbRunner = (args, options) => new Promise((resolve) => {
1133
+ (0, import_node_child_process2.execFile)(
1134
+ "adb",
1135
+ args,
1136
+ { encoding: "utf-8", timeout: options?.timeoutMs, maxBuffer: 16 * 1024 * 1024 },
1137
+ (error, stdout, stderr) => {
1138
+ const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
1139
+ const capturedStderr = stderr ?? "";
1140
+ resolve({
1141
+ stdout: stdout ?? "",
1142
+ stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
1143
+ code
1144
+ });
1145
+ }
1146
+ );
1147
+ });
1148
+ var spawnAdbProcess = (args) => {
1149
+ const child = (0, import_node_child_process2.spawn)("adb", args, { stdio: "ignore" });
1150
+ child.on("error", () => {
1151
+ });
1152
+ return { kill: () => child.kill() };
1153
+ };
1154
+ function withSerial(serial, args) {
1155
+ return serial ? ["-s", serial, ...args] : args;
1156
+ }
1157
+ function parseAdbDevices(stdout) {
1158
+ const devices = [];
1159
+ for (const rawLine of stdout.split(/\r?\n/)) {
1160
+ const line = rawLine.trim();
1161
+ if (!line || /^list of devices attached/i.test(line)) {
1162
+ continue;
1163
+ }
1164
+ const [serial, state, ...rest] = line.split(/\s+/);
1165
+ if (!serial || !state) {
1166
+ continue;
1167
+ }
1168
+ const description = {};
1169
+ for (const token of rest) {
1170
+ const eq = token.indexOf(":");
1171
+ if (eq > 0) {
1172
+ description[token.slice(0, eq)] = token.slice(eq + 1);
1173
+ }
1174
+ }
1175
+ devices.push({ serial, state, description });
544
1176
  }
545
- for (const step of steps) {
546
- const reason = webOnlyReason(step);
547
- if (reason) {
1177
+ return devices;
1178
+ }
1179
+ function bootedDevices(devices) {
1180
+ return devices.filter((device) => device.state === "device");
1181
+ }
1182
+ function selectDeviceSerial(devices, requested) {
1183
+ const booted = bootedDevices(devices);
1184
+ if (requested) {
1185
+ const match = devices.find((device) => device.serial === requested);
1186
+ if (!match) {
1187
+ const attached = devices.length > 0 ? devices.map((d) => d.serial).join(", ") : "none";
548
1188
  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.).`
1189
+ `Android device "${requested}" is not attached. Attached devices: ${attached}. Check \`adb devices -l\`.`
550
1190
  );
551
1191
  }
552
- if ("if" in step) {
553
- assertStepsSupportedByTarget(step.if.then, target);
554
- if (step.if.else) {
555
- assertStepsSupportedByTarget(step.if.else, target);
1192
+ if (match.state !== "device") {
1193
+ throw new Error(
1194
+ `Android device "${requested}" is present but not ready (state: ${match.state}). Boot or authorize it, then retry.`
1195
+ );
1196
+ }
1197
+ return requested;
1198
+ }
1199
+ if (booted.length === 0) {
1200
+ throw new Error(
1201
+ "No booted Android device found. Start an emulator or connect a device with USB debugging, then confirm it appears in `adb devices -l`."
1202
+ );
1203
+ }
1204
+ if (booted.length > 1) {
1205
+ throw new Error(
1206
+ `Multiple Android devices attached (${booted.map((d) => d.serial).join(", ")}). Set target.deviceSerial to pick one.`
1207
+ );
1208
+ }
1209
+ return booted[0].serial;
1210
+ }
1211
+ async function listDevices(runner) {
1212
+ const result = await runner(["devices", "-l"], { timeoutMs: 1e4 });
1213
+ if (result.code !== 0) {
1214
+ throw new Error(
1215
+ `\`adb devices\` failed (exit ${result.code}). Is adb on PATH and the server running? ` + (result.stderr.trim() || "").slice(0, 400)
1216
+ );
1217
+ }
1218
+ return parseAdbDevices(result.stdout);
1219
+ }
1220
+ function parseForwardPort(stdout) {
1221
+ const port = Number.parseInt(stdout.trim(), 10);
1222
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
1223
+ throw new Error(`Could not parse a forwarded port from adb output: "${stdout.trim()}"`);
1224
+ }
1225
+ return port;
1226
+ }
1227
+ function parseAaptPackage(stdout) {
1228
+ const match = /package:\s*name='([^']+)'/.exec(stdout);
1229
+ return match?.[1] ?? null;
1230
+ }
1231
+ async function forwardDynamicPort(runner, serial, remotePort) {
1232
+ const result = await runner(withSerial(serial, ["forward", "tcp:0", `tcp:${remotePort}`]), {
1233
+ timeoutMs: 1e4
1234
+ });
1235
+ if (result.code !== 0) {
1236
+ throw new Error(`adb forward failed (exit ${result.code}): ${result.stderr.trim()}`);
1237
+ }
1238
+ return parseForwardPort(result.stdout);
1239
+ }
1240
+ async function removeForward(runner, serial, localPort) {
1241
+ await runner(withSerial(serial, ["forward", "--remove", `tcp:${localPort}`]), { timeoutMs: 1e4 }).catch(
1242
+ () => void 0
1243
+ );
1244
+ }
1245
+ async function installApk(runner, serial, apkPath) {
1246
+ const result = await runner(withSerial(serial, ["install", "-r", "-t", "-g", apkPath]), {
1247
+ timeoutMs: 12e4
1248
+ });
1249
+ if (result.code !== 0 || /failure/i.test(result.stdout)) {
1250
+ throw new Error(
1251
+ `Failed to install APK "${apkPath}" (exit ${result.code}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
1252
+ );
1253
+ }
1254
+ }
1255
+ function formatUnknownError(error) {
1256
+ return error instanceof Error ? error.message : String(error);
1257
+ }
1258
+ function runnerPhaseError(message, error) {
1259
+ return new Error(`${message}: ${formatUnknownError(error)}`, { cause: error });
1260
+ }
1261
+ function parseResolvedComponent(stdout, pkg) {
1262
+ const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
1263
+ for (let i = lines.length - 1; i >= 0; i--) {
1264
+ if (/^[\w.]+\/[\w.$]+$/.test(lines[i]) && lines[i].startsWith(`${pkg}/`)) {
1265
+ return lines[i];
1266
+ }
1267
+ }
1268
+ return null;
1269
+ }
1270
+ async function launchPackage(runner, serial, pkg) {
1271
+ let resolved;
1272
+ try {
1273
+ resolved = await runner(
1274
+ withSerial(serial, [
1275
+ "shell",
1276
+ "cmd",
1277
+ "package",
1278
+ "resolve-activity",
1279
+ "--brief",
1280
+ "-c",
1281
+ "android.intent.category.LAUNCHER",
1282
+ pkg
1283
+ ]),
1284
+ { timeoutMs: 3e4 }
1285
+ );
1286
+ } catch (error) {
1287
+ throw runnerPhaseError(
1288
+ `Failed to resolve the launcher activity for Android package "${pkg}" via adb`,
1289
+ error
1290
+ );
1291
+ }
1292
+ const component = parseResolvedComponent(resolved.stdout, pkg);
1293
+ if (resolved.code !== 0 || !component) {
1294
+ throw new Error(
1295
+ `Could not resolve a launcher activity for Android package "${pkg}" (exit ${resolved.code})${(resolved.stdout + resolved.stderr).trim() ? `: ${(resolved.stdout + resolved.stderr).trim().slice(0, 300)}` : ""}. Is it installed?`
1296
+ );
1297
+ }
1298
+ let start;
1299
+ try {
1300
+ start = await runner(withSerial(serial, ["shell", "am", "start", "-n", component]), {
1301
+ timeoutMs: 3e4
1302
+ });
1303
+ } catch (error) {
1304
+ throw runnerPhaseError(
1305
+ `Failed to launch Android package "${pkg}" with \`am start\` (${component}) via adb`,
1306
+ error
1307
+ );
1308
+ }
1309
+ if (start.code !== 0 || /error|does not exist|cannot start/i.test(start.stdout + start.stderr)) {
1310
+ throw new Error(
1311
+ `Failed to launch Android package "${pkg}" (${component}, exit ${start.code}): ${(start.stdout + start.stderr).trim().slice(0, 400)}.`
1312
+ );
1313
+ }
1314
+ }
1315
+ async function forceStop(runner, serial, pkg) {
1316
+ await runner(withSerial(serial, ["shell", "am", "force-stop", pkg]), { timeoutMs: 3e4 });
1317
+ }
1318
+ async function clearPackage(runner, serial, pkg) {
1319
+ const result = await runner(withSerial(serial, ["shell", "pm", "clear", pkg]), { timeoutMs: 3e4 });
1320
+ if (result.code !== 0 || !/success/i.test(result.stdout)) {
1321
+ throw new Error(
1322
+ `Failed to clear Android package "${pkg}" for cold start (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 300)}`
1323
+ );
1324
+ }
1325
+ }
1326
+ function startInstrumentation(spawner, serial) {
1327
+ return spawner(
1328
+ withSerial(serial, [
1329
+ "shell",
1330
+ "am",
1331
+ "instrument",
1332
+ "-w",
1333
+ "-e",
1334
+ "disableAnalytics",
1335
+ "true",
1336
+ "io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner"
1337
+ ])
1338
+ );
1339
+ }
1340
+
1341
+ // src/browser/android-agent.ts
1342
+ var DEFAULT_AGENT_REQUEST_TIMEOUT_MS = 3e4;
1343
+ var W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
1344
+ var Uia2HttpError = class extends Error {
1345
+ constructor(message, status, webdriverError) {
1346
+ super(message);
1347
+ this.status = status;
1348
+ this.webdriverError = webdriverError;
1349
+ this.name = "Uia2HttpError";
1350
+ }
1351
+ };
1352
+ var Uia2Transport = class {
1353
+ baseUrl;
1354
+ requestTimeoutMs;
1355
+ fetchImpl;
1356
+ constructor(options) {
1357
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
1358
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_AGENT_REQUEST_TIMEOUT_MS;
1359
+ const injected = options.fetchImpl;
1360
+ if (injected) {
1361
+ this.fetchImpl = injected;
1362
+ } else if (typeof fetch === "function") {
1363
+ this.fetchImpl = (url, init) => fetch(url, init);
1364
+ } else {
1365
+ throw new Error("global fetch is unavailable; Node 20+ is required for the Android target");
1366
+ }
1367
+ }
1368
+ /**
1369
+ * Send one request and return the parsed `value` field. Rejects with a
1370
+ * {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
1371
+ * per-request deadline elapses.
1372
+ */
1373
+ async request(method, path18, body, timeoutMs) {
1374
+ const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
1375
+ const controller = new AbortController();
1376
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
1377
+ timer.unref?.();
1378
+ const url = `${this.baseUrl}${path18}`;
1379
+ let response;
1380
+ try {
1381
+ response = await this.fetchImpl(url, {
1382
+ method,
1383
+ signal: controller.signal,
1384
+ headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
1385
+ body: body !== void 0 ? JSON.stringify(body) : void 0
1386
+ });
1387
+ } catch (error) {
1388
+ if (controller.signal.aborted) {
1389
+ const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
1390
+ throw new Error(`uiautomator2 request ${method} ${path18} timed out after ${shown}`);
1391
+ }
1392
+ throw error instanceof Error ? error : new Error(String(error));
1393
+ } finally {
1394
+ clearTimeout(timer);
1395
+ }
1396
+ const text = await response.text();
1397
+ const parsed = parseJson(text);
1398
+ if (!response.ok) {
1399
+ const wdError = extractWebdriverError(parsed);
1400
+ throw new Uia2HttpError(
1401
+ `uiautomator2 ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
1402
+ response.status,
1403
+ wdError
1404
+ );
1405
+ }
1406
+ return parsed?.value;
1407
+ }
1408
+ };
1409
+ function parseJson(text) {
1410
+ if (!text) {
1411
+ return void 0;
1412
+ }
1413
+ try {
1414
+ return JSON.parse(text);
1415
+ } catch {
1416
+ return void 0;
1417
+ }
1418
+ }
1419
+ function extractWebdriverError(parsed) {
1420
+ const value = parsed?.value;
1421
+ if (value && typeof value === "object") {
1422
+ const record = value;
1423
+ const error = typeof record.error === "string" ? record.error : void 0;
1424
+ const message = typeof record.message === "string" ? record.message : void 0;
1425
+ return error ?? message;
1426
+ }
1427
+ return void 0;
1428
+ }
1429
+ function extractElementId(value) {
1430
+ if (!value || typeof value !== "object") {
1431
+ return null;
1432
+ }
1433
+ const record = value;
1434
+ const id = record[W3C_ELEMENT_KEY] ?? record.ELEMENT;
1435
+ return typeof id === "string" ? id : null;
1436
+ }
1437
+ function isNoSuchElement(error) {
1438
+ if (error instanceof Uia2HttpError) {
1439
+ return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
1440
+ }
1441
+ return false;
1442
+ }
1443
+ async function createUia2Session(transport) {
1444
+ const value = await transport.request("POST", "/session", {
1445
+ capabilities: { alwaysMatch: {}, firstMatch: [{}] }
1446
+ });
1447
+ const record = value ?? {};
1448
+ const sessionId = record.sessionId;
1449
+ if (typeof sessionId === "string" && sessionId.length > 0) {
1450
+ return sessionId;
1451
+ }
1452
+ throw new Error("uiautomator2 did not return a session id");
1453
+ }
1454
+ async function waitForAgentReady(transport, options = { deadlineMs: 3e4 }) {
1455
+ const interval = options.intervalMs ?? 300;
1456
+ const deadline = Date.now() + options.deadlineMs;
1457
+ let lastError;
1458
+ for (; ; ) {
1459
+ try {
1460
+ const remainingMs = Math.max(1, deadline - Date.now());
1461
+ const value = await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
1462
+ const ready = value?.ready;
1463
+ if (ready === void 0 || ready === true) {
1464
+ return;
1465
+ }
1466
+ } catch (error) {
1467
+ lastError = error;
1468
+ }
1469
+ if (Date.now() >= deadline) {
1470
+ const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
1471
+ throw new Error(`uiautomator2 agent did not become ready within ${options.deadlineMs}ms${detail}`);
1472
+ }
1473
+ await sleep(Math.min(interval, Math.max(0, deadline - Date.now())));
1474
+ }
1475
+ }
1476
+ function sleep(ms) {
1477
+ return new Promise((resolve) => {
1478
+ const timer = setTimeout(resolve, ms);
1479
+ timer.unref?.();
1480
+ });
1481
+ }
1482
+ function createUia2AgentClient(transport, sessionId, options = {}) {
1483
+ const base = `/session/${sessionId}`;
1484
+ async function locate(query, path18) {
1485
+ return transport.request(
1486
+ "POST",
1487
+ `${base}${path18}`,
1488
+ androidQueryToLocator(query, { appPackage: options.appPackage })
1489
+ );
1490
+ }
1491
+ return {
1492
+ async findElement(query) {
1493
+ try {
1494
+ return extractElementId(await locate(query, "/element"));
1495
+ } catch (error) {
1496
+ if (isNoSuchElement(error)) {
1497
+ return null;
1498
+ }
1499
+ throw error;
1500
+ }
1501
+ },
1502
+ async findElements(query) {
1503
+ const value = await locate(query, "/elements");
1504
+ if (!Array.isArray(value)) {
1505
+ return [];
1506
+ }
1507
+ return value.map((entry) => extractElementId(entry)).filter((id) => id !== null);
1508
+ },
1509
+ async click(elementId) {
1510
+ await transport.request("POST", `${base}/element/${elementId}/click`, {});
1511
+ },
1512
+ async setValue(elementId, text) {
1513
+ await transport.request("POST", `${base}/element/${elementId}/value`, { text });
1514
+ },
1515
+ async getText(elementId) {
1516
+ const value = await transport.request("GET", `${base}/element/${elementId}/text`);
1517
+ return typeof value === "string" ? value : value == null ? null : String(value);
1518
+ },
1519
+ async pressKeyCode(keyCode) {
1520
+ await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
1521
+ },
1522
+ async screenshotPng() {
1523
+ const value = await transport.request("GET", `${base}/screenshot`);
1524
+ if (typeof value !== "string") {
1525
+ throw new Error("uiautomator2 screenshot did not return base64 data");
1526
+ }
1527
+ return Buffer.from(value, "base64");
1528
+ },
1529
+ async source() {
1530
+ const value = await transport.request("GET", `${base}/source`);
1531
+ if (typeof value !== "string") {
1532
+ throw new Error("uiautomator2 /source did not return XML text; cannot analyze Android UI hierarchy");
1533
+ }
1534
+ return value;
1535
+ },
1536
+ async close() {
1537
+ await transport.request("DELETE", base).catch(() => void 0);
1538
+ }
1539
+ };
1540
+ }
1541
+
1542
+ // src/config/target.ts
1543
+ var import_node_child_process3 = require("child_process");
1544
+ var import_node_fs3 = __toESM(require("fs"), 1);
1545
+ var import_node_path2 = __toESM(require("path"), 1);
1546
+ var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
1547
+ "navigate",
1548
+ "waitForUrl",
1549
+ "waitForNetworkIdle",
1550
+ "mockRoute",
1551
+ "unmockRoute",
1552
+ "evalScript",
1553
+ "runScript",
1554
+ "onDialog",
1555
+ "select",
1556
+ "selectOption",
1557
+ "setInputFiles",
1558
+ "waitForDownload",
1559
+ "scroll"
1560
+ // directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
1561
+ ]);
1562
+ function webOnlyReason(step) {
1563
+ for (const type of WEB_ONLY_STEP_TYPES) {
1564
+ if (type in step) {
1565
+ return type;
1566
+ }
1567
+ }
1568
+ if ("assert" in step) {
1569
+ const assertion = step.assert;
1570
+ if (assertion.urlIncludes !== void 0 || assertion.urlEquals !== void 0) {
1571
+ return "assert (url)";
1572
+ }
1573
+ }
1574
+ return null;
1575
+ }
1576
+ function nativeTargetLabel(target) {
1577
+ if (target === "android") {
1578
+ return "Android";
1579
+ }
1580
+ if (target === "ios") {
1581
+ return "iOS";
1582
+ }
1583
+ return "macOS";
1584
+ }
1585
+ function assertStepsSupportedByTarget(steps, target) {
1586
+ if (target === "web") {
1587
+ return;
1588
+ }
1589
+ const label = nativeTargetLabel(target);
1590
+ for (const step of steps) {
1591
+ const reason = webOnlyReason(step);
1592
+ if (reason) {
1593
+ throw new Error(
1594
+ `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.).`
1595
+ );
1596
+ }
1597
+ if ("if" in step) {
1598
+ assertStepsSupportedByTarget(step.if.then, target);
1599
+ if (step.if.else) {
1600
+ assertStepsSupportedByTarget(step.if.else, target);
1601
+ }
1602
+ }
1603
+ if ("repeat" in step) {
1604
+ assertStepsSupportedByTarget(step.repeat.steps, target);
1605
+ }
1606
+ }
1607
+ }
1608
+ function assertHuntAssertionsSupportedByTarget(assertions, target) {
1609
+ if (target === "web" || !assertions || assertions.length === 0) {
1610
+ return;
1611
+ }
1612
+ throw new Error(
1613
+ `Hunt-level assertions are not supported by the ${nativeTargetLabel(target)} target. Use inline assert visible/notVisible steps instead.`
1614
+ );
1615
+ }
1616
+ function trimTrailingPathSeparators(value) {
1617
+ return value.replace(/[\\/]+$/g, "");
1618
+ }
1619
+ function looksLikeAppBundlePath(app) {
1620
+ const trimmed = trimTrailingPathSeparators(app);
1621
+ return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
1622
+ }
1623
+ function looksLikeIosAppPath(app) {
1624
+ const trimmed = trimTrailingPathSeparators(app);
1625
+ if (trimmed.includes("/") || trimmed.includes("\\")) {
1626
+ return true;
1627
+ }
1628
+ if (trimmed.toLowerCase().endsWith(".app")) {
1629
+ try {
1630
+ return import_node_fs3.default.statSync(normalizeAppPath(app)).isDirectory();
1631
+ } catch {
1632
+ return false;
1633
+ }
1634
+ }
1635
+ return false;
1636
+ }
1637
+ function normalizeAppPath(app) {
1638
+ return import_node_path2.default.resolve(trimTrailingPathSeparators(app));
1639
+ }
1640
+ function parseBundleIdentifier(plist) {
1641
+ const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
1642
+ return match?.[1]?.trim() || null;
1643
+ }
1644
+ function readBundleIdentifier(appPath, ...plistSubPath) {
1645
+ const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), ...plistSubPath);
1646
+ if (!import_node_fs3.default.existsSync(infoPlistPath)) {
1647
+ return null;
1648
+ }
1649
+ try {
1650
+ const parsed = parseBundleIdentifier(import_node_fs3.default.readFileSync(infoPlistPath, "utf-8"));
1651
+ if (parsed) {
1652
+ return parsed;
1653
+ }
1654
+ } catch {
1655
+ }
1656
+ if (process.platform !== "darwin" || !import_node_fs3.default.existsSync("/usr/bin/plutil")) {
1657
+ return null;
1658
+ }
1659
+ try {
1660
+ const output = (0, import_node_child_process3.execFileSync)(
1661
+ "/usr/bin/plutil",
1662
+ ["-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlistPath],
1663
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1e3 }
1664
+ );
1665
+ return output.trim() || null;
1666
+ } catch {
1667
+ return null;
1668
+ }
1669
+ }
1670
+ function readMacosBundleIdentifier(appPath) {
1671
+ return readBundleIdentifier(appPath, "Contents", "Info.plist");
1672
+ }
1673
+ function readIosBundleIdentifier(appPath) {
1674
+ return readBundleIdentifier(appPath, "Info.plist");
1675
+ }
1676
+ function appBundleAllowedIdentities(app, readBundleId, isAppPath = looksLikeAppBundlePath) {
1677
+ const identities = /* @__PURE__ */ new Set([app]);
1678
+ if (isAppPath(app)) {
1679
+ const normalizedPath = normalizeAppPath(app);
1680
+ identities.add(trimTrailingPathSeparators(app));
1681
+ identities.add(normalizedPath);
1682
+ const bundleName = import_node_path2.default.basename(normalizedPath).replace(/\.app$/i, "");
1683
+ if (bundleName) {
1684
+ identities.add(bundleName);
1685
+ }
1686
+ const bundleId = readBundleId(app);
1687
+ if (bundleId) {
1688
+ identities.add(bundleId);
1689
+ }
1690
+ }
1691
+ return [...identities];
1692
+ }
1693
+ function macosAppAllowedIdentities(app) {
1694
+ return appBundleAllowedIdentities(app, readMacosBundleIdentifier);
1695
+ }
1696
+ function iosAppAllowedIdentities(app) {
1697
+ return appBundleAllowedIdentities(app, readIosBundleIdentifier, looksLikeIosAppPath);
1698
+ }
1699
+ function assertTargetAppAllowed(allowedApps, app) {
1700
+ assertNativeAppAllowed(allowedApps, app, macosAppAllowedIdentities);
1701
+ }
1702
+ function assertIosAppAllowed(allowedApps, app) {
1703
+ assertNativeAppAllowed(allowedApps, app, iosAppAllowedIdentities);
1704
+ }
1705
+ function looksLikeApkPath(app) {
1706
+ const trimmed = trimTrailingPathSeparators(app);
1707
+ return trimmed.includes("/") || trimmed.includes("\\") || trimmed.toLowerCase().endsWith(".apk");
1708
+ }
1709
+ function normalizeAndroidApkPath(app) {
1710
+ const resolved = import_node_path2.default.resolve(trimTrailingPathSeparators(app));
1711
+ try {
1712
+ return import_node_fs3.default.realpathSync.native(resolved);
1713
+ } catch {
1714
+ return resolved;
1715
+ }
1716
+ }
1717
+ function androidAppAllowedIdentities(app, resolvedPackage) {
1718
+ if (looksLikeApkPath(app)) {
1719
+ return resolvedPackage ? [normalizeAndroidApkPath(app), resolvedPackage] : [normalizeAndroidApkPath(app)];
1720
+ }
1721
+ return [app];
1722
+ }
1723
+ function assertAndroidAppAllowed(allowedApps, app, resolvedPackage) {
1724
+ assertNativeAppAllowed(
1725
+ allowedApps,
1726
+ app,
1727
+ (value) => androidAppAllowedIdentities(value, value === app ? resolvedPackage : void 0)
1728
+ );
1729
+ }
1730
+ function assertNativeAppAllowed(allowedApps, app, resolveIdentities) {
1731
+ if (allowedApps.length === 0) {
1732
+ return;
1733
+ }
1734
+ const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => resolveIdentities(allowedApp)));
1735
+ if (resolveIdentities(app).some((identity) => allowedIdentities.has(identity))) {
1736
+ return;
1737
+ }
1738
+ throw new Error(
1739
+ `Target app "${app}" is not in guardrails.allowedApps (${allowedApps.join(", ")}).`
1740
+ );
1741
+ }
1742
+
1743
+ // src/browser/android-helper.ts
1744
+ var import_meta2 = {};
1745
+ var UIA2_REMOTE_PORT = 6790;
1746
+ function resolveAgentApks(requireFn = (0, import_node_module.createRequire)(import_meta2.url)) {
1747
+ let pkgJsonPath;
1748
+ try {
1749
+ pkgJsonPath = requireFn.resolve("appium-uiautomator2-server/package.json");
1750
+ } catch {
1751
+ throw new Error(
1752
+ "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"
1753
+ );
1754
+ }
1755
+ const pkgDir = import_node_path3.default.dirname(pkgJsonPath);
1756
+ const version = requireFn(pkgJsonPath).version;
1757
+ const serverApk = import_node_path3.default.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
1758
+ const testApk = import_node_path3.default.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
1759
+ for (const apk of [serverApk, testApk]) {
1760
+ if (!import_node_fs4.default.existsSync(apk)) {
1761
+ throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
1762
+ }
1763
+ }
1764
+ return { serverApk, testApk };
1765
+ }
1766
+ function looksLikeApk(app) {
1767
+ return app.toLowerCase().endsWith(".apk");
1768
+ }
1769
+ var execFileAaptResolver = async (apkPath) => {
1770
+ for (const tool of ["aapt", "aapt2"]) {
1771
+ const output = await new Promise((resolve) => {
1772
+ (0, import_node_child_process4.execFile)(
1773
+ tool,
1774
+ ["dump", "badging", apkPath],
1775
+ { encoding: "utf-8", timeout: 2e4, maxBuffer: 8 * 1024 * 1024 },
1776
+ (error, stdout) => resolve(error ? null : stdout)
1777
+ );
1778
+ });
1779
+ const pkg = output ? parseAaptPackage(output) : null;
1780
+ if (pkg) {
1781
+ return pkg;
1782
+ }
1783
+ }
1784
+ return null;
1785
+ };
1786
+ var defaultAgentConnector = async ({
1787
+ host,
1788
+ port,
1789
+ requestTimeoutMs,
1790
+ readyDeadlineMs,
1791
+ appPackage
1792
+ }) => {
1793
+ const transport = new Uia2Transport({
1794
+ baseUrl: `http://${host}:${port}/wd/hub`,
1795
+ requestTimeoutMs
1796
+ });
1797
+ await waitForAgentReady(transport, { deadlineMs: readyDeadlineMs });
1798
+ const sessionId = await createUia2Session(transport);
1799
+ return createUia2AgentClient(transport, sessionId, { appPackage });
1800
+ };
1801
+ async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
1802
+ if (!looksLikeApk(app)) {
1803
+ assertAndroidAppAllowed(allowedApps, app);
1804
+ return app;
1805
+ }
1806
+ const apkPath = import_node_path3.default.resolve(app);
1807
+ if (!import_node_fs4.default.existsSync(apkPath)) {
1808
+ throw new Error(`APK not found: ${apkPath}`);
1809
+ }
1810
+ const pkg = await aaptResolver(apkPath);
1811
+ if (!pkg) {
1812
+ throw new Error(
1813
+ `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.`
1814
+ );
1815
+ }
1816
+ assertAndroidAppAllowed(allowedApps, apkPath, pkg);
1817
+ await installApk(runner, serial, apkPath);
1818
+ return pkg;
1819
+ }
1820
+ async function launchAndroidSession(options) {
1821
+ const runner = options.runner ?? execFileAdbRunner;
1822
+ const spawner = options.spawner ?? spawnAdbProcess;
1823
+ const connector = options.agentConnector ?? defaultAgentConnector;
1824
+ const aaptResolver = options.aaptResolver ?? execFileAaptResolver;
1825
+ const apks = options.apks ?? resolveAgentApks();
1826
+ const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS) + 5e3;
1827
+ const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS);
1828
+ const devices = await listDevices(runner);
1829
+ const serial = selectDeviceSerial(devices, options.deviceSerial);
1830
+ const pkg = await resolvePackage(options.app, runner, serial, aaptResolver, options.allowedApps ?? []);
1831
+ await installApk(runner, serial, apks.serverApk);
1832
+ await installApk(runner, serial, apks.testApk);
1833
+ if (options.coldStart) {
1834
+ await clearPackage(runner, serial, pkg);
1835
+ }
1836
+ await launchPackage(runner, serial, pkg);
1837
+ let instrumentation;
1838
+ let localPort;
1839
+ let client;
1840
+ let tornDown = false;
1841
+ const teardown = async () => {
1842
+ if (tornDown) {
1843
+ return;
1844
+ }
1845
+ tornDown = true;
1846
+ if (client) {
1847
+ await client.close().catch(() => void 0);
1848
+ }
1849
+ instrumentation?.kill();
1850
+ const forwardedPort = localPort;
1851
+ localPort = void 0;
1852
+ if (forwardedPort !== void 0) {
1853
+ await removeForward(runner, serial, forwardedPort);
1854
+ }
1855
+ await forceStop(runner, serial, pkg).catch(() => void 0);
1856
+ };
1857
+ try {
1858
+ instrumentation = startInstrumentation(spawner, serial);
1859
+ localPort = await forwardDynamicPort(runner, serial, UIA2_REMOTE_PORT);
1860
+ client = await connector({
1861
+ host: "127.0.0.1",
1862
+ port: localPort,
1863
+ requestTimeoutMs,
1864
+ readyDeadlineMs,
1865
+ appPackage: pkg
1866
+ });
1867
+ const driver = createAndroidDriver(client, { appLabel: pkg });
1868
+ return { client, driver, package: pkg, serial, teardown };
1869
+ } catch (error) {
1870
+ await teardown();
1871
+ throw error;
1872
+ }
1873
+ }
1874
+ async function closeAndroidSession(session) {
1875
+ await session.teardown();
1876
+ }
1877
+
1878
+ // src/browser/ios-driver.ts
1879
+ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
1880
+ "query",
1881
+ "interact",
1882
+ "wait",
1883
+ "screenshot"
1884
+ ]);
1885
+ var WAIT_POLL_INTERVAL_MS2 = 250;
1886
+ var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
1887
+ var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
1888
+ function escapePredicateArg(value) {
1889
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1890
+ }
1891
+ function toIosQuery(selector) {
1892
+ switch (selector.kind) {
1893
+ case "focused":
1894
+ return { by: "focused" };
1895
+ case "id":
1896
+ return { by: "accessibilityId", value: selector.value };
1897
+ case "role":
1898
+ return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
1899
+ case "label":
1900
+ return { by: "label", value: selector.value };
1901
+ case "text":
1902
+ return { by: "text", value: selector.value };
1903
+ }
1904
+ }
1905
+ function parseIosSelector(selector) {
1906
+ return toIosQuery(parseNativeSelector(selector));
1907
+ }
1908
+ function iosQueryToLocator(query) {
1909
+ switch (query.by) {
1910
+ case "accessibilityId":
1911
+ return { using: "accessibility id", value: query.value };
1912
+ case "label":
1913
+ return { using: "predicate string", value: `label == "${escapePredicateArg(query.value)}"` };
1914
+ case "text": {
1915
+ const escaped = escapePredicateArg(query.value);
1916
+ return {
1917
+ using: "predicate string",
1918
+ value: `label CONTAINS "${escaped}" OR value CONTAINS "${escaped}"`
1919
+ };
1920
+ }
1921
+ case "focused":
1922
+ return { using: "predicate string", value: "hasKeyboardFocus == 1" };
1923
+ case "role": {
1924
+ const className = normalizeXcuiClassName(query.role);
1925
+ if (query.name === void 0 || query.name.length === 0) {
1926
+ return { using: "class name", value: className };
1927
+ }
1928
+ const escapedClass = escapePredicateArg(className);
1929
+ const escapedName = escapePredicateArg(query.name);
1930
+ return {
1931
+ using: "predicate string",
1932
+ value: `type == "${escapedClass}" AND (label CONTAINS "${escapedName}" OR value CONTAINS "${escapedName}")`
1933
+ };
1934
+ }
1935
+ }
1936
+ }
1937
+ function unwrapIosTextSelector(selector) {
1938
+ return unwrapNativeTextSelector(selector);
1939
+ }
1940
+ function delay2(ms) {
1941
+ return new Promise((resolve) => {
1942
+ setTimeout(resolve, ms);
1943
+ });
1944
+ }
1945
+ function createIosDriver(client, options) {
1946
+ const unsupported = (verb) => new Error(`${verb} is not supported by the iOS target`);
1947
+ const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
1948
+ async function resolveOne(selector) {
1949
+ const id = await client.findElement(parseIosSelector(selector));
1950
+ if (id === null) {
1951
+ throw new Error(`No element matched selector: ${selector}`);
1952
+ }
1953
+ return id;
1954
+ }
1955
+ async function clickSelector(selector) {
1956
+ await client.click(await resolveOne(selector));
1957
+ }
1958
+ async function fillSelector(selector, value) {
1959
+ await client.setValue(await resolveOne(selector), value);
1960
+ }
1961
+ async function pressKey(key) {
1962
+ const name = key.trim().toLowerCase();
1963
+ if (name === "enter" || name === "return") {
1964
+ await client.sendKeys(["\n"]);
1965
+ return;
1966
+ }
1967
+ if (name === "delete" || name === "backspace" || name === "del") {
1968
+ await client.sendKeys(["\b"]);
1969
+ return;
1970
+ }
1971
+ if (name === "home") {
1972
+ await client.homescreen();
1973
+ return;
1974
+ }
1975
+ throw new Error(
1976
+ `Unsupported key "${key}" for the iOS target. Supported keys: ${IOS_PRESS_KEYS.join(", ")}.`
1977
+ );
1978
+ }
1979
+ return {
1980
+ capabilities: IOS_CAPABILITIES,
1981
+ // navigation -----------------------------------------------------------
1982
+ goto(_url, _options) {
1983
+ return rejectUnsupported("navigate");
1984
+ },
1985
+ currentUrl() {
1986
+ return `ios:${options.appLabel ?? ""}`;
1987
+ },
1988
+ // queries --------------------------------------------------------------
1989
+ async count(selector) {
1990
+ return (await client.findElements(parseIosSelector(selector))).length;
1991
+ },
1992
+ async textContent(selector) {
1993
+ const id = await client.findElement(parseIosSelector(selector));
1994
+ if (id === null) {
1995
+ return null;
1996
+ }
1997
+ return client.getText(id);
1998
+ },
1999
+ // interactions ---------------------------------------------------------
2000
+ click: clickSelector,
2001
+ clickFirst: clickSelector,
2002
+ fill: fillSelector,
2003
+ fillFirst: fillSelector,
2004
+ async press(_selector, key) {
2005
+ await pressKey(key);
2006
+ },
2007
+ selectOption() {
2008
+ return rejectUnsupported("select");
2009
+ },
2010
+ selectOptionFirst() {
2011
+ return rejectUnsupported("select");
2012
+ },
2013
+ hover() {
2014
+ return rejectUnsupported("hover");
2015
+ },
2016
+ scrollIntoView() {
2017
+ return rejectUnsupported("scrollTo");
2018
+ },
2019
+ setInputFiles() {
2020
+ return rejectUnsupported("setInputFiles");
2021
+ },
2022
+ // semantic locators ----------------------------------------------------
2023
+ async countByRole(role, name) {
2024
+ return (await client.findElements({ by: "role", role, name })).length;
2025
+ },
2026
+ async clickFirstByRole(role, name) {
2027
+ const id = await client.findElement({ by: "role", role, name });
2028
+ if (id === null) {
2029
+ throw new Error(`No element matched role=${role}[name="${name}"]`);
2030
+ }
2031
+ await client.click(id);
2032
+ },
2033
+ async countByLabel(label) {
2034
+ return (await client.findElements({ by: "label", value: label })).length;
2035
+ },
2036
+ async fillFirstByLabel(label, value) {
2037
+ const id = await client.findElement({ by: "label", value: label });
2038
+ if (id === null) {
2039
+ throw new Error(`No element matched label="${label}"`);
2040
+ }
2041
+ await client.setValue(id, value);
2042
+ },
2043
+ selectOptionFirstByLabel() {
2044
+ return rejectUnsupported("select");
2045
+ },
2046
+ // waiting --------------------------------------------------------------
2047
+ async waitForSelector(selector, waitOptions) {
2048
+ const query = parseIosSelector(selector);
2049
+ const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
2050
+ const deadline = Date.now() + timeoutMs;
2051
+ for (; ; ) {
2052
+ if ((await client.findElements(query)).length > 0) {
2053
+ return;
2054
+ }
2055
+ if (Date.now() >= deadline) {
2056
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
2057
+ }
2058
+ await delay2(Math.min(WAIT_POLL_INTERVAL_MS2, Math.max(0, deadline - Date.now())));
2059
+ }
2060
+ },
2061
+ waitForUrl() {
2062
+ return rejectUnsupported("waitForUrl");
2063
+ },
2064
+ waitForNetworkIdle() {
2065
+ return rejectUnsupported("waitForNetworkIdle");
2066
+ },
2067
+ // scripting & artifacts ------------------------------------------------
2068
+ evaluate() {
2069
+ return rejectUnsupported("evalScript");
2070
+ },
2071
+ async screenshot(screenshotOptions) {
2072
+ await options.captureScreenshot(screenshotOptions.path);
2073
+ },
2074
+ // network / dialogs / downloads (all web-only) -------------------------
2075
+ onResponse(_handler) {
2076
+ throw unsupported("onResponse");
2077
+ },
2078
+ route(_url, _handler) {
2079
+ return rejectUnsupported("mockRoute");
2080
+ },
2081
+ unroute() {
2082
+ return rejectUnsupported("unmockRoute");
2083
+ },
2084
+ onDialog(_action) {
2085
+ throw unsupported("onDialog");
2086
+ },
2087
+ waitForDownloadEvent() {
2088
+ return rejectUnsupported("waitForDownload");
2089
+ },
2090
+ parseTextSelector(selector) {
2091
+ return unwrapIosTextSelector(selector);
2092
+ }
2093
+ };
2094
+ }
2095
+
2096
+ // src/browser/ios-helper.ts
2097
+ var import_node_module2 = require("module");
2098
+ var import_node_child_process6 = require("child_process");
2099
+ var import_node_fs5 = __toESM(require("fs"), 1);
2100
+ var import_node_os2 = __toESM(require("os"), 1);
2101
+ var import_node_path5 = __toESM(require("path"), 1);
2102
+
2103
+ // src/browser/ios-simctl.ts
2104
+ var import_node_child_process5 = require("child_process");
2105
+ var import_promises = require("fs/promises");
2106
+ var import_node_net = __toESM(require("net"), 1);
2107
+ var import_node_os = __toESM(require("os"), 1);
2108
+ var import_node_path4 = __toESM(require("path"), 1);
2109
+ var spawnXcrunProcess = (args, options) => {
2110
+ const child = (0, import_node_child_process5.spawn)("xcrun", args, {
2111
+ stdio: "ignore",
2112
+ env: options?.env ? { ...process.env, ...options.env } : process.env
2113
+ });
2114
+ child.on("error", () => {
2115
+ });
2116
+ return {
2117
+ kill: () => {
2118
+ child.kill();
2119
+ }
2120
+ };
2121
+ };
2122
+ var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path4.default.join(import_node_os.default.tmpdir(), "prowl-ios-simulator-locks");
2123
+ var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
2124
+ var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
2125
+ (0, import_node_child_process5.execFile)(
2126
+ "xcrun",
2127
+ args,
2128
+ {
2129
+ encoding: "utf-8",
2130
+ timeout: options?.timeoutMs,
2131
+ maxBuffer: 32 * 1024 * 1024,
2132
+ env: options?.env ? { ...process.env, ...options.env } : process.env
2133
+ },
2134
+ (error, stdout, stderr) => {
2135
+ const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
2136
+ const capturedStderr = stderr ?? "";
2137
+ resolve({
2138
+ stdout: stdout ?? "",
2139
+ stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
2140
+ code
2141
+ });
2142
+ }
2143
+ );
2144
+ });
2145
+ function simulatorLockName(udid) {
2146
+ const safe = udid.replace(/[^A-Za-z0-9_.-]/g, "_");
2147
+ return `${safe || "simulator"}.lock`;
2148
+ }
2149
+ function isErrno(error, code) {
2150
+ return error?.code === code;
2151
+ }
2152
+ function isProcessAlive(pid) {
2153
+ try {
2154
+ process.kill(pid, 0);
2155
+ return true;
2156
+ } catch (error) {
2157
+ return isErrno(error, "EPERM");
2158
+ }
2159
+ }
2160
+ async function removeStaleSimulatorLock(lockPath) {
2161
+ try {
2162
+ const ownerText = await (0, import_promises.readFile)(import_node_path4.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
2163
+ const owner = JSON.parse(ownerText);
2164
+ if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
2165
+ await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
2166
+ return true;
2167
+ }
2168
+ } catch {
2169
+ return false;
2170
+ }
2171
+ return false;
2172
+ }
2173
+ function simulatorReservedError(udid) {
2174
+ return new Error(
2175
+ `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.`
2176
+ );
2177
+ }
2178
+ async function reserveSimulatorUdid(udid, options = {}) {
2179
+ const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
2180
+ const lockPath = import_node_path4.default.join(lockRoot, simulatorLockName(udid));
2181
+ await (0, import_promises.mkdir)(lockRoot, { recursive: true });
2182
+ for (let attempt = 0; attempt < 2; attempt += 1) {
2183
+ try {
2184
+ await (0, import_promises.mkdir)(lockPath);
2185
+ } catch (error) {
2186
+ if (!isErrno(error, "EEXIST")) {
2187
+ throw error instanceof Error ? error : new Error(String(error));
2188
+ }
2189
+ if (attempt === 0 && await removeStaleSimulatorLock(lockPath)) {
2190
+ continue;
2191
+ }
2192
+ throw simulatorReservedError(udid);
2193
+ }
2194
+ let released = false;
2195
+ const release = async () => {
2196
+ if (released) {
2197
+ return;
2198
+ }
2199
+ released = true;
2200
+ await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
2201
+ };
2202
+ try {
2203
+ await (0, import_promises.writeFile)(
2204
+ import_node_path4.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
2205
+ `${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
2206
+ `,
2207
+ { flag: "wx" }
2208
+ );
2209
+ } catch (error) {
2210
+ await release().catch(() => void 0);
2211
+ throw error instanceof Error ? error : new Error(String(error));
2212
+ }
2213
+ return { udid, release };
2214
+ }
2215
+ throw simulatorReservedError(udid);
2216
+ }
2217
+ function findFreePort() {
2218
+ return new Promise((resolve, reject) => {
2219
+ const server = import_node_net.default.createServer();
2220
+ server.on("error", reject);
2221
+ server.listen(0, "127.0.0.1", () => {
2222
+ const address = server.address();
2223
+ if (address && typeof address === "object") {
2224
+ const { port } = address;
2225
+ server.close(() => resolve(port));
2226
+ } else {
2227
+ server.close(() => reject(new Error("Could not allocate a local port")));
2228
+ }
2229
+ });
2230
+ });
2231
+ }
2232
+ function parseSimctlDevices(json) {
2233
+ let parsed;
2234
+ try {
2235
+ parsed = JSON.parse(json);
2236
+ } catch {
2237
+ throw new Error("Could not parse `simctl list devices --json` output.");
2238
+ }
2239
+ const byRuntime = parsed?.devices;
2240
+ if (!byRuntime || typeof byRuntime !== "object") {
2241
+ return [];
2242
+ }
2243
+ const devices = [];
2244
+ for (const [runtime, entries] of Object.entries(byRuntime)) {
2245
+ if (!Array.isArray(entries)) {
2246
+ continue;
2247
+ }
2248
+ for (const entry of entries) {
2249
+ if (!entry || typeof entry !== "object") {
2250
+ continue;
2251
+ }
2252
+ const record = entry;
2253
+ const udid = typeof record.udid === "string" ? record.udid : void 0;
2254
+ const name = typeof record.name === "string" ? record.name : void 0;
2255
+ const state = typeof record.state === "string" ? record.state : "Unknown";
2256
+ if (!udid || !name) {
2257
+ continue;
2258
+ }
2259
+ devices.push({
2260
+ udid,
2261
+ name,
2262
+ state,
2263
+ runtime,
2264
+ isAvailable: record.isAvailable !== false
2265
+ });
2266
+ }
2267
+ }
2268
+ return devices;
2269
+ }
2270
+ function bootedSimulators(devices) {
2271
+ return devices.filter((device) => device.state === "Booted");
2272
+ }
2273
+ function describeSimulator(device) {
2274
+ const runtime = device.runtime.replace(/^com\.apple\.CoreSimulator\.SimRuntime\./, "");
2275
+ return `${device.name} [${runtime}] (${device.udid})`;
2276
+ }
2277
+ function selectSimulatorUdid(devices, requested) {
2278
+ const booted = bootedSimulators(devices);
2279
+ if (requested) {
2280
+ const match = devices.find((device) => device.udid === requested);
2281
+ if (!match) {
2282
+ const known = devices.length > 0 ? devices.map((d) => d.udid).join(", ") : "none";
2283
+ throw new Error(
2284
+ `iOS simulator "${requested}" was not found. Known simulators: ${known}. Check \`xcrun simctl list devices\`.`
2285
+ );
2286
+ }
2287
+ if (match.state !== "Booted") {
2288
+ throw new Error(
2289
+ `iOS simulator "${requested}" is not booted (state: ${match.state}). Boot it with \`xcrun simctl boot ${requested}\`, then retry.`
2290
+ );
2291
+ }
2292
+ return requested;
2293
+ }
2294
+ if (booted.length === 0) {
2295
+ throw new Error(
2296
+ "No booted iOS simulator found. Boot one from Xcode or with `xcrun simctl boot <udid>` (see `xcrun simctl list devices available`), then retry."
2297
+ );
2298
+ }
2299
+ if (booted.length > 1) {
2300
+ throw new Error(
2301
+ `Multiple iOS simulators are booted (${booted.map(describeSimulator).join("; ")}). Set target.udid to pick one.`
2302
+ );
2303
+ }
2304
+ return booted[0].udid;
2305
+ }
2306
+ async function listSimulators(runner) {
2307
+ const result = await runner(["simctl", "list", "devices", "--json"], { timeoutMs: 3e4 });
2308
+ if (result.code !== 0) {
2309
+ throw new Error(
2310
+ `\`xcrun simctl list devices\` failed. Is Xcode installed and are the command-line tools selected (\`xcode-select -p\`)? ${(result.stderr.trim() || "").slice(0, 400)}`
2311
+ );
2312
+ }
2313
+ return parseSimctlDevices(result.stdout);
2314
+ }
2315
+ async function installApp(runner, udid, appPath) {
2316
+ const result = await runner(["simctl", "install", udid, appPath], { timeoutMs: 12e4 });
2317
+ if (result.code !== 0) {
2318
+ throw new Error(
2319
+ `Failed to install "${appPath}" onto simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
2320
+ );
2321
+ }
2322
+ }
2323
+ async function uninstallApp(runner, udid, bundleId) {
2324
+ await runner(["simctl", "uninstall", udid, bundleId], { timeoutMs: 6e4 }).catch(() => void 0);
2325
+ }
2326
+ async function launchApp(runner, udid, bundleId, childEnv = {}) {
2327
+ const env = {};
2328
+ for (const [key, value] of Object.entries(childEnv)) {
2329
+ env[`SIMCTL_CHILD_${key}`] = value;
2330
+ }
2331
+ const result = await runner(["simctl", "launch", udid, bundleId], {
2332
+ timeoutMs: 6e4,
2333
+ env: Object.keys(env).length > 0 ? env : void 0
2334
+ });
2335
+ if (result.code !== 0) {
2336
+ throw new Error(
2337
+ `Failed to launch "${bundleId}" on simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}. Is it installed?`
2338
+ );
2339
+ }
2340
+ }
2341
+ async function terminateApp(runner, udid, bundleId) {
2342
+ await runner(["simctl", "terminate", udid, bundleId], { timeoutMs: 3e4 }).catch(() => void 0);
2343
+ }
2344
+ async function captureScreenshot(runner, udid, outPath) {
2345
+ const result = await runner(["simctl", "io", udid, "screenshot", outPath], { timeoutMs: 3e4 });
2346
+ if (result.code !== 0) {
2347
+ throw new Error(
2348
+ `Failed to capture a simulator screenshot (${udid}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 300)}`
2349
+ );
2350
+ }
2351
+ }
2352
+ function parseXcodeVersion(stdout) {
2353
+ const match = /Xcode\s+([\d.]+)/i.exec(stdout);
2354
+ return match?.[1] ?? null;
2355
+ }
2356
+ async function xcodeVersion(runner) {
2357
+ const result = await runner(["xcodebuild", "-version"], { timeoutMs: 3e4 });
2358
+ if (result.code !== 0) {
2359
+ return null;
2360
+ }
2361
+ return parseXcodeVersion(result.stdout);
2362
+ }
2363
+
2364
+ // src/browser/ios-agent.ts
2365
+ var DEFAULT_WDA_REQUEST_TIMEOUT_MS = 3e4;
2366
+ var W3C_ELEMENT_KEY2 = "element-6066-11e4-a52e-4f735466cecf";
2367
+ var WdaHttpError = class extends Error {
2368
+ constructor(message, status, webdriverError) {
2369
+ super(message);
2370
+ this.status = status;
2371
+ this.webdriverError = webdriverError;
2372
+ this.name = "WdaHttpError";
2373
+ }
2374
+ };
2375
+ var WdaTransport = class {
2376
+ baseUrl;
2377
+ requestTimeoutMs;
2378
+ fetchImpl;
2379
+ constructor(options) {
2380
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
2381
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_WDA_REQUEST_TIMEOUT_MS;
2382
+ const injected = options.fetchImpl;
2383
+ if (injected) {
2384
+ this.fetchImpl = injected;
2385
+ } else if (typeof fetch === "function") {
2386
+ this.fetchImpl = (url, init) => fetch(url, init);
2387
+ } else {
2388
+ throw new Error("global fetch is unavailable; Node 20+ is required for the iOS target");
2389
+ }
2390
+ }
2391
+ /**
2392
+ * Send one request and return the full parsed JSON body. Rejects with a
2393
+ * {@link WdaHttpError} on a non-2xx response, or a timeout error when the
2394
+ * per-request deadline elapses.
2395
+ */
2396
+ async requestFull(method, path18, body, timeoutMs) {
2397
+ const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
2398
+ const controller = new AbortController();
2399
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
2400
+ timer.unref?.();
2401
+ const url = `${this.baseUrl}${path18}`;
2402
+ const timeoutError = () => {
2403
+ const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
2404
+ return new Error(`WebDriverAgent request ${method} ${path18} timed out after ${shown}`);
2405
+ };
2406
+ let response;
2407
+ try {
2408
+ response = await this.fetchImpl(url, {
2409
+ method,
2410
+ signal: controller.signal,
2411
+ headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
2412
+ body: body !== void 0 ? JSON.stringify(body) : void 0
2413
+ });
2414
+ } catch (error) {
2415
+ clearTimeout(timer);
2416
+ if (controller.signal.aborted) {
2417
+ throw timeoutError();
2418
+ }
2419
+ throw error instanceof Error ? error : new Error(String(error));
2420
+ }
2421
+ let text;
2422
+ try {
2423
+ text = await response.text();
2424
+ } catch (error) {
2425
+ if (controller.signal.aborted) {
2426
+ throw timeoutError();
2427
+ }
2428
+ throw error instanceof Error ? error : new Error(String(error));
2429
+ } finally {
2430
+ clearTimeout(timer);
2431
+ }
2432
+ const parsed = parseJson2(text);
2433
+ if (!response.ok) {
2434
+ const wdError = extractWebdriverError2(parsed);
2435
+ throw new WdaHttpError(
2436
+ `WebDriverAgent ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2437
+ response.status,
2438
+ wdError
2439
+ );
2440
+ }
2441
+ return parsed;
2442
+ }
2443
+ /** Like {@link requestFull} but returns just the `value` field. */
2444
+ async request(method, path18, body, timeoutMs) {
2445
+ const parsed = await this.requestFull(method, path18, body, timeoutMs);
2446
+ return parsed?.value;
2447
+ }
2448
+ };
2449
+ function parseJson2(text) {
2450
+ if (!text) {
2451
+ return void 0;
2452
+ }
2453
+ try {
2454
+ return JSON.parse(text);
2455
+ } catch {
2456
+ return void 0;
2457
+ }
2458
+ }
2459
+ function extractWebdriverError2(parsed) {
2460
+ const value = parsed?.value;
2461
+ if (value && typeof value === "object") {
2462
+ const record = value;
2463
+ const error = typeof record.error === "string" ? record.error : void 0;
2464
+ const message = typeof record.message === "string" ? record.message : void 0;
2465
+ return error ?? message;
2466
+ }
2467
+ return void 0;
2468
+ }
2469
+ function extractElementId2(value) {
2470
+ if (!value || typeof value !== "object") {
2471
+ return null;
2472
+ }
2473
+ const record = value;
2474
+ const id = record[W3C_ELEMENT_KEY2] ?? record.ELEMENT;
2475
+ return typeof id === "string" ? id : null;
2476
+ }
2477
+ function isNoSuchElement2(error) {
2478
+ if (error instanceof WdaHttpError) {
2479
+ return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
2480
+ }
2481
+ return false;
2482
+ }
2483
+ async function createWdaSession(transport, bundleId) {
2484
+ const body = await transport.requestFull("POST", "/session", {
2485
+ capabilities: { alwaysMatch: { bundleId }, firstMatch: [{}] }
2486
+ });
2487
+ const envelope = body ?? {};
2488
+ const value = envelope.value ?? {};
2489
+ const sessionId = typeof value.sessionId === "string" && value.sessionId || typeof envelope.sessionId === "string" && envelope.sessionId || "";
2490
+ if (sessionId.length > 0) {
2491
+ return sessionId;
2492
+ }
2493
+ throw new Error("WebDriverAgent did not return a session id");
2494
+ }
2495
+ async function waitForWdaReady(transport, options = { deadlineMs: 6e4 }) {
2496
+ const interval = options.intervalMs ?? 300;
2497
+ const deadline = Date.now() + options.deadlineMs;
2498
+ let lastError;
2499
+ for (; ; ) {
2500
+ try {
2501
+ const remainingMs = Math.max(1, deadline - Date.now());
2502
+ await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
2503
+ return;
2504
+ } catch (error) {
2505
+ lastError = error;
2506
+ }
2507
+ if (Date.now() >= deadline) {
2508
+ const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
2509
+ throw new Error(`WebDriverAgent did not become ready within ${options.deadlineMs}ms${detail}`);
2510
+ }
2511
+ await sleep2(Math.min(interval, Math.max(0, deadline - Date.now())));
2512
+ }
2513
+ }
2514
+ function sleep2(ms) {
2515
+ return new Promise((resolve) => {
2516
+ setTimeout(resolve, ms);
2517
+ });
2518
+ }
2519
+ function createWdaAgentClient(transport, sessionId) {
2520
+ const base = `/session/${sessionId}`;
2521
+ async function locate(query, path18) {
2522
+ return transport.request("POST", `${base}${path18}`, iosQueryToLocator(query));
2523
+ }
2524
+ return {
2525
+ async findElement(query) {
2526
+ try {
2527
+ return extractElementId2(await locate(query, "/element"));
2528
+ } catch (error) {
2529
+ if (isNoSuchElement2(error)) {
2530
+ return null;
2531
+ }
2532
+ throw error;
2533
+ }
2534
+ },
2535
+ async findElements(query) {
2536
+ const value = await locate(query, "/elements");
2537
+ if (!Array.isArray(value)) {
2538
+ return [];
2539
+ }
2540
+ return value.map((entry) => extractElementId2(entry)).filter((id) => id !== null);
2541
+ },
2542
+ async click(elementId) {
2543
+ await transport.request("POST", `${base}/element/${elementId}/click`, {});
2544
+ },
2545
+ async setValue(elementId, text) {
2546
+ await transport.request("POST", `${base}/element/${elementId}/value`, { text });
2547
+ },
2548
+ async getText(elementId) {
2549
+ const value = await transport.request("GET", `${base}/element/${elementId}/text`);
2550
+ return typeof value === "string" ? value : value == null ? null : String(value);
2551
+ },
2552
+ async sendKeys(keys) {
2553
+ await transport.request("POST", `${base}/wda/keys`, { value: keys });
2554
+ },
2555
+ async homescreen() {
2556
+ await transport.request("POST", "/wda/homescreen", {});
2557
+ },
2558
+ async source() {
2559
+ const value = await transport.request("GET", "/source");
2560
+ if (typeof value !== "string") {
2561
+ throw new Error("WebDriverAgent /source did not return XML text; cannot analyze iOS UI hierarchy");
556
2562
  }
2563
+ return value;
2564
+ },
2565
+ async close() {
2566
+ await transport.request("DELETE", base).catch(() => void 0);
557
2567
  }
558
- if ("repeat" in step) {
559
- assertStepsSupportedByTarget(step.repeat.steps, target);
560
- }
561
- }
2568
+ };
562
2569
  }
563
- function assertHuntAssertionsSupportedByTarget(assertions, target) {
564
- if (target !== "macos" || !assertions || assertions.length === 0) {
565
- return;
2570
+
2571
+ // src/browser/ios-helper.ts
2572
+ var import_meta3 = {};
2573
+ var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
2574
+ var WDA_USE_PORT_ENV = "USE_PORT";
2575
+ var PREPARED_XCTESTRUN_PREFIX = "prowl-wda-xctestrun-";
2576
+ var WDA_STARTUP_ATTEMPTS = 2;
2577
+ var defaultIosAgentConnector = async ({
2578
+ host,
2579
+ port,
2580
+ bundleId,
2581
+ requestTimeoutMs,
2582
+ readyDeadlineMs
2583
+ }) => {
2584
+ const transport = new WdaTransport({ baseUrl: `http://${host}:${port}`, requestTimeoutMs });
2585
+ await waitForWdaReady(transport, { deadlineMs: readyDeadlineMs });
2586
+ const sessionId = await createWdaSession(transport, bundleId);
2587
+ return createWdaAgentClient(transport, sessionId);
2588
+ };
2589
+ function resolveWdaProject(requireFn = (0, import_node_module2.createRequire)(import_meta3.url)) {
2590
+ let pkgJsonPath;
2591
+ try {
2592
+ pkgJsonPath = requireFn.resolve("appium-webdriveragent/package.json");
2593
+ } catch {
2594
+ throw new Error(
2595
+ "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"
2596
+ );
566
2597
  }
567
- throw new Error(
568
- "Hunt-level assertions are not supported by the macOS target. Use inline assert visible/notVisible steps instead."
569
- );
570
- }
571
- function trimTrailingPathSeparators(value) {
572
- return value.replace(/[\\/]+$/g, "");
573
- }
574
- function looksLikeMacosAppPath(app) {
575
- const trimmed = trimTrailingPathSeparators(app);
576
- return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
2598
+ const pkgDir = import_node_path5.default.dirname(pkgJsonPath);
2599
+ const version = requireFn(pkgJsonPath).version;
2600
+ const projectPath = import_node_path5.default.join(pkgDir, "WebDriverAgent.xcodeproj");
2601
+ if (!import_node_fs5.default.existsSync(projectPath)) {
2602
+ throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
2603
+ }
2604
+ return { projectPath, version };
577
2605
  }
578
- function normalizeAppPath(app) {
579
- return import_node_path2.default.resolve(trimTrailingPathSeparators(app));
2606
+ function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os2.default.homedir()) {
2607
+ return import_node_path5.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
580
2608
  }
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;
2609
+ function productsDir(derivedDataPath) {
2610
+ return import_node_path5.default.join(derivedDataPath, "Build", "Products");
584
2611
  }
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)) {
2612
+ function findXctestrunIn(dir) {
2613
+ let entries;
2614
+ try {
2615
+ entries = import_node_fs5.default.readdirSync(dir);
2616
+ } catch {
588
2617
  return null;
589
2618
  }
590
- try {
591
- const parsed = parseBundleIdentifier(import_node_fs2.default.readFileSync(infoPlistPath, "utf-8"));
592
- if (parsed) {
593
- return parsed;
2619
+ const match = entries.filter((name) => name.endsWith(".xctestrun") && !name.startsWith(PREPARED_XCTESTRUN_PREFIX)).sort()[0];
2620
+ return match ? import_node_path5.default.join(dir, match) : null;
2621
+ }
2622
+ function resolveOverrideXctestrun(override) {
2623
+ if (!import_node_fs5.default.existsSync(override)) {
2624
+ throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
2625
+ }
2626
+ if (override.endsWith(".xctestrun")) {
2627
+ return override;
2628
+ }
2629
+ const candidates = [];
2630
+ const stat = import_node_fs5.default.statSync(override);
2631
+ if (override.endsWith(".app")) {
2632
+ candidates.push(import_node_path5.default.dirname(import_node_path5.default.dirname(override)));
2633
+ } else if (stat.isDirectory()) {
2634
+ candidates.push(override, productsDir(override));
2635
+ }
2636
+ for (const dir of candidates) {
2637
+ const found = findXctestrunIn(dir);
2638
+ if (found) {
2639
+ return found;
594
2640
  }
595
- } catch {
596
2641
  }
597
- if (process.platform !== "darwin" || !import_node_fs2.default.existsSync("/usr/bin/plutil")) {
598
- return null;
2642
+ throw new Error(
2643
+ `PROWL_WDA_RUNNER (${override}) does not resolve to a WebDriverAgent .xctestrun. Point it at the generated \`*.xctestrun\`, at the \`-derivedDataPath\` directory from \`xcodebuild build-for-testing\`, or at that build's runner \`.app\`.`
2644
+ );
2645
+ }
2646
+ async function resolveWdaTestRun(options = {}) {
2647
+ const runner = options.runner ?? execFileXcrunRunner;
2648
+ const env = options.env ?? process.env;
2649
+ const homeDir = options.homeDir ?? import_node_os2.default.homedir();
2650
+ const log = options.logger ?? ((message) => process.stderr.write(`${message}
2651
+ `));
2652
+ const override = env.PROWL_WDA_RUNNER;
2653
+ if (override) {
2654
+ return resolveOverrideXctestrun(override);
599
2655
  }
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 }
2656
+ const { projectPath, version } = resolveWdaProject(options.requireFn);
2657
+ const xcode = await xcodeVersion(runner);
2658
+ if (!xcode) {
2659
+ throw new Error(
2660
+ "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."
2661
+ );
2662
+ }
2663
+ const cacheDir = wdaCacheDir(version, xcode, homeDir);
2664
+ const cached = findXctestrunIn(productsDir(cacheDir));
2665
+ if (cached) {
2666
+ return cached;
2667
+ }
2668
+ log(
2669
+ `Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
2670
+ );
2671
+ import_node_fs5.default.mkdirSync(cacheDir, { recursive: true });
2672
+ const result = await runner(
2673
+ [
2674
+ "xcodebuild",
2675
+ "build-for-testing",
2676
+ "-project",
2677
+ projectPath,
2678
+ "-scheme",
2679
+ "WebDriverAgentRunner",
2680
+ "-destination",
2681
+ "generic/platform=iOS Simulator",
2682
+ "-derivedDataPath",
2683
+ cacheDir,
2684
+ "CODE_SIGNING_ALLOWED=NO"
2685
+ ],
2686
+ { timeoutMs: 12e5 }
2687
+ );
2688
+ if (result.code !== 0) {
2689
+ throw new Error(
2690
+ `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)}`
2691
+ );
2692
+ }
2693
+ const built = findXctestrunIn(productsDir(cacheDir));
2694
+ if (!built) {
2695
+ throw new Error(
2696
+ `WebDriverAgent build succeeded but no .xctestrun was found under ${productsDir(cacheDir)}. This may indicate an Xcode layout change; set PROWL_WDA_RUNNER to a prebuilt runner/xctestrun.`
605
2697
  );
606
- return output.trim() || null;
607
- } catch {
608
- return null;
609
2698
  }
2699
+ return built;
610
2700
  }
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);
2701
+ function injectUsePortIntoXctestrun(plist, port) {
2702
+ const clone = structuredClone(plist);
2703
+ let injected = 0;
2704
+ const visit = (node) => {
2705
+ if (Array.isArray(node)) {
2706
+ for (const entry of node) {
2707
+ visit(entry);
2708
+ }
2709
+ return;
620
2710
  }
621
- const bundleId = readBundleIdentifier(app);
622
- if (bundleId) {
623
- identities.add(bundleId);
2711
+ if (!node || typeof node !== "object") {
2712
+ return;
2713
+ }
2714
+ const obj = node;
2715
+ const isTarget = typeof obj.TestBundlePath === "string" || typeof obj.TestHostPath === "string";
2716
+ const existingEnv = obj.EnvironmentVariables && typeof obj.EnvironmentVariables === "object" && !Array.isArray(obj.EnvironmentVariables) ? obj.EnvironmentVariables : void 0;
2717
+ if (isTarget || existingEnv) {
2718
+ const env = existingEnv ?? {};
2719
+ env[WDA_USE_PORT_ENV] = String(port);
2720
+ obj.EnvironmentVariables = env;
2721
+ injected += 1;
624
2722
  }
2723
+ for (const value of Object.values(obj)) {
2724
+ visit(value);
2725
+ }
2726
+ };
2727
+ visit(clone);
2728
+ if (injected === 0) {
2729
+ throw new Error(
2730
+ "Could not find a test target in the WebDriverAgent .xctestrun to set USE_PORT. The xctestrun format may have changed; set PROWL_WDA_RUNNER to a compatible runner."
2731
+ );
625
2732
  }
626
- return [...identities];
2733
+ return clone;
2734
+ }
2735
+ function runPlutil(args) {
2736
+ return new Promise((resolve, reject) => {
2737
+ (0, import_node_child_process6.execFile)(
2738
+ "plutil",
2739
+ args,
2740
+ { encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 },
2741
+ (error, stdout, stderr) => {
2742
+ if (error) {
2743
+ reject(
2744
+ new Error(
2745
+ `\`plutil ${args.join(" ")}\` failed: ${(stderr || error.message).slice(0, 400)}`
2746
+ )
2747
+ );
2748
+ return;
2749
+ }
2750
+ resolve(stdout ?? "");
2751
+ }
2752
+ );
2753
+ });
627
2754
  }
628
- function assertTargetAppAllowed(allowedApps, app) {
629
- if (allowedApps.length === 0) {
2755
+ var defaultWdaTestRunPreparer = async ({ xctestrunPath, port }) => {
2756
+ const json = await runPlutil(["-convert", "json", "-o", "-", xctestrunPath]);
2757
+ let parsed;
2758
+ try {
2759
+ parsed = JSON.parse(json);
2760
+ } catch {
2761
+ throw new Error(`Could not parse the WebDriverAgent .xctestrun as JSON: ${xctestrunPath}`);
2762
+ }
2763
+ const injected = injectUsePortIntoXctestrun(parsed, port);
2764
+ const stem = `${PREPARED_XCTESTRUN_PREFIX}${port}-${process.pid}`;
2765
+ const outPath = import_node_path5.default.join(import_node_path5.default.dirname(xctestrunPath), `${stem}.xctestrun`);
2766
+ const jsonPath = import_node_path5.default.join(import_node_os2.default.tmpdir(), `${stem}.json`);
2767
+ import_node_fs5.default.writeFileSync(jsonPath, JSON.stringify(injected));
2768
+ try {
2769
+ await runPlutil(["-convert", "xml1", jsonPath, "-o", outPath]);
2770
+ } finally {
2771
+ import_node_fs5.default.rmSync(jsonPath, { force: true });
2772
+ }
2773
+ return outPath;
2774
+ };
2775
+ function cleanupPreparedTestRun(preparedPath) {
2776
+ if (!preparedPath) {
630
2777
  return;
631
2778
  }
632
- const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => macosAppAllowedIdentities(allowedApp)));
633
- if (macosAppAllowedIdentities(app).some((identity) => allowedIdentities.has(identity))) {
2779
+ if (!import_node_path5.default.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
634
2780
  return;
635
2781
  }
636
- throw new Error(
637
- `Target app "${app}" is not in guardrails.allowedApps (${allowedApps.join(", ")}).`
638
- );
2782
+ import_node_fs5.default.rmSync(preparedPath, { force: true });
2783
+ }
2784
+ function wdaTestRunArgs(xctestrunPath, udid) {
2785
+ return [
2786
+ "xcodebuild",
2787
+ "test-without-building",
2788
+ "-xctestrun",
2789
+ xctestrunPath,
2790
+ "-destination",
2791
+ `id=${udid}`
2792
+ ];
2793
+ }
2794
+ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
2795
+ if (!looksLikeIosAppPath(app)) {
2796
+ assertIosAppAllowed(allowedApps, app);
2797
+ if (coldStart) {
2798
+ throw new Error(
2799
+ `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.`
2800
+ );
2801
+ }
2802
+ return app;
2803
+ }
2804
+ const appPath = import_node_path5.default.resolve(app);
2805
+ if (!import_node_fs5.default.existsSync(appPath)) {
2806
+ throw new Error(`.app bundle not found: ${appPath}`);
2807
+ }
2808
+ const bundleId = readIosBundleIdentifier(appPath);
2809
+ if (!bundleId) {
2810
+ throw new Error(
2811
+ `Could not read CFBundleIdentifier from "${appPath}" (root Info.plist). Ensure target.app points at a built iOS .app bundle.`
2812
+ );
2813
+ }
2814
+ assertIosAppAllowed(allowedApps, appPath);
2815
+ if (coldStart) {
2816
+ await uninstallApp(runner, udid, bundleId);
2817
+ }
2818
+ await installApp(runner, udid, appPath);
2819
+ return bundleId;
2820
+ }
2821
+ async function launchIosSession(options) {
2822
+ const runner = options.runner ?? execFileXcrunRunner;
2823
+ const spawner = options.spawner ?? spawnXcrunProcess;
2824
+ const preparer = options.testRunPreparer ?? defaultWdaTestRunPreparer;
2825
+ const portAllocator = options.portAllocator ?? findFreePort;
2826
+ const connector = options.agentConnector ?? defaultIosAgentConnector;
2827
+ const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
2828
+ const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, 6e4);
2829
+ const devices = await listSimulators(runner);
2830
+ const udid = selectSimulatorUdid(devices, options.udid);
2831
+ const reservation = await reserveSimulatorUdid(udid, { lockRoot: options.simulatorLockRoot });
2832
+ let reservationReleased = false;
2833
+ const releaseReservation = async () => {
2834
+ if (reservationReleased) {
2835
+ return;
2836
+ }
2837
+ reservationReleased = true;
2838
+ await reservation.release();
2839
+ };
2840
+ try {
2841
+ const bundleId = await resolveBundleId(
2842
+ options.app,
2843
+ runner,
2844
+ udid,
2845
+ options.coldStart ?? false,
2846
+ options.allowedApps ?? []
2847
+ );
2848
+ const baseTestRun = options.wdaTestRun ?? await resolveWdaTestRun({ runner, logger: options.logger });
2849
+ for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
2850
+ let client;
2851
+ let wdaProcess;
2852
+ let preparedTestRun;
2853
+ let tornDown = false;
2854
+ let terminateTarget = false;
2855
+ let stage = "prepare";
2856
+ const teardownAttempt = async () => {
2857
+ if (tornDown) {
2858
+ return;
2859
+ }
2860
+ tornDown = true;
2861
+ if (client) {
2862
+ await client.close().catch(() => void 0);
2863
+ }
2864
+ wdaProcess?.kill();
2865
+ await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
2866
+ if (terminateTarget) {
2867
+ await terminateApp(runner, udid, bundleId);
2868
+ }
2869
+ cleanupPreparedTestRun(preparedTestRun);
2870
+ };
2871
+ try {
2872
+ const port = await portAllocator();
2873
+ preparedTestRun = await preparer({ xctestrunPath: baseTestRun, port });
2874
+ stage = "wda-launch";
2875
+ wdaProcess = spawner(wdaTestRunArgs(preparedTestRun, udid));
2876
+ stage = "target-launch";
2877
+ terminateTarget = true;
2878
+ await launchApp(runner, udid, bundleId);
2879
+ stage = "connect";
2880
+ client = await connector({ host: "127.0.0.1", port, bundleId, requestTimeoutMs, readyDeadlineMs });
2881
+ const driver = createIosDriver(client, {
2882
+ appLabel: bundleId,
2883
+ captureScreenshot: (outPath) => captureScreenshot(runner, udid, outPath)
2884
+ });
2885
+ const teardown = async () => {
2886
+ await teardownAttempt();
2887
+ await releaseReservation();
2888
+ };
2889
+ return { client, driver, bundleId, udid, teardown };
2890
+ } catch (error) {
2891
+ await teardownAttempt();
2892
+ if (stage === "target-launch" || attempt >= WDA_STARTUP_ATTEMPTS) {
2893
+ throw error instanceof Error ? error : new Error(String(error));
2894
+ }
2895
+ }
2896
+ }
2897
+ throw new Error("WebDriverAgent startup failed without an error.");
2898
+ } catch (error) {
2899
+ await releaseReservation().catch(() => void 0);
2900
+ throw error;
2901
+ }
2902
+ }
2903
+ async function closeIosSession(session) {
2904
+ await session.teardown();
639
2905
  }
640
2906
 
641
2907
  // src/runner/index.ts
642
- var import_node_fs11 = __toESM(require("fs"), 1);
643
- var import_node_path11 = __toESM(require("path"), 1);
2908
+ var import_node_fs14 = __toESM(require("fs"), 1);
2909
+ var import_node_path14 = __toESM(require("path"), 1);
644
2910
 
645
2911
  // src/config/loader.ts
646
- var import_node_fs3 = __toESM(require("fs"), 1);
647
- var import_node_path3 = __toESM(require("path"), 1);
2912
+ var import_node_fs6 = __toESM(require("fs"), 1);
2913
+ var import_node_path6 = __toESM(require("path"), 1);
648
2914
  var import_yaml = __toESM(require("yaml"), 1);
649
2915
  var import_dotenv = __toESM(require("dotenv"), 1);
650
2916
 
@@ -676,7 +2942,24 @@ var macosTargetSchema = import_zod.z.object({
676
2942
  type: import_zod.z.literal("macos"),
677
2943
  app: import_zod.z.string().min(1)
678
2944
  }).strict();
679
- var targetSchema = import_zod.z.union([macosTargetSchema, webTargetSchema]);
2945
+ var androidTargetSchema = import_zod.z.object({
2946
+ type: import_zod.z.literal("android"),
2947
+ app: import_zod.z.string().min(1),
2948
+ deviceSerial: import_zod.z.string().min(1).optional(),
2949
+ coldStart: import_zod.z.boolean().optional()
2950
+ }).strict();
2951
+ var iosTargetSchema = import_zod.z.object({
2952
+ type: import_zod.z.literal("ios"),
2953
+ app: import_zod.z.string().min(1),
2954
+ udid: import_zod.z.string().min(1).optional(),
2955
+ coldStart: import_zod.z.boolean().optional()
2956
+ }).strict();
2957
+ var targetSchema = import_zod.z.union([
2958
+ macosTargetSchema,
2959
+ androidTargetSchema,
2960
+ iosTargetSchema,
2961
+ webTargetSchema
2962
+ ]);
680
2963
  var configSchema = import_zod.z.object({
681
2964
  target: targetSchema,
682
2965
  browser: import_zod.z.object({
@@ -896,6 +3179,9 @@ var assertScreenshotStepSchema = import_zod.z.object({
896
3179
  threshold: import_zod.z.number().min(0).max(1).optional()
897
3180
  }).strict()
898
3181
  }).strict();
3182
+ var assertWithAiStepSchema = import_zod.z.object({
3183
+ assertWithAI: import_zod.z.string().min(1)
3184
+ }).strict();
899
3185
  var copyTextStepSchema = import_zod.z.object({
900
3186
  copyText: import_zod.z.object({
901
3187
  selector: import_zod.z.string().min(1),
@@ -938,6 +3224,7 @@ var stepSchema = import_zod.z.union([
938
3224
  evalScriptStepSchema,
939
3225
  runScriptStepSchema,
940
3226
  assertScreenshotStepSchema,
3227
+ assertWithAiStepSchema,
941
3228
  copyTextStepSchema,
942
3229
  waitForDownloadStepSchema
943
3230
  ]);
@@ -1018,12 +3305,12 @@ function findConfigPath(startDir) {
1018
3305
  let current = startDir;
1019
3306
  while (current) {
1020
3307
  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)) {
3308
+ const candidate = import_node_path6.default.join(current, dir, "config.yml");
3309
+ if (import_node_fs6.default.existsSync(candidate)) {
1023
3310
  return candidate;
1024
3311
  }
1025
3312
  }
1026
- const parent = import_node_path3.default.dirname(current);
3313
+ const parent = import_node_path6.default.dirname(current);
1027
3314
  if (parent === current) {
1028
3315
  break;
1029
3316
  }
@@ -1050,9 +3337,28 @@ function resolveViewport(value) {
1050
3337
  return value;
1051
3338
  }
1052
3339
  function resolveTarget(target) {
1053
- if (target && target.type === "macos") {
3340
+ const type = target?.type;
3341
+ if (type === "macos") {
1054
3342
  return { type: "macos", app: target.app };
1055
3343
  }
3344
+ if (type === "android") {
3345
+ const androidTarget = target;
3346
+ return {
3347
+ type: "android",
3348
+ app: androidTarget.app,
3349
+ ...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
3350
+ ...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
3351
+ };
3352
+ }
3353
+ if (type === "ios") {
3354
+ const iosTarget = target;
3355
+ return {
3356
+ type: "ios",
3357
+ app: iosTarget.app,
3358
+ ...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
3359
+ ...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
3360
+ };
3361
+ }
1056
3362
  return {
1057
3363
  type: "web",
1058
3364
  url: target?.url ?? DEFAULT_WEB_URL
@@ -1111,19 +3417,19 @@ function ensureAllowedDomain(allowed, urlValue) {
1111
3417
  return allowed;
1112
3418
  }
1113
3419
  function loadConfig(configPath) {
1114
- const resolvedPath = configPath ? import_node_path3.default.resolve(configPath) : findConfigPath(process.cwd());
3420
+ const resolvedPath = configPath ? import_node_path6.default.resolve(configPath) : findConfigPath(process.cwd());
1115
3421
  if (!resolvedPath) {
1116
3422
  throw new Error("Could not find .prowl/config.yml. Run `prowl init` first.");
1117
3423
  }
1118
- if (!import_node_fs3.default.existsSync(resolvedPath)) {
3424
+ if (!import_node_fs6.default.existsSync(resolvedPath)) {
1119
3425
  throw new Error(`Config file not found at ${resolvedPath}`);
1120
3426
  }
1121
- const configDir = import_node_path3.default.dirname(resolvedPath);
1122
- if (import_node_path3.default.basename(configDir) === LEGACY_CONFIG_DIR) {
3427
+ const configDir = import_node_path6.default.dirname(resolvedPath);
3428
+ if (import_node_path6.default.basename(configDir) === LEGACY_CONFIG_DIR) {
1123
3429
  warnLegacyConfigDir();
1124
3430
  }
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");
3431
+ import_dotenv.default.config({ path: import_node_path6.default.join(configDir, ".env"), override: false });
3432
+ const raw = import_node_fs6.default.readFileSync(resolvedPath, "utf-8");
1127
3433
  const parsed = import_yaml.default.parse(raw) ?? {};
1128
3434
  const validated = configSchema.parse(parsed);
1129
3435
  const config = mergeConfig(validated);
@@ -1137,32 +3443,32 @@ function loadConfig(configPath) {
1137
3443
  }
1138
3444
  function loadHunt(huntName, configDir) {
1139
3445
  assertValidHuntName(huntName);
1140
- const huntPath = import_node_path3.default.join(configDir, "hunts", `${huntName}.yml`);
1141
- if (!import_node_fs3.default.existsSync(huntPath)) {
3446
+ const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
3447
+ if (!import_node_fs6.default.existsSync(huntPath)) {
1142
3448
  throw new Error(`Hunt file not found: ${huntPath}`);
1143
3449
  }
1144
- const raw = import_node_fs3.default.readFileSync(huntPath, "utf-8");
3450
+ const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
1145
3451
  const parsed = import_yaml.default.parse(raw) ?? {};
1146
3452
  const validated = huntSchema.parse(parsed);
1147
3453
  return validated;
1148
3454
  }
1149
3455
  function loadHuntTags(huntName, configDir) {
1150
3456
  assertValidHuntName(huntName);
1151
- const huntPath = import_node_path3.default.join(configDir, "hunts", `${huntName}.yml`);
1152
- if (!import_node_fs3.default.existsSync(huntPath)) {
3457
+ const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
3458
+ if (!import_node_fs6.default.existsSync(huntPath)) {
1153
3459
  return [];
1154
3460
  }
1155
- const raw = import_node_fs3.default.readFileSync(huntPath, "utf-8");
3461
+ const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
1156
3462
  const parsed = import_yaml.default.parse(raw) ?? {};
1157
3463
  return Array.isArray(parsed.tags) ? parsed.tags : [];
1158
3464
  }
1159
3465
  function loadHuntMeta(huntName, configDir) {
1160
3466
  assertValidHuntName(huntName);
1161
- const huntPath = import_node_path3.default.join(configDir, "hunts", `${huntName}.yml`);
1162
- if (!import_node_fs3.default.existsSync(huntPath)) {
3467
+ const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
3468
+ if (!import_node_fs6.default.existsSync(huntPath)) {
1163
3469
  return { tags: [] };
1164
3470
  }
1165
- const raw = import_node_fs3.default.readFileSync(huntPath, "utf-8");
3471
+ const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
1166
3472
  const parsed = import_yaml.default.parse(raw) ?? {};
1167
3473
  return {
1168
3474
  description: typeof parsed.description === "string" ? parsed.description : void 0,
@@ -1170,24 +3476,24 @@ function loadHuntMeta(huntName, configDir) {
1170
3476
  };
1171
3477
  }
1172
3478
  function listHunts(configDir) {
1173
- const huntsDir = import_node_path3.default.join(configDir, "hunts");
1174
- if (!import_node_fs3.default.existsSync(huntsDir)) {
3479
+ const huntsDir = import_node_path6.default.join(configDir, "hunts");
3480
+ if (!import_node_fs6.default.existsSync(huntsDir)) {
1175
3481
  return [];
1176
3482
  }
1177
- const stats = import_node_fs3.default.statSync(huntsDir);
3483
+ const stats = import_node_fs6.default.statSync(huntsDir);
1178
3484
  if (!stats.isDirectory()) {
1179
3485
  throw new Error(`Hunts path is not a directory: ${huntsDir}`);
1180
3486
  }
1181
3487
  const results = [];
1182
3488
  function scanDir(dir) {
1183
- const entries = import_node_fs3.default.readdirSync(dir, { withFileTypes: true });
3489
+ const entries = import_node_fs6.default.readdirSync(dir, { withFileTypes: true });
1184
3490
  for (const entry of entries) {
1185
3491
  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);
3492
+ const fullPath = import_node_path6.default.join(dir, entry.name);
3493
+ const relative = import_node_path6.default.relative(huntsDir, fullPath);
1188
3494
  results.push(relative.replace(/\.yml$/, ""));
1189
3495
  } else if (entry.isDirectory()) {
1190
- scanDir(import_node_path3.default.join(dir, entry.name));
3496
+ scanDir(import_node_path6.default.join(dir, entry.name));
1191
3497
  }
1192
3498
  }
1193
3499
  }
@@ -1489,6 +3795,11 @@ function interpolateStep(step, vars, stepPath2, redacted) {
1489
3795
  }
1490
3796
  };
1491
3797
  }
3798
+ if ("assertWithAI" in step) {
3799
+ return {
3800
+ assertWithAI: interpolateString(step.assertWithAI, vars).value
3801
+ };
3802
+ }
1492
3803
  if ("copyText" in step) {
1493
3804
  return {
1494
3805
  copyText: {
@@ -1562,8 +3873,8 @@ function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
1562
3873
  }
1563
3874
 
1564
3875
  // src/browser/playwright-driver.ts
1565
- var import_node_fs4 = __toESM(require("fs"), 1);
1566
- var import_node_path4 = __toESM(require("path"), 1);
3876
+ var import_node_fs7 = __toESM(require("fs"), 1);
3877
+ var import_node_path7 = __toESM(require("path"), 1);
1567
3878
  var import_playwright = require("playwright");
1568
3879
  var ENGINES = { chromium: import_playwright.chromium, firefox: import_playwright.firefox, webkit: import_playwright.webkit };
1569
3880
  async function launchBrowser(options) {
@@ -1585,14 +3896,14 @@ async function launchBrowser(options) {
1585
3896
  contextOptions.viewport = options.viewport;
1586
3897
  }
1587
3898
  if (options.storageStatePath) {
1588
- if (import_node_fs4.default.existsSync(options.storageStatePath)) {
3899
+ if (import_node_fs7.default.existsSync(options.storageStatePath)) {
1589
3900
  contextOptions.storageState = options.storageStatePath;
1590
3901
  } else {
1591
3902
  console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
1592
3903
  }
1593
3904
  }
1594
3905
  if (options.recordHar) {
1595
- contextOptions.recordHar = { path: import_node_path4.default.join(options.runDir, "network.har") };
3906
+ contextOptions.recordHar = { path: import_node_path7.default.join(options.runDir, "network.har") };
1596
3907
  }
1597
3908
  const context = await browser.newContext(contextOptions);
1598
3909
  const page = await context.newPage();
@@ -1600,7 +3911,7 @@ async function launchBrowser(options) {
1600
3911
  page.setDefaultNavigationTimeout(options.timeout);
1601
3912
  let tracePath;
1602
3913
  if (options.trace) {
1603
- tracePath = import_node_path4.default.join(options.runDir, "trace.zip");
3914
+ tracePath = import_node_path7.default.join(options.runDir, "trace.zip");
1604
3915
  await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
1605
3916
  }
1606
3917
  return { browser, context, page, tracePath };
@@ -1775,8 +4086,8 @@ function createPlaywrightDriver(page) {
1775
4086
  }
1776
4087
 
1777
4088
  // src/runner/steps.ts
1778
- var import_node_fs6 = __toESM(require("fs"), 1);
1779
- var import_node_path6 = __toESM(require("path"), 1);
4089
+ var import_node_fs9 = __toESM(require("fs"), 1);
4090
+ var import_node_path9 = __toESM(require("path"), 1);
1780
4091
 
1781
4092
  // src/runner/healing.ts
1782
4093
  var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
@@ -1820,8 +4131,8 @@ async function healSelector(probe, selector, options) {
1820
4131
  for (const candidate of buildHealCandidates(selector)) {
1821
4132
  let count;
1822
4133
  try {
1823
- const locator = probe.locator(candidate.selector);
1824
- count = await locator.count();
4134
+ const locator2 = probe.locator(candidate.selector);
4135
+ count = await locator2.count();
1825
4136
  } catch {
1826
4137
  continue;
1827
4138
  }
@@ -1920,14 +4231,254 @@ function createRunPolicy(driver, options) {
1920
4231
  return { selector: healed.selector, healedFrom: healed.healedFrom };
1921
4232
  }
1922
4233
  return {
1923
- assertWithinMaxSteps,
1924
- ensureUrlAllowed,
1925
- ensureAppAllowed,
1926
- ensureLocationAllowed,
1927
- assertAllowedSelector,
1928
- resolveActionSelector
4234
+ assertWithinMaxSteps,
4235
+ ensureUrlAllowed,
4236
+ ensureAppAllowed,
4237
+ ensureLocationAllowed,
4238
+ assertAllowedSelector,
4239
+ resolveActionSelector
4240
+ };
4241
+ }
4242
+
4243
+ // src/generator/ai.ts
4244
+ var DEFAULT_BASE_URL = {
4245
+ anthropic: "https://api.anthropic.com",
4246
+ openai: "https://api.openai.com"
4247
+ };
4248
+ var AI_REQUEST_TIMEOUT_MS = 3e4;
4249
+ function apiRoot(config) {
4250
+ return config.baseUrl ?? DEFAULT_BASE_URL[config.provider];
4251
+ }
4252
+ function defaultModelFor(provider) {
4253
+ return provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
4254
+ }
4255
+ function resolveAiEnv() {
4256
+ const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
4257
+ if (provider !== "anthropic" && provider !== "openai") {
4258
+ throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
4259
+ }
4260
+ const model = process.env.PROWL_AI_MODEL ?? defaultModelFor(provider);
4261
+ const baseUrl = normalizeBaseUrl(process.env.PROWL_AI_BASE_URL) ?? DEFAULT_BASE_URL[provider];
4262
+ return { provider, model, baseUrl, apiKey: process.env.PROWL_AI_KEY };
4263
+ }
4264
+ function normalizeBaseUrl(value) {
4265
+ if (value === void 0) return void 0;
4266
+ const trimmed = value.trim().replace(/\/+$/, "");
4267
+ return trimmed.length > 0 ? trimmed : void 0;
4268
+ }
4269
+ function resolveAiConfig() {
4270
+ const env = resolveAiEnv();
4271
+ if (!env.apiKey) {
4272
+ throw new Error(
4273
+ "PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
4274
+ );
4275
+ }
4276
+ return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
4277
+ }
4278
+ function tryResolveAiConfig() {
4279
+ const env = resolveAiEnv();
4280
+ if (!env.apiKey) {
4281
+ return null;
4282
+ }
4283
+ return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
4284
+ }
4285
+ async function generateWithAi(prompt, config) {
4286
+ if (config.provider === "anthropic") {
4287
+ return generateWithAnthropic(prompt, config);
4288
+ }
4289
+ return generateWithOpenAi(prompt, config);
4290
+ }
4291
+ async function generateWithAnthropic(prompt, config) {
4292
+ const data = await postJson(
4293
+ "Anthropic",
4294
+ `${apiRoot(config)}/v1/messages`,
4295
+ anthropicHeaders(config),
4296
+ {
4297
+ model: config.model,
4298
+ max_tokens: 4096,
4299
+ messages: [
4300
+ { role: "user", content: prompt }
4301
+ ]
4302
+ }
4303
+ );
4304
+ return extractAnthropicText(data);
4305
+ }
4306
+ async function generateWithOpenAi(prompt, config) {
4307
+ const data = await postJson(
4308
+ "OpenAI",
4309
+ `${apiRoot(config)}/v1/chat/completions`,
4310
+ openAiHeaders(config),
4311
+ {
4312
+ model: config.model,
4313
+ messages: [
4314
+ { role: "user", content: prompt }
4315
+ ],
4316
+ max_tokens: 4096
4317
+ }
4318
+ );
4319
+ return extractOpenAiText(data);
4320
+ }
4321
+ function buildVisionPrompt(assertion) {
4322
+ return [
4323
+ "You are a meticulous QA reviewer. You are given a screenshot of an application",
4324
+ "and a single assertion describing what should be true about it.",
4325
+ "",
4326
+ "Assertion:",
4327
+ assertion,
4328
+ "",
4329
+ "Decide whether the assertion holds for the screenshot. Judge ONLY what is",
4330
+ "visible; do not assume behavior you cannot see. Be strict: if the assertion",
4331
+ "is not clearly satisfied, it fails.",
4332
+ "",
4333
+ "Reply with ONLY a single JSON object on one line, no markdown, no code fences:",
4334
+ '{"pass": <true|false>, "reason": "<one concise sentence explaining the verdict>"}'
4335
+ ].join("\n");
4336
+ }
4337
+ function parseVisionVerdict(raw) {
4338
+ const cleaned = stripCodeFences(raw).trim();
4339
+ const candidate = extractJsonObject(cleaned);
4340
+ if (candidate === null) {
4341
+ throw new Error(
4342
+ `Could not parse a JSON verdict from the AI response: ${truncateForError(raw)}`
4343
+ );
4344
+ }
4345
+ let parsed;
4346
+ try {
4347
+ parsed = JSON.parse(candidate);
4348
+ } catch {
4349
+ throw new Error(
4350
+ `AI verdict was not valid JSON: ${truncateForError(raw)}`
4351
+ );
4352
+ }
4353
+ if (typeof parsed !== "object" || parsed === null) {
4354
+ throw new Error(`AI verdict was not a JSON object: ${truncateForError(raw)}`);
4355
+ }
4356
+ const record = parsed;
4357
+ if (typeof record.pass !== "boolean") {
4358
+ throw new Error(
4359
+ `AI verdict is missing a boolean "pass" field: ${truncateForError(raw)}`
4360
+ );
4361
+ }
4362
+ const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : record.pass ? "Assertion satisfied." : "Assertion not satisfied.";
4363
+ return { pass: record.pass, reason };
4364
+ }
4365
+ function stripCodeFences(text) {
4366
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
4367
+ return fence ? fence[1] : text;
4368
+ }
4369
+ function extractJsonObject(text) {
4370
+ const trimmed = text.trim();
4371
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
4372
+ return trimmed;
4373
+ }
4374
+ const first = trimmed.indexOf("{");
4375
+ const last = trimmed.lastIndexOf("}");
4376
+ if (first === -1 || last === -1 || last <= first) {
4377
+ return null;
4378
+ }
4379
+ return trimmed.slice(first, last + 1);
4380
+ }
4381
+ function truncateForError(text) {
4382
+ const collapsed = text.replace(/\s+/g, " ").trim();
4383
+ return collapsed.length > 200 ? `${collapsed.slice(0, 199)}\u2026` : collapsed;
4384
+ }
4385
+ async function assertWithAiVision(input, config) {
4386
+ const raw = config.provider === "anthropic" ? await visionWithAnthropic(input, config) : await visionWithOpenAi(input, config);
4387
+ return parseVisionVerdict(raw);
4388
+ }
4389
+ async function visionWithAnthropic(input, config) {
4390
+ const data = await postJson(
4391
+ "Anthropic",
4392
+ `${apiRoot(config)}/v1/messages`,
4393
+ anthropicHeaders(config),
4394
+ {
4395
+ model: config.model,
4396
+ max_tokens: 1024,
4397
+ temperature: 0,
4398
+ messages: [
4399
+ {
4400
+ role: "user",
4401
+ content: [
4402
+ {
4403
+ type: "image",
4404
+ source: {
4405
+ type: "base64",
4406
+ media_type: input.mediaType,
4407
+ data: input.imageBase64
4408
+ }
4409
+ },
4410
+ { type: "text", text: buildVisionPrompt(input.assertion) }
4411
+ ]
4412
+ }
4413
+ ]
4414
+ }
4415
+ );
4416
+ return extractAnthropicText(data);
4417
+ }
4418
+ async function visionWithOpenAi(input, config) {
4419
+ const data = await postJson(
4420
+ "OpenAI",
4421
+ `${apiRoot(config)}/v1/chat/completions`,
4422
+ openAiHeaders(config),
4423
+ {
4424
+ model: config.model,
4425
+ max_tokens: 1024,
4426
+ temperature: 0,
4427
+ messages: [
4428
+ {
4429
+ role: "user",
4430
+ content: [
4431
+ { type: "text", text: buildVisionPrompt(input.assertion) },
4432
+ {
4433
+ type: "image_url",
4434
+ image_url: { url: `data:${input.mediaType};base64,${input.imageBase64}` }
4435
+ }
4436
+ ]
4437
+ }
4438
+ ]
4439
+ }
4440
+ );
4441
+ return extractOpenAiText(data);
4442
+ }
4443
+ async function postJson(provider, url, headers, body) {
4444
+ const response = await fetch(url, {
4445
+ method: "POST",
4446
+ headers,
4447
+ body: JSON.stringify(body),
4448
+ signal: AbortSignal.timeout(AI_REQUEST_TIMEOUT_MS)
4449
+ });
4450
+ if (!response.ok) {
4451
+ const responseBody = await response.text();
4452
+ throw new Error(`${provider} API error (${response.status}): ${responseBody}`);
4453
+ }
4454
+ return await response.json();
4455
+ }
4456
+ function anthropicHeaders(config) {
4457
+ return {
4458
+ "Content-Type": "application/json",
4459
+ "x-api-key": config.apiKey,
4460
+ "anthropic-version": "2023-06-01"
4461
+ };
4462
+ }
4463
+ function openAiHeaders(config) {
4464
+ return {
4465
+ "Content-Type": "application/json",
4466
+ "Authorization": `Bearer ${config.apiKey}`
1929
4467
  };
1930
4468
  }
4469
+ function extractAnthropicText(data) {
4470
+ const textBlock = data.content?.find((c) => c.type === "text");
4471
+ if (!textBlock?.text) {
4472
+ throw new Error("Anthropic API returned no text content");
4473
+ }
4474
+ return textBlock.text;
4475
+ }
4476
+ function extractOpenAiText(data) {
4477
+ if (!data.choices?.[0]?.message?.content) {
4478
+ throw new Error("OpenAI API returned no content");
4479
+ }
4480
+ return data.choices[0].message.content;
4481
+ }
1931
4482
 
1932
4483
  // src/runner/steps.ts
1933
4484
  function getStepType(step) {
@@ -1957,6 +4508,7 @@ function getStepType(step) {
1957
4508
  if ("evalScript" in step) return "evalScript";
1958
4509
  if ("runScript" in step) return "runScript";
1959
4510
  if ("assertScreenshot" in step) return "assertScreenshot";
4511
+ if ("assertWithAI" in step) return "assertWithAI";
1960
4512
  if ("copyText" in step) return "copyText";
1961
4513
  if ("waitForDownload" in step) return "waitForDownload";
1962
4514
  return "step";
@@ -2016,6 +4568,9 @@ function applyRuntimeVars(step, vars) {
2016
4568
  }
2017
4569
  };
2018
4570
  }
4571
+ if ("assertWithAI" in step) {
4572
+ return { assertWithAI: sub(step.assertWithAI) };
4573
+ }
2019
4574
  if ("copyText" in step) {
2020
4575
  return { copyText: { selector: sub(step.copyText.selector), as: step.copyText.as } };
2021
4576
  }
@@ -2266,7 +4821,7 @@ async function runInlineAssert(driver, policy, assertion) {
2266
4821
  throw new Error("assert step is missing an assertion type");
2267
4822
  }
2268
4823
  function screenshotPath(screenshotsDir, fileName) {
2269
- return import_node_path6.default.join(screenshotsDir, fileName);
4824
+ return import_node_path9.default.join(screenshotsDir, fileName);
2270
4825
  }
2271
4826
  function stepPath(prefix, index) {
2272
4827
  return prefix ? `${prefix}.${index}` : `${index}`;
@@ -2283,12 +4838,12 @@ function validateDownloadFilename(suggestedFilename) {
2283
4838
  const safeFilename = suggestedFilename.trim();
2284
4839
  const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
2285
4840
  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)) {
4841
+ if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path9.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
2287
4842
  throw new Error(`Invalid download filename: "${suggestedFilename}"`);
2288
4843
  }
2289
4844
  return safeFilename;
2290
4845
  }
2291
- async function captureScreenshot(taker, filePath) {
4846
+ async function captureScreenshot2(taker, filePath) {
2292
4847
  try {
2293
4848
  await taker.screenshot({ path: filePath, fullPage: true });
2294
4849
  } catch (error) {
@@ -2463,7 +5018,7 @@ var STEP_HANDLERS = {
2463
5018
  if (!("setInputFiles" in h.step)) unknownStep();
2464
5019
  const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
2465
5020
  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);
5021
+ const resolveFile = (f) => import_node_path9.default.isAbsolute(f) ? f : import_node_path9.default.join(h.context.configDir, f);
2467
5022
  const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
2468
5023
  await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
2469
5024
  h.policy.ensureLocationAllowed(h.driver);
@@ -2500,7 +5055,7 @@ var STEP_HANDLERS = {
2500
5055
  redactedFillSteps: subRedacted,
2501
5056
  randomVars
2502
5057
  } = interpolateHunt(subHunt, process.env, h.context.randomVars);
2503
- const subTargetType = h.driver.capabilities.has("navigate") ? "web" : "macos";
5058
+ const subTargetType = h.context.targetType ?? (h.driver.capabilities.has("navigate") ? "web" : "macos");
2504
5059
  assertStepsSupportedByTarget(interpolatedSubHunt.steps, subTargetType);
2505
5060
  assertHuntAssertionsSupportedByTarget(interpolatedSubHunt.assertions, subTargetType);
2506
5061
  h.policy.assertWithinMaxSteps(interpolatedSubHunt.steps.length, huntName);
@@ -2825,15 +5380,15 @@ var STEP_HANDLERS = {
2825
5380
  if (!responseFile) {
2826
5381
  throw new Error("mock.response must include either body or file");
2827
5382
  }
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);
5383
+ const candidateFilePath = import_node_path9.default.isAbsolute(responseFile) ? responseFile : import_node_path9.default.join(h.context.configDir, responseFile);
5384
+ const resolvedConfigDir = import_node_path9.default.resolve(h.context.configDir);
5385
+ const resolvedFilePath = import_node_path9.default.resolve(candidateFilePath);
5386
+ const relativePath = import_node_path9.default.relative(resolvedConfigDir, resolvedFilePath);
5387
+ const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path9.default.sep}`) && !import_node_path9.default.isAbsolute(relativePath);
2833
5388
  if (!isWithinConfigDir) {
2834
5389
  throw new Error("mock.response.file must resolve within config directory");
2835
5390
  }
2836
- responseBody = await import_node_fs6.default.promises.readFile(resolvedFilePath, "utf-8");
5391
+ responseBody = await import_node_fs9.default.promises.readFile(resolvedFilePath, "utf-8");
2837
5392
  }
2838
5393
  const contentType = mock.response.contentType ?? "application/json";
2839
5394
  const status = mock.response.status;
@@ -2896,8 +5451,8 @@ var STEP_HANDLERS = {
2896
5451
  capabilities: ["evaluate"],
2897
5452
  run: async (h) => {
2898
5453
  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");
5454
+ 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);
5455
+ const fileContents = import_node_fs9.default.readFileSync(filePath, "utf-8");
2901
5456
  await h.driver.evaluate(fileContents);
2902
5457
  return {
2903
5458
  kind: "result",
@@ -2918,19 +5473,19 @@ var STEP_HANDLERS = {
2918
5473
  const name = h.step.assertScreenshot.name;
2919
5474
  const threshold = h.step.assertScreenshot.threshold ?? 0.1;
2920
5475
  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 });
5476
+ const baselinePath = import_node_path9.default.join(baselineDir, `${name}.png`);
5477
+ const currentScreenshotPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
5478
+ import_node_fs9.default.mkdirSync(import_node_path9.default.dirname(currentScreenshotPath), { recursive: true });
2924
5479
  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);
5480
+ h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-current.png`));
5481
+ if (!import_node_fs9.default.existsSync(baselinePath)) {
5482
+ import_node_fs9.default.copyFileSync(currentScreenshotPath, baselinePath);
2928
5483
  return {
2929
5484
  kind: "result",
2930
5485
  result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
2931
5486
  };
2932
5487
  }
2933
- const diffPath = import_node_path6.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
5488
+ const diffPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
2934
5489
  const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
2935
5490
  if (comparison.match) {
2936
5491
  return {
@@ -2943,12 +5498,54 @@ var STEP_HANDLERS = {
2943
5498
  }
2944
5499
  };
2945
5500
  }
2946
- h.screenshots.push(import_node_path6.default.join("screenshots", `${name}-diff.png`));
5501
+ h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-diff.png`));
2947
5502
  throw new Error(
2948
5503
  `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
2949
5504
  );
2950
5505
  }
2951
5506
  },
5507
+ assertWithAI: {
5508
+ capabilities: ["screenshot"],
5509
+ run: async (h) => {
5510
+ if (!("assertWithAI" in h.step)) unknownStep();
5511
+ const assertion = h.step.assertWithAI;
5512
+ const resolve = h.context.resolveAiConfig ?? tryResolveAiConfig;
5513
+ const aiConfig = resolve();
5514
+ if (!aiConfig) {
5515
+ return {
5516
+ kind: "result",
5517
+ result: {
5518
+ type: "assertWithAI",
5519
+ status: "warn",
5520
+ durationMs: Date.now() - h.stepStart,
5521
+ value: `skipped: no AI provider configured (set PROWL_AI_KEY to enable) \u2014 "${assertion}"`
5522
+ }
5523
+ };
5524
+ }
5525
+ const fileName = `assertWithAI_step_${h.index + 1}.png`;
5526
+ const relative = await h.addScreenshot(fileName);
5527
+ const screenshotFullPath = import_node_path9.default.join(h.context.runDir, relative);
5528
+ const imageBase64 = import_node_fs9.default.readFileSync(screenshotFullPath).toString("base64");
5529
+ const assertVision = h.context.assertVision ?? assertWithAiVision;
5530
+ const verdict = await assertVision(
5531
+ { imageBase64, mediaType: "image/png", assertion },
5532
+ aiConfig
5533
+ );
5534
+ if (verdict.pass) {
5535
+ return {
5536
+ kind: "result",
5537
+ result: {
5538
+ type: "assertWithAI",
5539
+ status: "pass",
5540
+ durationMs: Date.now() - h.stepStart,
5541
+ value: verdict.reason,
5542
+ screenshot: relative
5543
+ }
5544
+ };
5545
+ }
5546
+ throw new Error(`AI assertion failed: ${verdict.reason}`);
5547
+ }
5548
+ },
2952
5549
  copyText: {
2953
5550
  capabilities: ["query"],
2954
5551
  run: async (h) => {
@@ -2985,7 +5582,7 @@ var STEP_HANDLERS = {
2985
5582
  `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
2986
5583
  );
2987
5584
  }
2988
- const savePath = import_node_path6.default.join(h.context.runDir, suggestedFilename);
5585
+ const savePath = import_node_path9.default.join(h.context.runDir, suggestedFilename);
2989
5586
  await download.saveAs(savePath);
2990
5587
  return {
2991
5588
  kind: "result",
@@ -3014,8 +5611,8 @@ async function executeSteps(context) {
3014
5611
  maxSteps: context.maxSteps,
3015
5612
  selfHealing: context.selfHealing
3016
5613
  });
3017
- const screenshotsDir = import_node_path6.default.join(context.runDir, "screenshots");
3018
- import_node_fs6.default.mkdirSync(screenshotsDir, { recursive: true });
5614
+ const screenshotsDir = import_node_path9.default.join(context.runDir, "screenshots");
5615
+ import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
3019
5616
  const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
3020
5617
  policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
3021
5618
  const results = [];
@@ -3024,8 +5621,8 @@ async function executeSteps(context) {
3024
5621
  context.runStartedAtMs = runStartedAtMs;
3025
5622
  const addScreenshot = async (fileName) => {
3026
5623
  const fullPath = screenshotPath(screenshotsDir, fileName);
3027
- await captureScreenshot(driver, fullPath);
3028
- const relative = import_node_path6.default.join("screenshots", fileName);
5624
+ await captureScreenshot2(driver, fullPath);
5625
+ const relative = import_node_path9.default.join("screenshots", fileName);
3029
5626
  screenshots.push(relative);
3030
5627
  return relative;
3031
5628
  };
@@ -3113,12 +5710,12 @@ async function executeSteps(context) {
3113
5710
  return { results, screenshots, failed: false };
3114
5711
  }
3115
5712
  async function captureFinalScreenshot(page, runDir) {
3116
- const screenshotsDir = import_node_path6.default.join(runDir, "screenshots");
3117
- import_node_fs6.default.mkdirSync(screenshotsDir, { recursive: true });
5713
+ const screenshotsDir = import_node_path9.default.join(runDir, "screenshots");
5714
+ import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
3118
5715
  const fileName = "final.png";
3119
5716
  const filePath = screenshotPath(screenshotsDir, fileName);
3120
- await captureScreenshot(page, filePath);
3121
- return import_node_path6.default.join("screenshots", fileName);
5717
+ await captureScreenshot2(page, filePath);
5718
+ return import_node_path9.default.join("screenshots", fileName);
3122
5719
  }
3123
5720
 
3124
5721
  // src/runner/assertions.ts
@@ -3278,18 +5875,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
3278
5875
  }
3279
5876
 
3280
5877
  // src/reporter/result.ts
3281
- var import_node_fs7 = __toESM(require("fs"), 1);
3282
- var import_node_path7 = __toESM(require("path"), 1);
5878
+ var import_node_fs10 = __toESM(require("fs"), 1);
5879
+ var import_node_path10 = __toESM(require("path"), 1);
3283
5880
  function writeResult(runDir, result) {
3284
5881
  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));
5882
+ const fullPath = import_node_path10.default.join(runDir, fileName);
5883
+ import_node_fs10.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
3287
5884
  return fileName;
3288
5885
  }
3289
5886
 
3290
5887
  // src/reporter/summary.ts
3291
- var import_node_fs8 = __toESM(require("fs"), 1);
3292
- var import_node_path8 = __toESM(require("path"), 1);
5888
+ var import_node_fs11 = __toESM(require("fs"), 1);
5889
+ var import_node_path11 = __toESM(require("path"), 1);
3293
5890
  function escapeMd(text) {
3294
5891
  return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
3295
5892
  }
@@ -3367,15 +5964,15 @@ function writeSummary(runDir, result) {
3367
5964
  }
3368
5965
  }
3369
5966
  const fileName = "summary.md";
3370
- const fullPath = import_node_path8.default.join(runDir, fileName);
3371
- import_node_fs8.default.writeFileSync(fullPath, `${lines.join("\n")}
5967
+ const fullPath = import_node_path11.default.join(runDir, fileName);
5968
+ import_node_fs11.default.writeFileSync(fullPath, `${lines.join("\n")}
3372
5969
  `);
3373
5970
  return fileName;
3374
5971
  }
3375
5972
 
3376
5973
  // src/reporter/junit.ts
3377
- var import_node_fs9 = __toESM(require("fs"), 1);
3378
- var import_node_path9 = __toESM(require("path"), 1);
5974
+ var import_node_fs12 = __toESM(require("fs"), 1);
5975
+ var import_node_path12 = __toESM(require("path"), 1);
3379
5976
  function escapeXml(text) {
3380
5977
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3381
5978
  }
@@ -3419,8 +6016,8 @@ function writeJunit(runDir, result) {
3419
6016
  lines.push(" </testsuite>");
3420
6017
  lines.push("</testsuites>");
3421
6018
  const fileName = "junit.xml";
3422
- const fullPath = import_node_path9.default.join(runDir, fileName);
3423
- import_node_fs9.default.writeFileSync(fullPath, `${lines.join("\n")}
6019
+ const fullPath = import_node_path12.default.join(runDir, fileName);
6020
+ import_node_fs12.default.writeFileSync(fullPath, `${lines.join("\n")}
3424
6021
  `);
3425
6022
  return fileName;
3426
6023
  }
@@ -3454,15 +6051,15 @@ function timestamp(prefix) {
3454
6051
  }
3455
6052
 
3456
6053
  // src/runner/history.ts
3457
- var import_node_fs10 = __toESM(require("fs"), 1);
3458
- var import_node_path10 = __toESM(require("path"), 1);
6054
+ var import_node_fs13 = __toESM(require("fs"), 1);
6055
+ var import_node_path13 = __toESM(require("path"), 1);
3459
6056
  var HISTORY_FILE = "history.json";
3460
6057
  var LOCK_FILE_SUFFIX = ".lock";
3461
6058
  var LOCK_RETRY_MS = 10;
3462
6059
  var LOCK_TIMEOUT_MS = 5e3;
3463
6060
  var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
3464
6061
  function historyPath(configDir) {
3465
- return import_node_path10.default.join(configDir, HISTORY_FILE);
6062
+ return import_node_path13.default.join(configDir, HISTORY_FILE);
3466
6063
  }
3467
6064
  function isHistoryEntry(value) {
3468
6065
  if (!value || typeof value !== "object") {
@@ -3473,11 +6070,11 @@ function isHistoryEntry(value) {
3473
6070
  }
3474
6071
  function readHistory(configDir) {
3475
6072
  const filePath = historyPath(configDir);
3476
- if (!import_node_fs10.default.existsSync(filePath)) {
6073
+ if (!import_node_fs13.default.existsSync(filePath)) {
3477
6074
  return { entries: [] };
3478
6075
  }
3479
6076
  try {
3480
- const raw = import_node_fs10.default.readFileSync(filePath, "utf-8");
6077
+ const raw = import_node_fs13.default.readFileSync(filePath, "utf-8");
3481
6078
  const parsed = JSON.parse(raw);
3482
6079
  if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
3483
6080
  const validatedEntries = parsed.entries.filter(isHistoryEntry);
@@ -3516,12 +6113,12 @@ function sleepSync(ms) {
3516
6113
  function withHistoryLock(configDir, fn) {
3517
6114
  const filePath = historyPath(configDir);
3518
6115
  const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
3519
- import_node_fs10.default.mkdirSync(import_node_path10.default.dirname(filePath), { recursive: true });
6116
+ import_node_fs13.default.mkdirSync(import_node_path13.default.dirname(filePath), { recursive: true });
3520
6117
  const startedAt = Date.now();
3521
6118
  while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
3522
6119
  let fd;
3523
6120
  try {
3524
- fd = import_node_fs10.default.openSync(lockPath, "wx");
6121
+ fd = import_node_fs13.default.openSync(lockPath, "wx");
3525
6122
  } catch (error) {
3526
6123
  if (error.code === "EEXIST") {
3527
6124
  sleepSync(LOCK_RETRY_MS);
@@ -3533,10 +6130,10 @@ function withHistoryLock(configDir, fn) {
3533
6130
  return fn();
3534
6131
  } finally {
3535
6132
  try {
3536
- import_node_fs10.default.closeSync(fd);
6133
+ import_node_fs13.default.closeSync(fd);
3537
6134
  } catch {
3538
6135
  }
3539
- import_node_fs10.default.rmSync(lockPath, { force: true });
6136
+ import_node_fs13.default.rmSync(lockPath, { force: true });
3540
6137
  }
3541
6138
  }
3542
6139
  throw new Error(
@@ -3549,9 +6146,9 @@ function appendEntry(configDir, entry, maxRuns) {
3549
6146
  const current = readHistory(configDir);
3550
6147
  const next = pruneEntries([...current.entries, entry], maxRuns);
3551
6148
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
3552
- import_node_fs10.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
6149
+ import_node_fs13.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
3553
6150
  `);
3554
- import_node_fs10.default.renameSync(tempPath, filePath);
6151
+ import_node_fs13.default.renameSync(tempPath, filePath);
3555
6152
  });
3556
6153
  }
3557
6154
 
@@ -3564,11 +6161,11 @@ function parseViewportFlag(value) {
3564
6161
  return value;
3565
6162
  }
3566
6163
  function resolvePath(configDir, inputPath) {
3567
- if (import_node_path11.default.isAbsolute(inputPath)) {
6164
+ if (import_node_path14.default.isAbsolute(inputPath)) {
3568
6165
  return inputPath;
3569
6166
  }
3570
- const projectRoot = import_node_path11.default.dirname(configDir);
3571
- return import_node_path11.default.join(projectRoot, inputPath);
6167
+ const projectRoot = import_node_path14.default.dirname(configDir);
6168
+ return import_node_path14.default.join(projectRoot, inputPath);
3572
6169
  }
3573
6170
  function buildRunResult(options) {
3574
6171
  return {
@@ -3587,12 +6184,12 @@ function buildRunResult(options) {
3587
6184
  }
3588
6185
  function writeConsoleLog(runDir, entries) {
3589
6186
  const fileName = "console.log";
3590
- const filePath = import_node_path11.default.join(runDir, fileName);
6187
+ const filePath = import_node_path14.default.join(runDir, fileName);
3591
6188
  const lines = entries.map((entry) => {
3592
6189
  const location = entry.location ? ` (${entry.location})` : "";
3593
6190
  return `[${entry.type}] ${entry.text}${location}`;
3594
6191
  });
3595
- import_node_fs11.default.writeFileSync(filePath, `${lines.join("\n")}
6192
+ import_node_fs14.default.writeFileSync(filePath, `${lines.join("\n")}
3596
6193
  `);
3597
6194
  return fileName;
3598
6195
  }
@@ -3600,8 +6197,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
3600
6197
  const headless = options.headed ? false : config.browser.headless;
3601
6198
  const slowMo = options.slowMo ?? config.browser.slowMo;
3602
6199
  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 });
6200
+ const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
6201
+ import_node_fs14.default.mkdirSync(runDir, { recursive: true });
3605
6202
  const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
3606
6203
  const engine = options.browser ?? config.browser.engine;
3607
6204
  const channel = options.channel ?? config.browser.channel;
@@ -3718,7 +6315,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
3718
6315
  }
3719
6316
  return { result, runDir, steps: interpolatedHunt.steps };
3720
6317
  }
3721
- function delay(ms) {
6318
+ function delay3(ms) {
3722
6319
  return new Promise((resolve) => setTimeout(resolve, ms));
3723
6320
  }
3724
6321
  async function runHunt(options) {
@@ -3726,6 +6323,12 @@ async function runHunt(options) {
3726
6323
  if (config.target.type === "macos") {
3727
6324
  return runMacHunt(options, config, configDir, config.target);
3728
6325
  }
6326
+ if (config.target.type === "android") {
6327
+ return runAndroidHunt(options, config, configDir, config.target);
6328
+ }
6329
+ if (config.target.type === "ios") {
6330
+ return runIosHunt(options, config, configDir, config.target);
6331
+ }
3729
6332
  const hunt = loadHunt(options.huntName, configDir);
3730
6333
  const {
3731
6334
  hunt: interpolatedHunt,
@@ -3747,7 +6350,7 @@ async function runHunt(options) {
3747
6350
  let lastResult;
3748
6351
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
3749
6352
  if (attempt > 0 && retryDelay > 0) {
3750
- await delay(retryDelay);
6353
+ await delay3(retryDelay);
3751
6354
  }
3752
6355
  lastResult = await executeHuntAttempt(
3753
6356
  options,
@@ -3776,19 +6379,17 @@ async function runHunt(options) {
3776
6379
  }
3777
6380
  return lastResult;
3778
6381
  }
3779
- async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
6382
+ async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
3780
6383
  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
- });
6384
+ const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
6385
+ import_node_fs14.default.mkdirSync(runDir, { recursive: true });
6386
+ const session = await native.launchSession();
3788
6387
  let result;
3789
6388
  try {
3790
- const targetLabel = `macos:${session.bundleId}`;
3791
- const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, target.app, session.bundleId])];
6389
+ const driver = native.sessionDriver(session);
6390
+ const appIdentity = native.sessionAppIdentity(session);
6391
+ const targetLabel = `${native.targetType}:${appIdentity}`;
6392
+ const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
3792
6393
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3793
6394
  const startTime = Date.now();
3794
6395
  let stepResults = [];
@@ -3796,7 +6397,8 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3796
6397
  let stepFailed = false;
3797
6398
  try {
3798
6399
  const stepExecution = await executeSteps({
3799
- driver: session.driver,
6400
+ driver,
6401
+ targetType: native.targetType,
3800
6402
  steps: interpolatedHunt.steps,
3801
6403
  targetUrl: targetLabel,
3802
6404
  runDir,
@@ -3823,7 +6425,7 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3823
6425
  }
3824
6426
  let finalScreenshot;
3825
6427
  try {
3826
- finalScreenshot = await captureFinalScreenshot(session.driver, runDir);
6428
+ finalScreenshot = await captureFinalScreenshot(driver, runDir);
3827
6429
  } catch {
3828
6430
  finalScreenshot = void 0;
3829
6431
  }
@@ -3844,16 +6446,116 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
3844
6446
  });
3845
6447
  result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
3846
6448
  } finally {
3847
- await closeMacSession(session);
6449
+ await native.closeSession(session);
3848
6450
  }
3849
6451
  return { result, runDir, steps: interpolatedHunt.steps };
3850
6452
  }
6453
+ async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
6454
+ return executeNativeHuntAttempt(
6455
+ options,
6456
+ config,
6457
+ configDir,
6458
+ interpolatedHunt,
6459
+ redactedFillSteps,
6460
+ randomVars,
6461
+ allowedApps,
6462
+ {
6463
+ targetType: "macos",
6464
+ targetApp: target.app,
6465
+ launchSession: () => launchMacSession({
6466
+ app: target.app,
6467
+ timeoutMs: config.browser.timeout,
6468
+ clientFactory: options.macClientFactory
6469
+ }),
6470
+ closeSession: closeMacSession,
6471
+ sessionDriver: (session) => session.driver,
6472
+ sessionAppIdentity: (session) => session.bundleId
6473
+ }
6474
+ );
6475
+ }
3851
6476
  async function runMacHunt(options, config, configDir, target) {
6477
+ return runNativeHunt(options, config, configDir, target, {
6478
+ targetType: "macos",
6479
+ assertAppAllowed: (allowedApps, nativeTarget) => assertTargetAppAllowed(allowedApps, nativeTarget.app),
6480
+ attempt: executeMacHuntAttempt
6481
+ });
6482
+ }
6483
+ async function executeAndroidHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
6484
+ const launch = options.androidSessionFactory ?? launchAndroidSession;
6485
+ return executeNativeHuntAttempt(
6486
+ options,
6487
+ config,
6488
+ configDir,
6489
+ interpolatedHunt,
6490
+ redactedFillSteps,
6491
+ randomVars,
6492
+ allowedApps,
6493
+ {
6494
+ targetType: "android",
6495
+ targetApp: target.app,
6496
+ launchSession: () => launch({
6497
+ app: target.app,
6498
+ deviceSerial: target.deviceSerial,
6499
+ coldStart: target.coldStart,
6500
+ timeoutMs: config.browser.timeout,
6501
+ allowedApps
6502
+ }),
6503
+ closeSession: closeAndroidSession,
6504
+ sessionDriver: (session) => session.driver,
6505
+ sessionAppIdentity: (session) => session.package
6506
+ }
6507
+ );
6508
+ }
6509
+ async function runAndroidHunt(options, config, configDir, target) {
6510
+ return runNativeHunt(options, config, configDir, target, {
6511
+ targetType: "android",
6512
+ assertAppAllowed: (allowedApps, nativeTarget) => {
6513
+ if (!nativeTarget.app.toLowerCase().endsWith(".apk")) {
6514
+ assertAndroidAppAllowed(allowedApps, nativeTarget.app);
6515
+ }
6516
+ },
6517
+ attempt: executeAndroidHuntAttempt
6518
+ });
6519
+ }
6520
+ async function executeIosHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
6521
+ const launch = options.iosSessionFactory ?? launchIosSession;
6522
+ return executeNativeHuntAttempt(
6523
+ options,
6524
+ config,
6525
+ configDir,
6526
+ interpolatedHunt,
6527
+ redactedFillSteps,
6528
+ randomVars,
6529
+ allowedApps,
6530
+ {
6531
+ targetType: "ios",
6532
+ targetApp: target.app,
6533
+ launchSession: () => launch({
6534
+ app: target.app,
6535
+ udid: target.udid,
6536
+ coldStart: target.coldStart,
6537
+ timeoutMs: config.browser.timeout,
6538
+ allowedApps
6539
+ }),
6540
+ closeSession: closeIosSession,
6541
+ sessionDriver: (session) => session.driver,
6542
+ sessionAppIdentity: (session) => session.bundleId
6543
+ }
6544
+ );
6545
+ }
6546
+ async function runIosHunt(options, config, configDir, target) {
6547
+ return runNativeHunt(options, config, configDir, target, {
6548
+ targetType: "ios",
6549
+ assertAppAllowed: (allowedApps, nativeTarget) => assertIosAppAllowed(allowedApps, nativeTarget.app),
6550
+ attempt: executeIosHuntAttempt
6551
+ });
6552
+ }
6553
+ async function runNativeHunt(options, config, configDir, target, native) {
3852
6554
  const hunt = loadHunt(options.huntName, configDir);
3853
6555
  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);
6556
+ assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
6557
+ assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
6558
+ native.assertAppAllowed(config.guardrails.allowedApps, target);
3857
6559
  const maxSteps = config.guardrails.maxSteps;
3858
6560
  if (interpolatedHunt.steps.length > maxSteps) {
3859
6561
  throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
@@ -3863,9 +6565,9 @@ async function runMacHunt(options, config, configDir, target) {
3863
6565
  let lastResult;
3864
6566
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
3865
6567
  if (attempt > 0 && retryDelay > 0) {
3866
- await delay(retryDelay);
6568
+ await delay3(retryDelay);
3867
6569
  }
3868
- lastResult = await executeMacHuntAttempt(
6570
+ lastResult = await native.attempt(
3869
6571
  options,
3870
6572
  config,
3871
6573
  configDir,
@@ -3893,7 +6595,7 @@ async function runMacHunt(options, config, configDir, target) {
3893
6595
  }
3894
6596
  function recordHistory(configDir, outcome, maxRuns) {
3895
6597
  try {
3896
- const relativeRunDir = import_node_path11.default.relative(configDir, outcome.runDir);
6598
+ const relativeRunDir = import_node_path14.default.relative(configDir, outcome.runDir);
3897
6599
  appendEntry(
3898
6600
  configDir,
3899
6601
  {
@@ -3910,11 +6612,11 @@ function recordHistory(configDir, outcome, maxRuns) {
3910
6612
  }
3911
6613
 
3912
6614
  // src/runner/suite.ts
3913
- var import_node_path14 = __toESM(require("path"), 1);
6615
+ var import_node_path17 = __toESM(require("path"), 1);
3914
6616
 
3915
6617
  // src/reporter/ci-summary.ts
3916
- var import_node_fs12 = __toESM(require("fs"), 1);
3917
- var import_node_path12 = __toESM(require("path"), 1);
6618
+ var import_node_fs15 = __toESM(require("fs"), 1);
6619
+ var import_node_path15 = __toESM(require("path"), 1);
3918
6620
  var import_chalk = __toESM(require("chalk"), 1);
3919
6621
  function countCiResults(results) {
3920
6622
  return {
@@ -3944,9 +6646,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
3944
6646
  ...flaky.length > 0 ? { flaky } : {},
3945
6647
  ...clusters.length > 0 ? { clusters } : {}
3946
6648
  };
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");
6649
+ import_node_fs15.default.mkdirSync(ciRunDir, { recursive: true });
6650
+ const filePath = import_node_path15.default.join(ciRunDir, "ci-result.json");
6651
+ import_node_fs15.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
3950
6652
  return filePath;
3951
6653
  }
3952
6654
 
@@ -4080,8 +6782,8 @@ function clusterFailures(failures) {
4080
6782
  }
4081
6783
 
4082
6784
  // src/backlog/index.ts
4083
- var import_node_fs13 = __toESM(require("fs"), 1);
4084
- var import_node_path13 = __toESM(require("path"), 1);
6785
+ var import_node_fs16 = __toESM(require("fs"), 1);
6786
+ var import_node_path16 = __toESM(require("path"), 1);
4085
6787
 
4086
6788
  // src/backlog/parse.ts
4087
6789
  var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
@@ -4171,7 +6873,7 @@ ${after}`;
4171
6873
  // src/backlog/index.ts
4172
6874
  function readFileOrEmpty(filePath) {
4173
6875
  try {
4174
- return import_node_fs13.default.readFileSync(filePath, "utf-8");
6876
+ return import_node_fs16.default.readFileSync(filePath, "utf-8");
4175
6877
  } catch (error) {
4176
6878
  const err = error;
4177
6879
  if (err.code === "ENOENT") return "";
@@ -4187,7 +6889,7 @@ function buildFailure(hunt) {
4187
6889
  if (!hunt.runDir) return failure;
4188
6890
  let run;
4189
6891
  try {
4190
- const resultJson = readFileOrEmpty(import_node_path13.default.join(hunt.runDir, "result.json"));
6892
+ const resultJson = readFileOrEmpty(import_node_path16.default.join(hunt.runDir, "result.json"));
4191
6893
  if (!resultJson) return failure;
4192
6894
  run = JSON.parse(resultJson);
4193
6895
  } catch (error) {
@@ -4216,8 +6918,8 @@ function extractFailures(suiteResult) {
4216
6918
  }
4217
6919
  function updateBacklogFromSuite(suiteResult, options = {}) {
4218
6920
  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");
6921
+ const backlogPath = options.backlogPath ?? import_node_path16.default.join(projectRoot, "docs", "backlog.md");
6922
+ const resolvedPath = options.resolvedPath ?? import_node_path16.default.join(projectRoot, "docs", "resolved.md");
4221
6923
  const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
4222
6924
  const summary = { created: [], regressions: [], skipped: [], backlogPath };
4223
6925
  const failures = extractFailures(suiteResult);
@@ -4249,8 +6951,8 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
4249
6951
  }
4250
6952
  }
4251
6953
  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));
6954
+ import_node_fs16.default.mkdirSync(import_node_path16.default.dirname(backlogPath), { recursive: true });
6955
+ import_node_fs16.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
4254
6956
  }
4255
6957
  return summary;
4256
6958
  }
@@ -4411,7 +7113,7 @@ async function runSuite(options = {}) {
4411
7113
  const clusters = clusterFailures(
4412
7114
  extractFailures({ result: { hunts: results }, resultPath: null })
4413
7115
  ).filter((cluster) => cluster.count > 1);
4414
- const ciRunDir = import_node_path14.default.join(configDir, "runs", timestamp("ci"));
7116
+ const ciRunDir = import_node_path17.default.join(configDir, "runs", timestamp("ci"));
4415
7117
  const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
4416
7118
  const { passed, failed, skipped } = countCiResults(results);
4417
7119
  return {
@@ -4546,6 +7248,401 @@ async function analyzePage(page) {
4546
7248
  };
4547
7249
  }
4548
7250
 
7251
+ // src/analyzer/mac.ts
7252
+ var INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
7253
+ "AXButton",
7254
+ "AXTextField",
7255
+ "AXSecureTextField",
7256
+ "AXTextArea",
7257
+ "AXCheckBox",
7258
+ "AXRadioButton",
7259
+ "AXPopUpButton",
7260
+ "AXMenuButton",
7261
+ "AXLink",
7262
+ "AXMenuItem",
7263
+ "AXComboBox",
7264
+ "AXSlider",
7265
+ "AXDisclosureTriangle"
7266
+ ]);
7267
+ var DEFAULT_ANALYZE_TREE_DEPTH = 20;
7268
+ function str(value) {
7269
+ if (typeof value !== "string") {
7270
+ return void 0;
7271
+ }
7272
+ const trimmed = value.trim();
7273
+ return trimmed.length > 0 ? trimmed : void 0;
7274
+ }
7275
+ function toNode(raw) {
7276
+ const node = raw ?? {};
7277
+ const children = Array.isArray(node.children) ? node.children.map(toNode) : void 0;
7278
+ return {
7279
+ role: str(node.role),
7280
+ title: str(node.title),
7281
+ description: str(node.description),
7282
+ value: str(node.value),
7283
+ identifier: str(node.identifier),
7284
+ enabled: typeof node.enabled === "boolean" ? node.enabled : void 0,
7285
+ ...children ? { children } : {}
7286
+ };
7287
+ }
7288
+ function quote(value) {
7289
+ return `"${value}"`;
7290
+ }
7291
+ function rankMacSelectors(node) {
7292
+ const selectors = [];
7293
+ const identifier = node.identifier;
7294
+ const exactLabel = node.title ?? node.description;
7295
+ const name = node.title ?? node.description ?? node.value;
7296
+ if (identifier) {
7297
+ selectors.push(`id=${identifier}`);
7298
+ }
7299
+ if (exactLabel) {
7300
+ selectors.push(`label=${quote(exactLabel)}`);
7301
+ }
7302
+ if (node.role && name) {
7303
+ selectors.push(`role=${node.role}[name=${quote(name)}]`);
7304
+ }
7305
+ if (name) {
7306
+ selectors.push(`text=${quote(name)}`);
7307
+ }
7308
+ if (selectors.length === 0 && node.role) {
7309
+ selectors.push(`role=${node.role}`);
7310
+ }
7311
+ return selectors;
7312
+ }
7313
+ function toElement(node, source) {
7314
+ return {
7315
+ role: node.role ?? "?",
7316
+ ...node.title ? { title: node.title } : {},
7317
+ ...node.description ? { description: node.description } : {},
7318
+ ...node.value ? { value: node.value } : {},
7319
+ ...node.identifier ? { identifier: node.identifier } : {},
7320
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
7321
+ source,
7322
+ selectors: rankMacSelectors(node)
7323
+ };
7324
+ }
7325
+ function collectInteractive(root) {
7326
+ const out = [];
7327
+ const visit = (node) => {
7328
+ if (node.role && INTERACTIVE_ROLES.has(node.role)) {
7329
+ out.push(toElement(node, "window"));
7330
+ }
7331
+ for (const child of node.children ?? []) {
7332
+ visit(child);
7333
+ }
7334
+ };
7335
+ visit(root);
7336
+ return out;
7337
+ }
7338
+ function toWindow(node) {
7339
+ const [best] = rankMacSelectors(node);
7340
+ return {
7341
+ ...node.title ? { title: node.title } : {},
7342
+ ...node.identifier ? { identifier: node.identifier } : {},
7343
+ selector: best ?? "role=AXWindow"
7344
+ };
7345
+ }
7346
+ async function analyzeMacApp(client, options) {
7347
+ const depth = options.treeDepth ?? DEFAULT_ANALYZE_TREE_DEPTH;
7348
+ const treeResult = await client.request("tree", { depth });
7349
+ const elements = collectInteractive(toNode(treeResult.tree));
7350
+ const windowsResult = await client.request("windows");
7351
+ const rawWindows = Array.isArray(windowsResult.windows) ? windowsResult.windows : [];
7352
+ const windows = rawWindows.map((raw) => toWindow(toNode(raw)));
7353
+ const menuItems = await readStatusMenu(client, options.menuTimeoutSeconds);
7354
+ return { app: options.app, elements, windows, menuItems };
7355
+ }
7356
+ async function readStatusMenu(client, menuTimeoutSeconds) {
7357
+ let statusItems;
7358
+ try {
7359
+ const status = await client.request("statusItems");
7360
+ statusItems = Array.isArray(status.items) ? status.items : [];
7361
+ } catch {
7362
+ return [];
7363
+ }
7364
+ if (statusItems.length === 0) {
7365
+ return [];
7366
+ }
7367
+ const params = menuTimeoutSeconds !== void 0 ? { timeout: menuTimeoutSeconds } : {};
7368
+ try {
7369
+ const menu = await client.request("openMenu", params);
7370
+ const rawItems = Array.isArray(menu.items) ? menu.items : [];
7371
+ return rawItems.map((raw) => toNode(raw)).filter((node) => node.role !== "AXMenuItem" || node.title || node.identifier || node.description).map((node) => toElement(node, "menu"));
7372
+ } catch {
7373
+ return [];
7374
+ } finally {
7375
+ await client.request("closeMenu").catch(() => void 0);
7376
+ }
7377
+ }
7378
+
7379
+ // src/analyzer/android.ts
7380
+ var ANDROID_INTERACTIVE_CLASSES = /* @__PURE__ */ new Set([
7381
+ "android.widget.Button",
7382
+ "android.widget.ImageButton",
7383
+ "android.widget.EditText",
7384
+ "android.widget.CheckBox",
7385
+ "android.widget.RadioButton",
7386
+ "android.widget.Switch",
7387
+ "android.widget.ToggleButton",
7388
+ "android.widget.Spinner",
7389
+ "android.widget.SeekBar",
7390
+ "android.widget.RatingBar",
7391
+ "android.widget.CompoundButton",
7392
+ "android.widget.AutoCompleteTextView",
7393
+ "android.widget.MultiAutoCompleteTextView",
7394
+ "android.widget.CheckedTextView",
7395
+ "androidx.appcompat.widget.SwitchCompat",
7396
+ "androidx.appcompat.widget.AppCompatButton",
7397
+ "androidx.appcompat.widget.AppCompatEditText"
7398
+ ]);
7399
+ function str2(value) {
7400
+ if (value === void 0) {
7401
+ return void 0;
7402
+ }
7403
+ const trimmed = value.trim();
7404
+ return trimmed.length > 0 ? trimmed : void 0;
7405
+ }
7406
+ function bool(value) {
7407
+ if (value === void 0) {
7408
+ return void 0;
7409
+ }
7410
+ return value === "true";
7411
+ }
7412
+ function toAndroidNode(element) {
7413
+ const a = element.attrs;
7414
+ return {
7415
+ className: str2(a["class"]),
7416
+ resourceId: str2(a["resource-id"]),
7417
+ contentDesc: str2(a["content-desc"]),
7418
+ text: str2(a.text),
7419
+ package: str2(a.package),
7420
+ clickable: bool(a.clickable),
7421
+ longClickable: bool(a["long-clickable"]),
7422
+ checkable: bool(a.checkable),
7423
+ checked: bool(a.checked),
7424
+ scrollable: bool(a.scrollable),
7425
+ focusable: bool(a.focusable),
7426
+ focused: bool(a.focused),
7427
+ enabled: bool(a.enabled),
7428
+ children: element.children.map(toAndroidNode)
7429
+ };
7430
+ }
7431
+ function parseAndroidHierarchy(xml) {
7432
+ const root = parseXml(xml);
7433
+ return root ? toAndroidNode(root) : null;
7434
+ }
7435
+ function isAndroidInteractive(node) {
7436
+ if (node.clickable || node.longClickable || node.checkable || node.scrollable) {
7437
+ return true;
7438
+ }
7439
+ return node.className !== void 0 && ANDROID_INTERACTIVE_CLASSES.has(node.className);
7440
+ }
7441
+ function rankAndroidSelectors(node) {
7442
+ return rankNativeSelectors({
7443
+ id: node.resourceId,
7444
+ label: node.contentDesc,
7445
+ role: node.className,
7446
+ name: node.text
7447
+ });
7448
+ }
7449
+ function androidNodeToNative(node) {
7450
+ return {
7451
+ ...node.resourceId !== void 0 ? { id: node.resourceId } : {},
7452
+ ...node.contentDesc !== void 0 ? { label: node.contentDesc } : {},
7453
+ ...node.className !== void 0 ? { role: node.className } : {},
7454
+ textValues: node.text !== void 0 ? [node.text] : [],
7455
+ ...node.focused !== void 0 ? { focused: node.focused } : {}
7456
+ };
7457
+ }
7458
+ function matchAndroidSelector(xml, selector, options = {}) {
7459
+ const parsedSelector = parseNativeSelector(selector);
7460
+ const root = parseAndroidHierarchy(xml);
7461
+ if (!root) {
7462
+ return [];
7463
+ }
7464
+ return matchNativeTree(
7465
+ ANDROID_MATCH_DIALECT,
7466
+ parsedSelector,
7467
+ root,
7468
+ androidNodeToNative,
7469
+ (node) => node.children,
7470
+ options
7471
+ );
7472
+ }
7473
+ function toElement2(node) {
7474
+ return {
7475
+ className: node.className ?? "?",
7476
+ ...node.resourceId ? { resourceId: node.resourceId } : {},
7477
+ ...node.contentDesc ? { contentDesc: node.contentDesc } : {},
7478
+ ...node.text ? { text: node.text } : {},
7479
+ ...node.clickable !== void 0 ? { clickable: node.clickable } : {},
7480
+ ...node.checkable !== void 0 ? { checkable: node.checkable } : {},
7481
+ ...node.scrollable !== void 0 ? { scrollable: node.scrollable } : {},
7482
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
7483
+ selectors: rankAndroidSelectors(node)
7484
+ };
7485
+ }
7486
+ function collectInteractive2(root) {
7487
+ const out = [];
7488
+ const visit = (node) => {
7489
+ if (isAndroidInteractive(node)) {
7490
+ out.push(toElement2(node));
7491
+ }
7492
+ for (const child of node.children) {
7493
+ visit(child);
7494
+ }
7495
+ };
7496
+ visit(root);
7497
+ return out;
7498
+ }
7499
+ async function analyzeAndroidApp(client, options) {
7500
+ const xml = await client.source();
7501
+ const root = parseAndroidHierarchy(xml);
7502
+ const elements = root ? collectInteractive2(root) : [];
7503
+ return { app: options.app, elements };
7504
+ }
7505
+
7506
+ // src/analyzer/ios.ts
7507
+ var IOS_INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
7508
+ "XCUIElementTypeButton",
7509
+ "XCUIElementTypeCell",
7510
+ "XCUIElementTypeTextField",
7511
+ "XCUIElementTypeSecureTextField",
7512
+ "XCUIElementTypeSearchField",
7513
+ "XCUIElementTypeSwitch",
7514
+ "XCUIElementTypeToggle",
7515
+ "XCUIElementTypeLink",
7516
+ "XCUIElementTypeMenuItem",
7517
+ "XCUIElementTypeSlider",
7518
+ "XCUIElementTypeStepper",
7519
+ "XCUIElementTypeTextView",
7520
+ "XCUIElementTypePickerWheel",
7521
+ "XCUIElementTypeTab",
7522
+ "XCUIElementTypeSegmentedControl",
7523
+ "XCUIElementTypeCheckBox",
7524
+ "XCUIElementTypeRadioButton",
7525
+ "XCUIElementTypeKey"
7526
+ ]);
7527
+ var IOS_WINDOW_TYPE = "XCUIElementTypeWindow";
7528
+ function str3(value) {
7529
+ if (value === void 0) {
7530
+ return void 0;
7531
+ }
7532
+ const trimmed = value.trim();
7533
+ return trimmed.length > 0 ? trimmed : void 0;
7534
+ }
7535
+ function bool2(value) {
7536
+ if (value === void 0) {
7537
+ return void 0;
7538
+ }
7539
+ return value === "true" || value === "1";
7540
+ }
7541
+ function toIosNode(element) {
7542
+ const a = element.attrs;
7543
+ return {
7544
+ type: str3(a.type) ?? str3(element.tag),
7545
+ name: str3(a.name),
7546
+ label: str3(a.label),
7547
+ value: str3(a.value),
7548
+ enabled: bool2(a.enabled),
7549
+ visible: bool2(a.visible),
7550
+ children: element.children.map(toIosNode)
7551
+ };
7552
+ }
7553
+ function parseIosHierarchy(xml) {
7554
+ const root = parseXml(xml);
7555
+ return root ? toIosNode(root) : null;
7556
+ }
7557
+ function hasAccessibilityId(node) {
7558
+ return node.name !== void 0 && node.name !== node.label;
7559
+ }
7560
+ function rankIosSelectors(node) {
7561
+ return rankNativeSelectors({
7562
+ id: hasAccessibilityId(node) ? node.name : void 0,
7563
+ label: node.label,
7564
+ role: node.type ? shortIosType(node.type) : void 0,
7565
+ name: node.label ?? node.value
7566
+ });
7567
+ }
7568
+ function iosNodeToNative(node) {
7569
+ const textValues = [];
7570
+ if (node.label !== void 0) {
7571
+ textValues.push(node.label);
7572
+ }
7573
+ if (node.value !== void 0) {
7574
+ textValues.push(node.value);
7575
+ }
7576
+ return {
7577
+ ...node.name !== void 0 ? { id: node.name } : {},
7578
+ ...node.label !== void 0 ? { label: node.label } : {},
7579
+ ...node.type !== void 0 ? { role: node.type } : {},
7580
+ textValues
7581
+ };
7582
+ }
7583
+ function matchIosSelector(xml, selector) {
7584
+ const parsedSelector = parseNativeSelector(selector);
7585
+ const root = parseIosHierarchy(xml);
7586
+ if (!root) {
7587
+ return [];
7588
+ }
7589
+ return matchNativeTree(
7590
+ IOS_MATCH_DIALECT,
7591
+ parsedSelector,
7592
+ root,
7593
+ iosNodeToNative,
7594
+ (node) => node.children
7595
+ );
7596
+ }
7597
+ function toElement3(node) {
7598
+ return {
7599
+ type: node.type ?? "?",
7600
+ ...node.name ? { name: node.name } : {},
7601
+ ...node.label ? { label: node.label } : {},
7602
+ ...node.value ? { value: node.value } : {},
7603
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
7604
+ ...node.visible !== void 0 ? { visible: node.visible } : {},
7605
+ selectors: rankIosSelectors(node)
7606
+ };
7607
+ }
7608
+ function toWindow2(node) {
7609
+ const [best] = rankIosSelectors(node);
7610
+ return {
7611
+ ...node.name ? { name: node.name } : {},
7612
+ ...node.label ? { label: node.label } : {},
7613
+ selector: best ?? `role=${shortIosType(IOS_WINDOW_TYPE)}`
7614
+ };
7615
+ }
7616
+ function isIosInteractive(node) {
7617
+ return node.type !== void 0 && IOS_INTERACTIVE_TYPES.has(node.type);
7618
+ }
7619
+ function collect(root) {
7620
+ const elements = [];
7621
+ const windows = [];
7622
+ const visit = (node) => {
7623
+ if (node.type === IOS_WINDOW_TYPE) {
7624
+ windows.push(toWindow2(node));
7625
+ }
7626
+ if (isIosInteractive(node)) {
7627
+ elements.push(toElement3(node));
7628
+ }
7629
+ for (const child of node.children) {
7630
+ visit(child);
7631
+ }
7632
+ };
7633
+ visit(root);
7634
+ return { elements, windows };
7635
+ }
7636
+ async function analyzeIosApp(client, options) {
7637
+ const xml = await client.source();
7638
+ const root = parseIosHierarchy(xml);
7639
+ if (!root) {
7640
+ return { app: options.app, elements: [], windows: [] };
7641
+ }
7642
+ const { elements, windows } = collect(root);
7643
+ return { app: options.app, elements, windows };
7644
+ }
7645
+
4549
7646
  // src/generator/index.ts
4550
7647
  var import_yaml2 = __toESM(require("yaml"), 1);
4551
7648
 
@@ -4606,6 +7703,7 @@ var STEP_REFERENCE = `
4606
7703
 
4607
7704
  ### Visual Regression
4608
7705
  - assertScreenshot: { name: "baseline-name", threshold: 0.1 }
7706
+ - assertWithAI: "The login form should show email and password fields" \u2014 AI checks the screenshot against the claim
4609
7707
 
4610
7708
  ### Control Flow
4611
7709
  - if: { visible: ".banner", then: [steps...] }
@@ -4647,81 +7745,6 @@ function extractYamlFromResponse(response) {
4647
7745
  return response.trim();
4648
7746
  }
4649
7747
 
4650
- // src/generator/ai.ts
4651
- function resolveAiConfig() {
4652
- const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
4653
- if (provider !== "anthropic" && provider !== "openai") {
4654
- throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
4655
- }
4656
- const apiKey = process.env.PROWL_AI_KEY;
4657
- if (!apiKey) {
4658
- throw new Error(
4659
- "PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
4660
- );
4661
- }
4662
- const defaultModel = provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
4663
- const model = process.env.PROWL_AI_MODEL ?? defaultModel;
4664
- return { provider, model, apiKey };
4665
- }
4666
- async function generateWithAi(prompt, config) {
4667
- if (config.provider === "anthropic") {
4668
- return generateWithAnthropic(prompt, config);
4669
- }
4670
- return generateWithOpenAi(prompt, config);
4671
- }
4672
- async function generateWithAnthropic(prompt, config) {
4673
- const response = await fetch("https://api.anthropic.com/v1/messages", {
4674
- method: "POST",
4675
- headers: {
4676
- "Content-Type": "application/json",
4677
- "x-api-key": config.apiKey,
4678
- "anthropic-version": "2023-06-01"
4679
- },
4680
- body: JSON.stringify({
4681
- model: config.model,
4682
- max_tokens: 4096,
4683
- messages: [
4684
- { role: "user", content: prompt }
4685
- ]
4686
- })
4687
- });
4688
- if (!response.ok) {
4689
- const body = await response.text();
4690
- throw new Error(`Anthropic API error (${response.status}): ${body}`);
4691
- }
4692
- const data = await response.json();
4693
- const textBlock = data.content.find((c) => c.type === "text");
4694
- if (!textBlock?.text) {
4695
- throw new Error("Anthropic API returned no text content");
4696
- }
4697
- return textBlock.text;
4698
- }
4699
- async function generateWithOpenAi(prompt, config) {
4700
- const response = await fetch("https://api.openai.com/v1/chat/completions", {
4701
- method: "POST",
4702
- headers: {
4703
- "Content-Type": "application/json",
4704
- "Authorization": `Bearer ${config.apiKey}`
4705
- },
4706
- body: JSON.stringify({
4707
- model: config.model,
4708
- messages: [
4709
- { role: "user", content: prompt }
4710
- ],
4711
- max_tokens: 4096
4712
- })
4713
- });
4714
- if (!response.ok) {
4715
- const body = await response.text();
4716
- throw new Error(`OpenAI API error (${response.status}): ${body}`);
4717
- }
4718
- const data = await response.json();
4719
- if (!data.choices?.[0]?.message?.content) {
4720
- throw new Error("OpenAI API returned no content");
4721
- }
4722
- return data.choices[0].message.content;
4723
- }
4724
-
4725
7748
  // src/generator/index.ts
4726
7749
  function parseViewportFlag2(value) {
4727
7750
  const match = /^(\d+)x(\d+)$/i.exec(value);
@@ -4766,41 +7789,137 @@ async function generateHunt(options) {
4766
7789
  }
4767
7790
  // Annotate the CommonJS export names for ESM import in node:
4768
7791
  0 && (module.exports = {
7792
+ ANDROID_INTERACTIVE_CLASSES,
7793
+ ANDROID_KEYCODES,
7794
+ ANDROID_MATCH_DIALECT,
7795
+ DEFAULT_AGENT_REQUEST_TIMEOUT_MS,
7796
+ DEFAULT_ANALYZE_TREE_DEPTH,
4769
7797
  DEFAULT_FLAKY_THRESHOLD,
4770
7798
  DEFAULT_REQUEST_TIMEOUT_MS,
7799
+ DEFAULT_SIMULATOR_LOCK_ROOT,
7800
+ DEFAULT_WDA_REQUEST_TIMEOUT_MS,
7801
+ INTERACTIVE_ROLES,
7802
+ IOS_INTERACTIVE_TYPES,
7803
+ IOS_MATCH_DIALECT,
7804
+ IOS_PRESS_KEYS,
7805
+ IOS_WINDOW_TYPE,
7806
+ MACOS_MATCH_DIALECT,
7807
+ NATIVE_ATTRIBUTE_MAP,
4771
7808
  SpawnMacHelperClient,
7809
+ UIA2_REMOTE_PORT,
7810
+ Uia2HttpError,
7811
+ Uia2Transport,
7812
+ WDA_RUNNER_BUNDLE_ID,
7813
+ WDA_USE_PORT_ENV,
4772
7814
  WEB_ONLY_STEP_TYPES,
7815
+ WdaHttpError,
7816
+ WdaTransport,
7817
+ analyzeAndroidApp,
7818
+ analyzeIosApp,
7819
+ analyzeMacApp,
4773
7820
  analyzePage,
7821
+ androidAppAllowedIdentities,
7822
+ androidNodeToNative,
7823
+ androidQueryToLocator,
7824
+ assertAndroidAppAllowed,
7825
+ assertIosAppAllowed,
4774
7826
  assertStepsSupportedByTarget,
4775
7827
  assertTargetAppAllowed,
7828
+ bootedDevices,
7829
+ bootedSimulators,
4776
7830
  buildHealCandidates,
7831
+ closeAndroidSession,
7832
+ closeIosSession,
4777
7833
  closeMacSession,
4778
7834
  clusterFailures,
4779
7835
  computeFlakeScore,
4780
7836
  configSchema,
7837
+ createAndroidDriver,
7838
+ createIosDriver,
4781
7839
  createMacDriver,
7840
+ createUia2AgentClient,
7841
+ createUia2Session,
7842
+ createWdaAgentClient,
7843
+ createWdaSession,
7844
+ decodeXmlEntities,
7845
+ defaultAgentConnector,
7846
+ defaultIosAgentConnector,
7847
+ defaultWdaTestRunPreparer,
7848
+ escapePredicateArg,
7849
+ escapeUiSelectorArg,
7850
+ extractElementId,
4782
7851
  extractFailures,
4783
7852
  extractSelectorIntent,
7853
+ findFreePort,
4784
7854
  generateHunt,
4785
7855
  healSelector,
4786
7856
  huntSchema,
7857
+ injectUsePortIntoXctestrun,
4787
7858
  interpolateHunt,
7859
+ iosAppAllowedIdentities,
7860
+ iosNodeToNative,
7861
+ iosQueryToLocator,
7862
+ isAndroidInteractive,
7863
+ isIosInteractive,
7864
+ launchAndroidSession,
7865
+ launchIosSession,
4788
7866
  launchMacSession,
7867
+ listDevices,
4789
7868
  listHunts,
7869
+ listSimulators,
4790
7870
  loadConfig,
4791
7871
  loadHunt,
4792
7872
  loadHuntMeta,
4793
7873
  loadHuntTags,
4794
7874
  macdriverBuildInstructions,
7875
+ matchAndroidSelector,
7876
+ matchIosSelector,
7877
+ matchNativeTree,
7878
+ nodeMatchesSelector,
7879
+ normalizeXcuiClassName,
7880
+ parseAaptPackage,
7881
+ parseAdbDevices,
7882
+ parseAndroidHierarchy,
7883
+ parseAndroidSelector,
7884
+ parseForwardPort,
7885
+ parseIosHierarchy,
7886
+ parseIosSelector,
4795
7887
  parseMacSelector,
7888
+ parseNativeSelector,
7889
+ parseSimctlDevices,
7890
+ parseSnapshot,
7891
+ parseXcodeVersion,
7892
+ parseXml,
7893
+ qualifyResourceId,
7894
+ quoteSelectorValue,
7895
+ rankAndroidSelectors,
4796
7896
  rankFlaky,
7897
+ rankIosSelectors,
7898
+ rankMacSelectors,
7899
+ rankNativeSelectors,
4797
7900
  readHistory,
4798
7901
  readHuntHistory,
7902
+ readIosBundleIdentifier,
7903
+ reserveSimulatorUdid,
7904
+ resolveAgentApks,
4799
7905
  resolveHelperBinary,
7906
+ resolveWdaProject,
7907
+ resolveWdaTestRun,
4800
7908
  runHunt,
4801
7909
  runSuite,
7910
+ selectDeviceSerial,
7911
+ selectSimulatorUdid,
7912
+ shortIosType,
4802
7913
  stepSchema,
7914
+ unquoteSelectorValue,
7915
+ unwrapAndroidTextSelector,
7916
+ unwrapIosTextSelector,
7917
+ unwrapNativeTextSelector,
4803
7918
  updateBacklogFromSuite,
7919
+ waitForAgentReady,
7920
+ waitForWdaReady,
7921
+ wdaCacheDir,
7922
+ wdaTestRunArgs,
4804
7923
  webOnlyReason
4805
7924
  });
4806
7925
  //# sourceMappingURL=lib.cjs.map