prowl-tools 0.1.5 → 0.1.7

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_fs8.default.readFileSync(baselinePath));
41
- const currentData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(currentPath));
40
+ const baselineData = import_pngjs.PNG.sync.read(import_node_fs9.default.readFileSync(baselinePath));
41
+ const currentData = import_pngjs.PNG.sync.read(import_node_fs9.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_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
45
+ import_node_fs9.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_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
63
+ import_node_fs9.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_path8.default.join(configDir, "baselines");
72
- import_node_fs8.default.mkdirSync(baselineDir, { recursive: true });
71
+ const baselineDir = import_node_path10.default.join(configDir, "baselines");
72
+ import_node_fs9.default.mkdirSync(baselineDir, { recursive: true });
73
73
  return baselineDir;
74
74
  }
75
- var import_node_fs8, import_node_path8, import_pngjs, import_pixelmatch;
75
+ var import_node_fs9, import_node_path10, import_pngjs, import_pixelmatch;
76
76
  var init_visual = __esm({
77
77
  "src/runner/visual.ts"() {
78
78
  "use strict";
79
- import_node_fs8 = __toESM(require("fs"), 1);
80
- import_node_path8 = __toESM(require("path"), 1);
79
+ import_node_fs9 = __toESM(require("fs"), 1);
80
+ import_node_path10 = __toESM(require("path"), 1);
81
81
  import_pngjs = require("pngjs");
82
82
  import_pixelmatch = __toESM(require("pixelmatch"), 1);
83
83
  }
@@ -86,27 +86,43 @@ 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,
89
90
  ANDROID_KEYCODES: () => ANDROID_KEYCODES,
91
+ ANDROID_MATCH_DIALECT: () => ANDROID_MATCH_DIALECT,
90
92
  DEFAULT_AGENT_REQUEST_TIMEOUT_MS: () => DEFAULT_AGENT_REQUEST_TIMEOUT_MS,
91
93
  DEFAULT_ANALYZE_TREE_DEPTH: () => DEFAULT_ANALYZE_TREE_DEPTH,
92
94
  DEFAULT_FLAKY_THRESHOLD: () => DEFAULT_FLAKY_THRESHOLD,
93
95
  DEFAULT_REQUEST_TIMEOUT_MS: () => DEFAULT_REQUEST_TIMEOUT_MS,
94
96
  DEFAULT_SIMULATOR_LOCK_ROOT: () => DEFAULT_SIMULATOR_LOCK_ROOT,
95
97
  DEFAULT_WDA_REQUEST_TIMEOUT_MS: () => DEFAULT_WDA_REQUEST_TIMEOUT_MS,
98
+ HELPER_BINARY: () => HELPER_BINARY,
96
99
  INTERACTIVE_ROLES: () => INTERACTIVE_ROLES,
100
+ IOS_INTERACTIVE_TYPES: () => IOS_INTERACTIVE_TYPES,
101
+ IOS_MATCH_DIALECT: () => IOS_MATCH_DIALECT,
97
102
  IOS_PRESS_KEYS: () => IOS_PRESS_KEYS,
103
+ IOS_WINDOW_TYPE: () => IOS_WINDOW_TYPE,
104
+ MACDRIVER_REPO: () => MACDRIVER_REPO,
105
+ MACDRIVER_SIGNING_AUTHORITY_PREFIX: () => MACDRIVER_SIGNING_AUTHORITY_PREFIX,
106
+ MACDRIVER_SIGNING_IDENTIFIER: () => MACDRIVER_SIGNING_IDENTIFIER,
107
+ MACDRIVER_VERSION: () => MACDRIVER_VERSION,
108
+ MACDRIVER_VERSION_PATTERN: () => MACDRIVER_VERSION_PATTERN,
109
+ MACOS_MATCH_DIALECT: () => MACOS_MATCH_DIALECT,
110
+ NATIVE_ATTRIBUTE_MAP: () => NATIVE_ATTRIBUTE_MAP,
98
111
  SpawnMacHelperClient: () => SpawnMacHelperClient,
99
112
  UIA2_REMOTE_PORT: () => UIA2_REMOTE_PORT,
100
113
  Uia2HttpError: () => Uia2HttpError,
101
114
  Uia2Transport: () => Uia2Transport,
102
- WDA_RUNNER_APP_NAME: () => WDA_RUNNER_APP_NAME,
103
115
  WDA_RUNNER_BUNDLE_ID: () => WDA_RUNNER_BUNDLE_ID,
116
+ WDA_USE_PORT_ENV: () => WDA_USE_PORT_ENV,
104
117
  WEB_ONLY_STEP_TYPES: () => WEB_ONLY_STEP_TYPES,
105
118
  WdaHttpError: () => WdaHttpError,
106
119
  WdaTransport: () => WdaTransport,
120
+ analyzeAndroidApp: () => analyzeAndroidApp,
121
+ analyzeIosApp: () => analyzeIosApp,
107
122
  analyzeMacApp: () => analyzeMacApp,
108
123
  analyzePage: () => analyzePage,
109
124
  androidAppAllowedIdentities: () => androidAppAllowedIdentities,
125
+ androidNodeToNative: () => androidNodeToNative,
110
126
  androidQueryToLocator: () => androidQueryToLocator,
111
127
  assertAndroidAppAllowed: () => assertAndroidAppAllowed,
112
128
  assertIosAppAllowed: () => assertIosAppAllowed,
@@ -119,6 +135,8 @@ __export(src_exports, {
119
135
  closeIosSession: () => closeIosSession,
120
136
  closeMacSession: () => closeMacSession,
121
137
  clusterFailures: () => clusterFailures,
138
+ codesignVerifier: () => codesignVerifier,
139
+ collectMacdriverStatus: () => collectMacdriverStatus,
122
140
  computeFlakeScore: () => computeFlakeScore,
123
141
  configSchema: () => configSchema,
124
142
  createAndroidDriver: () => createAndroidDriver,
@@ -128,8 +146,12 @@ __export(src_exports, {
128
146
  createUia2Session: () => createUia2Session,
129
147
  createWdaAgentClient: () => createWdaAgentClient,
130
148
  createWdaSession: () => createWdaSession,
149
+ decodeXmlEntities: () => decodeXmlEntities,
131
150
  defaultAgentConnector: () => defaultAgentConnector,
132
151
  defaultIosAgentConnector: () => defaultIosAgentConnector,
152
+ defaultWdaTestRunPreparer: () => defaultWdaTestRunPreparer,
153
+ dittoExtractor: () => dittoExtractor,
154
+ downloadAndVerify: () => downloadAndVerify,
133
155
  escapePredicateArg: () => escapePredicateArg,
134
156
  escapeUiSelectorArg: () => escapeUiSelectorArg,
135
157
  extractElementId: () => extractElementId,
@@ -139,9 +161,14 @@ __export(src_exports, {
139
161
  generateHunt: () => generateHunt,
140
162
  healSelector: () => healSelector,
141
163
  huntSchema: () => huntSchema,
164
+ injectUsePortIntoXctestrun: () => injectUsePortIntoXctestrun,
165
+ installMacdriver: () => installMacdriver,
142
166
  interpolateHunt: () => interpolateHunt,
143
167
  iosAppAllowedIdentities: () => iosAppAllowedIdentities,
168
+ iosNodeToNative: () => iosNodeToNative,
144
169
  iosQueryToLocator: () => iosQueryToLocator,
170
+ isAndroidInteractive: () => isAndroidInteractive,
171
+ isIosInteractive: () => isIosInteractive,
145
172
  launchAndroidSession: () => launchAndroidSession,
146
173
  launchIosSession: () => launchIosSession,
147
174
  launchMacSession: () => launchMacSession,
@@ -152,18 +179,41 @@ __export(src_exports, {
152
179
  loadHunt: () => loadHunt,
153
180
  loadHuntMeta: () => loadHuntMeta,
154
181
  loadHuntTags: () => loadHuntTags,
182
+ macdriverAssetName: () => macdriverAssetName,
183
+ macdriverAssetUrl: () => macdriverAssetUrl,
155
184
  macdriverBuildInstructions: () => macdriverBuildInstructions,
185
+ macdriverChecksumName: () => macdriverChecksumName,
186
+ macdriverInstallRoot: () => macdriverInstallRoot,
187
+ macdriverInstalledBinary: () => macdriverInstalledBinary,
188
+ macdriverReleaseTag: () => macdriverReleaseTag,
189
+ macdriverVersionDir: () => macdriverVersionDir,
190
+ matchAndroidSelector: () => matchAndroidSelector,
191
+ matchIosSelector: () => matchIosSelector,
192
+ matchNativeTree: () => matchNativeTree,
193
+ nodeMatchesSelector: () => nodeMatchesSelector,
156
194
  normalizeXcuiClassName: () => normalizeXcuiClassName,
157
195
  parseAaptPackage: () => parseAaptPackage,
158
196
  parseAdbDevices: () => parseAdbDevices,
197
+ parseAndroidHierarchy: () => parseAndroidHierarchy,
159
198
  parseAndroidSelector: () => parseAndroidSelector,
199
+ parseChecksumFile: () => parseChecksumFile,
200
+ parseCodesignDetails: () => parseCodesignDetails,
160
201
  parseForwardPort: () => parseForwardPort,
202
+ parseIosHierarchy: () => parseIosHierarchy,
161
203
  parseIosSelector: () => parseIosSelector,
162
204
  parseMacSelector: () => parseMacSelector,
205
+ parseNativeSelector: () => parseNativeSelector,
163
206
  parseSimctlDevices: () => parseSimctlDevices,
207
+ parseSnapshot: () => parseSnapshot,
164
208
  parseXcodeVersion: () => parseXcodeVersion,
209
+ parseXml: () => parseXml,
210
+ qualifyResourceId: () => qualifyResourceId,
211
+ quoteSelectorValue: () => quoteSelectorValue,
212
+ rankAndroidSelectors: () => rankAndroidSelectors,
165
213
  rankFlaky: () => rankFlaky,
214
+ rankIosSelectors: () => rankIosSelectors,
166
215
  rankMacSelectors: () => rankMacSelectors,
216
+ rankNativeSelectors: () => rankNativeSelectors,
167
217
  readHistory: () => readHistory,
168
218
  readHuntHistory: () => readHuntHistory,
169
219
  readIosBundleIdentifier: () => readIosBundleIdentifier,
@@ -171,19 +221,30 @@ __export(src_exports, {
171
221
  resolveAgentApks: () => resolveAgentApks,
172
222
  resolveHelperBinary: () => resolveHelperBinary,
173
223
  resolveWdaProject: () => resolveWdaProject,
174
- resolveWdaRunner: () => resolveWdaRunner,
224
+ resolveWdaTestRun: () => resolveWdaTestRun,
175
225
  runHunt: () => runHunt,
176
226
  runSuite: () => runSuite,
227
+ runVersionProbe: () => runVersionProbe,
177
228
  selectDeviceSerial: () => selectDeviceSerial,
178
229
  selectSimulatorUdid: () => selectSimulatorUdid,
230
+ sha256Hex: () => sha256Hex,
231
+ shortIosType: () => shortIosType,
179
232
  stepSchema: () => stepSchema,
233
+ tccGuidance: () => tccGuidance,
234
+ unquoteSelectorValue: () => unquoteSelectorValue,
180
235
  unwrapAndroidTextSelector: () => unwrapAndroidTextSelector,
181
236
  unwrapIosTextSelector: () => unwrapIosTextSelector,
237
+ unwrapNativeTextSelector: () => unwrapNativeTextSelector,
182
238
  updateBacklogFromSuite: () => updateBacklogFromSuite,
239
+ validateMacdriverArchiveEntries: () => validateMacdriverArchiveEntries,
240
+ validateMacdriverVersion: () => validateMacdriverVersion,
241
+ verifyMacdriverSignature: () => verifyMacdriverSignature,
183
242
  waitForAgentReady: () => waitForAgentReady,
184
243
  waitForWdaReady: () => waitForWdaReady,
185
244
  wdaCacheDir: () => wdaCacheDir,
186
- webOnlyReason: () => webOnlyReason
245
+ wdaTestRunArgs: () => wdaTestRunArgs,
246
+ webOnlyReason: () => webOnlyReason,
247
+ zipinfoArchiveLister: () => zipinfoArchiveLister
187
248
  });
188
249
  module.exports = __toCommonJS(src_exports);
189
250
 
@@ -366,25 +427,71 @@ function createMacDriver(client, options = {}) {
366
427
  // src/browser/mac-helper.ts
367
428
  var import_node_child_process = require("child_process");
368
429
  var import_node_fs = __toESM(require("fs"), 1);
369
- var import_node_path = __toESM(require("path"), 1);
430
+ var import_node_os2 = __toESM(require("os"), 1);
431
+ var import_node_path2 = __toESM(require("path"), 1);
370
432
  var import_node_url = require("url");
371
- var import_meta = {};
433
+
434
+ // src/browser/macdriver-release.ts
435
+ var import_node_os = __toESM(require("os"), 1);
436
+ var import_node_path = __toESM(require("path"), 1);
372
437
  var HELPER_BINARY = "prowl-macdriver";
438
+ var MACDRIVER_VERSION = "0.1.0";
439
+ var MACDRIVER_REPO = "prowl-tools/prowl";
440
+ var MACDRIVER_SIGNING_IDENTIFIER = "tools.prowl.macdriver";
441
+ var MACDRIVER_SIGNING_AUTHORITY_PREFIX = "Developer ID Application: Genkei Labs";
442
+ var MACDRIVER_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?$/;
443
+ function validateMacdriverVersion(version) {
444
+ if (!MACDRIVER_VERSION_PATTERN.test(version)) {
445
+ throw new Error(
446
+ `Invalid prowl-macdriver version "${version}". Expected a release version like 0.1.0.`
447
+ );
448
+ }
449
+ return version;
450
+ }
451
+ function macdriverReleaseTag(version = MACDRIVER_VERSION) {
452
+ return `macdriver-v${validateMacdriverVersion(version)}`;
453
+ }
454
+ function macdriverAssetName(version = MACDRIVER_VERSION) {
455
+ return `prowl-macdriver-v${validateMacdriverVersion(version)}-universal.zip`;
456
+ }
457
+ function macdriverChecksumName(version = MACDRIVER_VERSION) {
458
+ return `${macdriverAssetName(version)}.sha256`;
459
+ }
460
+ function macdriverAssetUrl(assetName, version = MACDRIVER_VERSION) {
461
+ return `https://github.com/${MACDRIVER_REPO}/releases/download/${macdriverReleaseTag(version)}/${assetName}`;
462
+ }
463
+ function macdriverInstallRoot(homedir = import_node_os.default.homedir()) {
464
+ return import_node_path.default.join(homedir, ".prowl", "macdriver");
465
+ }
466
+ function macdriverVersionDir(version = MACDRIVER_VERSION, homedir = import_node_os.default.homedir()) {
467
+ const root = import_node_path.default.resolve(macdriverInstallRoot(homedir));
468
+ const versionDir = import_node_path.default.resolve(root, validateMacdriverVersion(version));
469
+ if (!versionDir.startsWith(root + import_node_path.default.sep)) {
470
+ throw new Error(`Resolved prowl-macdriver version directory escaped install root: ${versionDir}`);
471
+ }
472
+ return versionDir;
473
+ }
474
+ function macdriverInstalledBinary(version = MACDRIVER_VERSION, homedir = import_node_os.default.homedir()) {
475
+ return import_node_path.default.join(macdriverVersionDir(version, homedir), HELPER_BINARY);
476
+ }
477
+
478
+ // src/browser/mac-helper.ts
479
+ var import_meta = {};
373
480
  function macdriverBuildInstructions() {
374
- return "The macOS target requires the experimental `prowl-macdriver` helper, which is not shipped in the npm package. Build it locally:\n cd macdriver && swift build -c release\nor point Prowl at a prebuilt binary via the PROWL_MACDRIVER_BIN environment variable.";
481
+ return "The macOS target needs the `prowl-macdriver` helper. Install the prebuilt, signed binary (recommended):\n prowl macdriver install\nContributors building from source can instead run:\n cd macdriver && swift build -c release\nor point Prowl at a prebuilt binary via the PROWL_MACDRIVER_BIN environment variable.";
375
482
  }
376
483
  function getPackageRoot() {
377
- let dir = import_node_path.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
378
- const root = import_node_path.default.parse(dir).root;
484
+ let dir = import_node_path2.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
485
+ const root = import_node_path2.default.parse(dir).root;
379
486
  while (dir !== root) {
380
- if (import_node_fs.default.existsSync(import_node_path.default.join(dir, "package.json"))) {
487
+ if (import_node_fs.default.existsSync(import_node_path2.default.join(dir, "package.json"))) {
381
488
  return dir;
382
489
  }
383
- dir = import_node_path.default.dirname(dir);
490
+ dir = import_node_path2.default.dirname(dir);
384
491
  }
385
492
  return root;
386
493
  }
387
- function resolveHelperBinary(env = process.env) {
494
+ function resolveHelperBinary(env = process.env, options = {}) {
388
495
  const override = env.PROWL_MACDRIVER_BIN;
389
496
  if (override) {
390
497
  if (!import_node_fs.default.existsSync(override)) {
@@ -395,10 +502,15 @@ ${macdriverBuildInstructions()}`
395
502
  }
396
503
  return override;
397
504
  }
505
+ const homedir = options.homedir ?? import_node_os2.default.homedir();
506
+ const userBinary = macdriverInstalledBinary(MACDRIVER_VERSION, homedir);
507
+ if (import_node_fs.default.existsSync(userBinary)) {
508
+ return userBinary;
509
+ }
398
510
  const root = getPackageRoot();
399
511
  const candidates = [
400
- import_node_path.default.join(root, "macdriver", ".build", "release", HELPER_BINARY),
401
- import_node_path.default.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
512
+ import_node_path2.default.join(root, "macdriver", ".build", "release", HELPER_BINARY),
513
+ import_node_path2.default.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
402
514
  ];
403
515
  for (const candidate of candidates) {
404
516
  if (import_node_fs.default.existsSync(candidate)) {
@@ -570,8 +682,583 @@ async function closeMacSession(session) {
570
682
  }
571
683
  }
572
684
 
573
- // src/browser/android-driver.ts
685
+ // src/browser/macdriver-install.ts
686
+ var import_node_child_process2 = require("child_process");
687
+ var import_node_crypto = require("crypto");
574
688
  var import_node_fs2 = __toESM(require("fs"), 1);
689
+ var import_node_os3 = __toESM(require("os"), 1);
690
+ var import_node_path3 = __toESM(require("path"), 1);
691
+ var import_node_util = require("util");
692
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process2.execFile);
693
+ function parseChecksumFile(text) {
694
+ const token = text.trim().split(/\s+/, 1)[0] ?? "";
695
+ const digest = token.toLowerCase();
696
+ if (!/^[0-9a-f]{64}$/.test(digest)) {
697
+ throw new Error(`Malformed .sha256 checksum file (expected a 64-char hex digest, got: ${text.trim().slice(0, 80)})`);
698
+ }
699
+ return digest;
700
+ }
701
+ function sha256Hex(bytes) {
702
+ return (0, import_node_crypto.createHash)("sha256").update(bytes).digest("hex");
703
+ }
704
+ var commandRunner = async (file, args) => execFileAsync(file, args);
705
+ function commandOutput(result) {
706
+ return [result.stdout, result.stderr].filter((value) => value !== void 0).map((value) => value.toString()).join("\n").trim();
707
+ }
708
+ function commandErrorDetail(error) {
709
+ const err = error;
710
+ return [err.stderr, err.stdout, err.message].filter((value) => value !== void 0 && value !== "").map((value) => value.toString()).join("\n").trim();
711
+ }
712
+ async function runRequiredCommand(run, binaryPath, command, args, label) {
713
+ try {
714
+ return await run(command, args);
715
+ } catch (error) {
716
+ const detail = commandErrorDetail(error);
717
+ throw new Error(`${label} failed for ${binaryPath}${detail ? `: ${detail}` : ""}`);
718
+ }
719
+ }
720
+ var zipinfoArchiveLister = async (zipPath) => {
721
+ try {
722
+ const { stdout } = await execFileAsync("zipinfo", ["-1", zipPath]);
723
+ return stdout.toString().split(/\r?\n/).filter((entry) => entry.length > 0);
724
+ } catch (error) {
725
+ const detail = commandErrorDetail(error);
726
+ throw new Error(`Failed to inspect release archive ${zipPath} with zipinfo${detail ? `: ${detail}` : ""}`);
727
+ }
728
+ };
729
+ function normalizeArchiveEntryName(entry) {
730
+ if (entry.length === 0 || entry !== entry.trim() || entry.includes("\0") || entry.includes("\\")) {
731
+ throw new Error(`Unsafe path in prowl-macdriver release archive: ${JSON.stringify(entry)}`);
732
+ }
733
+ if (entry.endsWith("/")) {
734
+ throw new Error(`Unexpected directory in prowl-macdriver release archive: ${entry}`);
735
+ }
736
+ if (import_node_path3.default.posix.isAbsolute(entry)) {
737
+ throw new Error(`Unsafe absolute path in prowl-macdriver release archive: ${entry}`);
738
+ }
739
+ const parts = entry.split("/");
740
+ if (parts.some((part) => part === "" || part === "." || part === "..")) {
741
+ throw new Error(`Unsafe path in prowl-macdriver release archive: ${entry}`);
742
+ }
743
+ return entry;
744
+ }
745
+ function validateMacdriverArchiveEntries(entries) {
746
+ const normalized = entries.map(normalizeArchiveEntryName);
747
+ if (normalized.length !== 1 || normalized[0] !== HELPER_BINARY) {
748
+ const shown = normalized.length > 0 ? normalized.join(", ") : "(empty archive)";
749
+ throw new Error(
750
+ `Unexpected prowl-macdriver release archive contents: ${shown}. Expected exactly "${HELPER_BINARY}" at the archive root.`
751
+ );
752
+ }
753
+ }
754
+ var dittoExtractor = async (zipPath, destDir) => {
755
+ await execFileAsync("ditto", ["-x", "-k", zipPath, destDir]);
756
+ };
757
+ function parseCodesignDetails(text) {
758
+ const details = { identifier: null, authorities: [], teamIdentifier: null };
759
+ for (const line of text.split(/\r?\n/)) {
760
+ const trimmed = line.trim();
761
+ if (trimmed.startsWith("Identifier=")) {
762
+ details.identifier = trimmed.slice("Identifier=".length);
763
+ } else if (trimmed.startsWith("Authority=")) {
764
+ details.authorities.push(trimmed.slice("Authority=".length));
765
+ } else if (trimmed.startsWith("TeamIdentifier=")) {
766
+ details.teamIdentifier = trimmed.slice("TeamIdentifier=".length);
767
+ }
768
+ }
769
+ return details;
770
+ }
771
+ function validateCodesignDetails(details, binaryPath) {
772
+ if (details.identifier !== MACDRIVER_SIGNING_IDENTIFIER) {
773
+ throw new Error(
774
+ `codesign verification failed for ${binaryPath}: expected identifier "${MACDRIVER_SIGNING_IDENTIFIER}", got "${details.identifier ?? "missing"}"`
775
+ );
776
+ }
777
+ const developerIdAuthority = details.authorities.find(
778
+ (authority) => authority.startsWith(`${MACDRIVER_SIGNING_AUTHORITY_PREFIX} (`)
779
+ );
780
+ const authorityTeamId = developerIdAuthority?.match(/\(([A-Z0-9]{10})\)$/)?.[1] ?? null;
781
+ if (!developerIdAuthority || !authorityTeamId) {
782
+ const shown = details.authorities.length > 0 ? details.authorities.join(" / ") : "missing";
783
+ throw new Error(
784
+ `codesign verification failed for ${binaryPath}: expected ${MACDRIVER_SIGNING_AUTHORITY_PREFIX} signer, got ${shown}`
785
+ );
786
+ }
787
+ if (!details.teamIdentifier) {
788
+ throw new Error(`codesign verification failed for ${binaryPath}: missing TeamIdentifier`);
789
+ }
790
+ if (details.teamIdentifier !== authorityTeamId) {
791
+ throw new Error(
792
+ `codesign verification failed for ${binaryPath}: TeamIdentifier ${details.teamIdentifier} does not match Developer ID authority team ${authorityTeamId}`
793
+ );
794
+ }
795
+ }
796
+ async function verifyMacdriverSignature(binaryPath, run = commandRunner) {
797
+ await runRequiredCommand(run, binaryPath, "codesign", ["--verify", "--strict", binaryPath], "codesign verification");
798
+ const display = await runRequiredCommand(
799
+ run,
800
+ binaryPath,
801
+ "codesign",
802
+ ["--display", "--verbose=4", binaryPath],
803
+ "codesign detail inspection"
804
+ );
805
+ validateCodesignDetails(parseCodesignDetails(commandOutput(display)), binaryPath);
806
+ await runRequiredCommand(
807
+ run,
808
+ binaryPath,
809
+ "spctl",
810
+ ["--assess", "--type", "execute", "--verbose=4", binaryPath],
811
+ "spctl assessment"
812
+ );
813
+ }
814
+ var codesignVerifier = async (binaryPath) => {
815
+ await verifyMacdriverSignature(binaryPath);
816
+ };
817
+ async function downloadAndVerify(options = {}) {
818
+ const version = options.version ?? MACDRIVER_VERSION;
819
+ const fetchImpl = options.fetchImpl ?? fetch;
820
+ const assetName = macdriverAssetName(version);
821
+ const zipUrl = macdriverAssetUrl(assetName, version);
822
+ const sumUrl = macdriverAssetUrl(macdriverChecksumName(version), version);
823
+ const [zipRes, sumRes] = await Promise.all([
824
+ fetchImpl(zipUrl, { redirect: "follow" }),
825
+ fetchImpl(sumUrl, { redirect: "follow" })
826
+ ]);
827
+ if (zipRes.status === 404 || sumRes.status === 404) {
828
+ throw new Error(
829
+ `No published prowl-macdriver release for ${macdriverReleaseTag(version)} yet.
830
+ The signed binary is cut by the maintainer; until then build from source:
831
+ cd macdriver && swift build -c release`
832
+ );
833
+ }
834
+ if (!zipRes.ok) {
835
+ throw new Error(`Failed to download ${assetName} (HTTP ${zipRes.status}) from ${zipUrl}`);
836
+ }
837
+ if (!sumRes.ok) {
838
+ throw new Error(`Failed to download the checksum (HTTP ${sumRes.status}) from ${sumUrl}`);
839
+ }
840
+ const zipBytes = Buffer.from(await zipRes.arrayBuffer());
841
+ const expected = parseChecksumFile(await sumRes.text());
842
+ const actual = sha256Hex(zipBytes);
843
+ if (actual !== expected) {
844
+ throw new Error(
845
+ `Checksum mismatch for ${assetName}.
846
+ expected: ${expected}
847
+ actual: ${actual}
848
+ The download was rejected and discarded; re-run the install, and report it if it repeats.`
849
+ );
850
+ }
851
+ return zipBytes;
852
+ }
853
+ async function installMacdriver(options = {}) {
854
+ const version = validateMacdriverVersion(options.version ?? MACDRIVER_VERSION);
855
+ const homedir = options.homedir ?? import_node_os3.default.homedir();
856
+ const listArchiveEntries = options.listArchiveEntries ?? zipinfoArchiveLister;
857
+ const extract = options.extract ?? dittoExtractor;
858
+ const verifySignature = options.verifySignature ?? codesignVerifier;
859
+ const installRoot = macdriverInstallRoot(homedir);
860
+ const versionDir = macdriverVersionDir(version, homedir);
861
+ const binaryPath = macdriverInstalledBinary(version, homedir);
862
+ if (!options.force && import_node_fs2.default.existsSync(binaryPath)) {
863
+ return { version, binaryPath, alreadyInstalled: true };
864
+ }
865
+ const zipBytes = await downloadAndVerify({ version, fetchImpl: options.fetchImpl });
866
+ import_node_fs2.default.mkdirSync(installRoot, { recursive: true });
867
+ const stagingDir = import_node_fs2.default.mkdtempSync(import_node_path3.default.join(installRoot, `.tmp-${version}-`));
868
+ const extractDir = import_node_path3.default.join(stagingDir, "extract");
869
+ const zipPath = import_node_path3.default.join(stagingDir, macdriverAssetName(version));
870
+ try {
871
+ import_node_fs2.default.mkdirSync(extractDir);
872
+ import_node_fs2.default.writeFileSync(zipPath, zipBytes);
873
+ validateMacdriverArchiveEntries(await listArchiveEntries(zipPath));
874
+ await extract(zipPath, extractDir);
875
+ const stagedBinaryPath = import_node_path3.default.join(extractDir, HELPER_BINARY);
876
+ assertExtractedHelper(extractDir, stagedBinaryPath);
877
+ import_node_fs2.default.chmodSync(stagedBinaryPath, 493);
878
+ await verifySignature(stagedBinaryPath);
879
+ replaceVersionDir(versionDir, extractDir, installRoot);
880
+ } finally {
881
+ import_node_fs2.default.rmSync(stagingDir, { recursive: true, force: true });
882
+ }
883
+ return { version, binaryPath, alreadyInstalled: false };
884
+ }
885
+ function assertExtractedHelper(extractDir, binaryPath) {
886
+ const entries = import_node_fs2.default.readdirSync(extractDir);
887
+ if (!entries.includes(HELPER_BINARY)) {
888
+ throw new Error(`The release archive did not contain a "${HELPER_BINARY}" binary.`);
889
+ }
890
+ if (entries.length !== 1 || entries[0] !== HELPER_BINARY) {
891
+ const shown = entries.length > 0 ? entries.join(", ") : "(empty directory)";
892
+ throw new Error(
893
+ `Unexpected extracted prowl-macdriver archive contents: ${shown}. Expected exactly "${HELPER_BINARY}".`
894
+ );
895
+ }
896
+ let stat = null;
897
+ try {
898
+ stat = import_node_fs2.default.lstatSync(binaryPath);
899
+ } catch {
900
+ }
901
+ if (!stat?.isFile()) {
902
+ throw new Error(`The release archive "${HELPER_BINARY}" entry is not a regular file.`);
903
+ }
904
+ }
905
+ function replaceVersionDir(versionDir, stagedVersionDir, installRoot) {
906
+ const backupDir = import_node_path3.default.join(installRoot, `.previous-${import_node_path3.default.basename(versionDir)}-${process.pid}-${Date.now()}`);
907
+ let backedUp = false;
908
+ try {
909
+ if (import_node_fs2.default.existsSync(versionDir)) {
910
+ import_node_fs2.default.renameSync(versionDir, backupDir);
911
+ backedUp = true;
912
+ }
913
+ import_node_fs2.default.renameSync(stagedVersionDir, versionDir);
914
+ if (backedUp) {
915
+ import_node_fs2.default.rmSync(backupDir, { recursive: true, force: true });
916
+ }
917
+ } catch (error) {
918
+ if (backedUp && !import_node_fs2.default.existsSync(versionDir) && import_node_fs2.default.existsSync(backupDir)) {
919
+ import_node_fs2.default.renameSync(backupDir, versionDir);
920
+ }
921
+ throw error;
922
+ }
923
+ }
924
+ var runVersionProbe = async (binaryPath) => {
925
+ try {
926
+ const { stdout } = await execFileAsync(binaryPath, ["version"], { timeout: 5e3 });
927
+ const match = stdout.match(/prowl-macdriver\s+(\S+)/);
928
+ return match ? match[1] : stdout.trim() || null;
929
+ } catch {
930
+ return null;
931
+ }
932
+ };
933
+ async function collectMacdriverStatus(options = {}) {
934
+ const env = options.env ?? process.env;
935
+ const homedir = options.homedir ?? import_node_os3.default.homedir();
936
+ const probe = options.probe ?? runVersionProbe;
937
+ let resolved = null;
938
+ try {
939
+ const resolvedPath = resolveHelperBinary(env, { homedir });
940
+ resolved = { path: resolvedPath, source: classifyResolvedSource(resolvedPath, env, homedir) };
941
+ } catch {
942
+ resolved = null;
943
+ }
944
+ const installed = [];
945
+ const root = macdriverInstallRoot(homedir);
946
+ if (import_node_fs2.default.existsSync(root)) {
947
+ for (const entry of import_node_fs2.default.readdirSync(root, { withFileTypes: true })) {
948
+ if (!entry.isDirectory()) continue;
949
+ let binaryPath;
950
+ try {
951
+ binaryPath = macdriverInstalledBinary(entry.name, homedir);
952
+ } catch {
953
+ continue;
954
+ }
955
+ if (import_node_fs2.default.existsSync(binaryPath)) {
956
+ installed.push({ version: entry.name, binaryPath });
957
+ }
958
+ }
959
+ installed.sort((a, b) => a.version.localeCompare(b.version));
960
+ }
961
+ const probedVersion = resolved ? await probe(resolved.path) : null;
962
+ return { resolved, pinnedVersion: MACDRIVER_VERSION, installed, probedVersion };
963
+ }
964
+ function classifyResolvedSource(resolvedPath, env, homedir) {
965
+ if (env.PROWL_MACDRIVER_BIN && resolvedPath === env.PROWL_MACDRIVER_BIN) {
966
+ return "env";
967
+ }
968
+ if (resolvedPath.startsWith(macdriverInstallRoot(homedir) + import_node_path3.default.sep)) {
969
+ return "user-install";
970
+ }
971
+ return "source-build";
972
+ }
973
+ function tccGuidance() {
974
+ return "macOS permissions: the app that hosts Prowl (your terminal \u2014 Terminal, iTerm, VS Code, \u2026)\nmust be granted, in System Settings \u2192 Privacy & Security:\n \u2022 Accessibility \u2014 required to drive the target app\n \u2022 Screen Recording \u2014 required for screenshots / visual baselines\nGrant both to the terminal app, not to prowl-macdriver itself, then re-run your hunt.";
975
+ }
976
+
977
+ // src/browser/android-driver.ts
978
+ var import_node_fs3 = __toESM(require("fs"), 1);
979
+
980
+ // src/analyzer/xml.ts
981
+ var ENTITIES = {
982
+ amp: "&",
983
+ lt: "<",
984
+ gt: ">",
985
+ quot: '"',
986
+ apos: "'"
987
+ };
988
+ function decodeXmlEntities(value) {
989
+ if (!value.includes("&")) {
990
+ return value;
991
+ }
992
+ return value.replace(/&(#(?:[xX][0-9a-fA-F]+|[0-9]+)|[a-zA-Z]+);/g, (match, code) => {
993
+ if (code[0] === "#") {
994
+ const hex = code[1] === "x" || code[1] === "X";
995
+ const num2 = Number.parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10);
996
+ return Number.isSafeInteger(num2) && num2 <= 1114111 ? String.fromCodePoint(num2) : match;
997
+ }
998
+ const named = ENTITIES[code];
999
+ return named ?? match;
1000
+ });
1001
+ }
1002
+ var ATTR_RE = /([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
1003
+ function parseTagBody(body) {
1004
+ const trimmed = body.trim();
1005
+ const nameMatch = /^([^\s/>]+)/.exec(trimmed);
1006
+ const tag = nameMatch ? nameMatch[1] : "";
1007
+ const attrs = {};
1008
+ const rest = trimmed.slice(tag.length);
1009
+ ATTR_RE.lastIndex = 0;
1010
+ let m;
1011
+ while ((m = ATTR_RE.exec(rest)) !== null) {
1012
+ const rawValue = m[3] !== void 0 ? m[3] : m[4] ?? "";
1013
+ attrs[m[1]] = decodeXmlEntities(rawValue);
1014
+ }
1015
+ return { tag, attrs };
1016
+ }
1017
+ function parseXml(input) {
1018
+ const n = input.length;
1019
+ const stack = [];
1020
+ let root = null;
1021
+ let i = 0;
1022
+ while (i < n) {
1023
+ const lt = input.indexOf("<", i);
1024
+ if (lt < 0) {
1025
+ break;
1026
+ }
1027
+ i = lt + 1;
1028
+ const ch = input[i];
1029
+ if (ch === "?") {
1030
+ const end = input.indexOf("?>", i);
1031
+ i = end < 0 ? n : end + 2;
1032
+ continue;
1033
+ }
1034
+ if (ch === "!") {
1035
+ if (input.startsWith("!--", i)) {
1036
+ const end = input.indexOf("-->", i);
1037
+ i = end < 0 ? n : end + 3;
1038
+ } else {
1039
+ const end = input.indexOf(">", i);
1040
+ i = end < 0 ? n : end + 1;
1041
+ }
1042
+ continue;
1043
+ }
1044
+ if (ch === "/") {
1045
+ const gt = input.indexOf(">", i);
1046
+ i = gt < 0 ? n : gt + 1;
1047
+ stack.pop();
1048
+ continue;
1049
+ }
1050
+ let j = i;
1051
+ let quote2 = null;
1052
+ while (j < n) {
1053
+ const c = input[j];
1054
+ if (quote2 !== null) {
1055
+ if (c === quote2) {
1056
+ quote2 = null;
1057
+ }
1058
+ } else if (c === '"' || c === "'") {
1059
+ quote2 = c;
1060
+ } else if (c === ">") {
1061
+ break;
1062
+ }
1063
+ j += 1;
1064
+ }
1065
+ const inner = input.slice(i, j);
1066
+ i = j + 1;
1067
+ const selfClose = inner.endsWith("/");
1068
+ const body = selfClose ? inner.slice(0, -1) : inner;
1069
+ const { tag, attrs } = parseTagBody(body);
1070
+ if (tag.length === 0) {
1071
+ continue;
1072
+ }
1073
+ const element = { tag, attrs, children: [] };
1074
+ const parent = stack[stack.length - 1];
1075
+ if (parent) {
1076
+ parent.children.push(element);
1077
+ }
1078
+ if (root === null) {
1079
+ root = element;
1080
+ }
1081
+ if (!selfClose) {
1082
+ stack.push(element);
1083
+ }
1084
+ }
1085
+ return root;
1086
+ }
1087
+
1088
+ // src/selector/native.ts
1089
+ function unquoteSelectorValue(value) {
1090
+ const trimmed = value.trim();
1091
+ const first = trimmed[0];
1092
+ if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
1093
+ return trimmed.slice(1, -1);
1094
+ }
1095
+ return trimmed;
1096
+ }
1097
+ function quoteSelectorValue(value) {
1098
+ return `"${value}"`;
1099
+ }
1100
+ var ROLE_SELECTOR_RE = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s;
1101
+ var NATIVE_SELECTOR_PREFIX_RE = /^(id|label|text|role)=/;
1102
+ function invalidSelectorMessage(selector, reason) {
1103
+ return `Invalid native selector ${JSON.stringify(selector)}: ${reason}.`;
1104
+ }
1105
+ function parseNativeSelector(selector) {
1106
+ const trimmed = selector.trim();
1107
+ if (trimmed.length === 0) {
1108
+ throw new Error(
1109
+ invalidSelectorMessage(
1110
+ selector,
1111
+ "selector is empty; use id=, label=, text=, role=, :focus, or a bare text value"
1112
+ )
1113
+ );
1114
+ }
1115
+ if (trimmed === ":focus") {
1116
+ return { kind: "focused" };
1117
+ }
1118
+ const idMatch = /^id=(.+)$/s.exec(trimmed);
1119
+ if (idMatch) {
1120
+ return { kind: "id", value: unquoteSelectorValue(idMatch[1]) };
1121
+ }
1122
+ const roleMatch = ROLE_SELECTOR_RE.exec(trimmed);
1123
+ if (roleMatch) {
1124
+ const name = roleMatch[2] !== void 0 ? unquoteSelectorValue(roleMatch[2]) : void 0;
1125
+ return name !== void 0 && name.length > 0 ? { kind: "role", role: roleMatch[1], name } : { kind: "role", role: roleMatch[1] };
1126
+ }
1127
+ const labelMatch = /^label=(.+)$/s.exec(trimmed);
1128
+ if (labelMatch) {
1129
+ return { kind: "label", value: unquoteSelectorValue(labelMatch[1]) };
1130
+ }
1131
+ const textMatch = /^text=(.+)$/s.exec(trimmed);
1132
+ if (textMatch) {
1133
+ return { kind: "text", value: unquoteSelectorValue(textMatch[1]) };
1134
+ }
1135
+ const prefix = NATIVE_SELECTOR_PREFIX_RE.exec(trimmed);
1136
+ if (prefix) {
1137
+ throw new Error(
1138
+ invalidSelectorMessage(
1139
+ selector,
1140
+ `malformed ${prefix[1]}= selector; expected id=<value>, label=<value>, text=<value>, or role=<Type>[name=<value>]`
1141
+ )
1142
+ );
1143
+ }
1144
+ return { kind: "text", value: trimmed };
1145
+ }
1146
+ function unwrapNativeTextSelector(selector) {
1147
+ const trimmed = selector.trim();
1148
+ if (!trimmed.startsWith("text=")) {
1149
+ return null;
1150
+ }
1151
+ return unquoteSelectorValue(trimmed.slice("text=".length));
1152
+ }
1153
+ function qualifyResourceId(value, appPackage) {
1154
+ if (value.includes(":") || !appPackage) {
1155
+ return value;
1156
+ }
1157
+ return `${appPackage}:id/${value}`;
1158
+ }
1159
+ function normalizeXcuiClassName(role) {
1160
+ return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
1161
+ }
1162
+ function shortIosType(type) {
1163
+ return type.startsWith("XCUIElementType") ? type.slice("XCUIElementType".length) : type;
1164
+ }
1165
+ function rankNativeSelectors(fields) {
1166
+ const selectors = [];
1167
+ if (fields.id) {
1168
+ selectors.push(`id=${fields.id}`);
1169
+ }
1170
+ if (fields.label) {
1171
+ selectors.push(`label=${quoteSelectorValue(fields.label)}`);
1172
+ }
1173
+ if (fields.role && fields.name) {
1174
+ selectors.push(`role=${fields.role}[name=${quoteSelectorValue(fields.name)}]`);
1175
+ }
1176
+ if (fields.name) {
1177
+ selectors.push(`text=${quoteSelectorValue(fields.name)}`);
1178
+ }
1179
+ if (selectors.length === 0 && fields.role) {
1180
+ selectors.push(`role=${fields.role}`);
1181
+ }
1182
+ return selectors;
1183
+ }
1184
+ var NATIVE_ATTRIBUTE_MAP = {
1185
+ android: {
1186
+ id: { attribute: "resource-id", match: "exact (package-qualified)" },
1187
+ label: { attribute: "content-desc", match: "exact" },
1188
+ text: { attribute: "text", match: "substring" },
1189
+ role: { attribute: "class", match: "exact" }
1190
+ },
1191
+ ios: {
1192
+ id: { attribute: "accessibility id (name)", match: "exact" },
1193
+ label: { attribute: "label", match: "exact" },
1194
+ text: { attribute: "label | value", match: "substring" },
1195
+ role: { attribute: "type (XCUIElementType\u2026)", match: "exact" }
1196
+ },
1197
+ macos: {
1198
+ id: { attribute: "AXIdentifier", match: "exact" },
1199
+ label: { attribute: "title | description", match: "exact" },
1200
+ text: { attribute: "title | description | value", match: "substring" },
1201
+ role: { attribute: "AXRole", match: "exact" }
1202
+ }
1203
+ };
1204
+ var ANDROID_MATCH_DIALECT = {
1205
+ platform: "android",
1206
+ normalizeRole: (role) => role,
1207
+ normalizeId: (value, options) => qualifyResourceId(value, options.appPackage)
1208
+ };
1209
+ var IOS_MATCH_DIALECT = {
1210
+ platform: "ios",
1211
+ normalizeRole: (role) => normalizeXcuiClassName(role),
1212
+ normalizeId: (value) => value
1213
+ };
1214
+ var MACOS_MATCH_DIALECT = {
1215
+ platform: "macos",
1216
+ normalizeRole: (role) => role,
1217
+ normalizeId: (value) => value
1218
+ };
1219
+ function nodeMatchesSelector(dialect, selector, node, options = {}) {
1220
+ switch (selector.kind) {
1221
+ case "id":
1222
+ return node.id !== void 0 && node.id === dialect.normalizeId(selector.value, options);
1223
+ case "label":
1224
+ return node.label !== void 0 && node.label === selector.value;
1225
+ case "text":
1226
+ return node.textValues.some((t) => t.includes(selector.value));
1227
+ case "focused":
1228
+ return node.focused === true;
1229
+ case "role": {
1230
+ if (node.role === void 0) {
1231
+ return false;
1232
+ }
1233
+ if (dialect.normalizeRole(node.role) !== dialect.normalizeRole(selector.role)) {
1234
+ return false;
1235
+ }
1236
+ if (selector.name === void 0 || selector.name.length === 0) {
1237
+ return true;
1238
+ }
1239
+ const name = selector.name;
1240
+ return node.textValues.some((t) => t.includes(name));
1241
+ }
1242
+ }
1243
+ }
1244
+ function matchNativeTree(dialect, selector, root, project, children, options = {}) {
1245
+ const out = [];
1246
+ const visit = (node) => {
1247
+ if (nodeMatchesSelector(dialect, selector, project(node), options)) {
1248
+ out.push(node);
1249
+ }
1250
+ for (const child of children(node)) {
1251
+ visit(child);
1252
+ }
1253
+ };
1254
+ visit(root);
1255
+ return out;
1256
+ }
1257
+ function parseSnapshot(xml) {
1258
+ return parseXml(xml);
1259
+ }
1260
+
1261
+ // src/browser/android-driver.ts
575
1262
  var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
576
1263
  "query",
577
1264
  "interact",
@@ -605,50 +1292,29 @@ var ANDROID_KEYCODES = {
605
1292
  pageup: 92,
606
1293
  pagedown: 93
607
1294
  };
608
- function unquote2(value) {
609
- const trimmed = value.trim();
610
- const first = trimmed[0];
611
- if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
612
- return trimmed.slice(1, -1);
613
- }
614
- return trimmed;
615
- }
616
1295
  function escapeUiSelectorArg(value) {
617
1296
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
618
1297
  }
619
- function parseAndroidSelector(selector) {
620
- const trimmed = selector.trim();
621
- if (trimmed === ":focus") {
622
- return { by: "focused" };
623
- }
624
- const idMatch = /^id=(.+)$/s.exec(trimmed);
625
- if (idMatch) {
626
- return { by: "id", value: unquote2(idMatch[1]) };
627
- }
628
- const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
629
- if (roleMatch) {
630
- const name = roleMatch[2] !== void 0 ? unquote2(roleMatch[2]) : void 0;
631
- return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
632
- }
633
- const labelMatch = /^label=(.+)$/s.exec(trimmed);
634
- if (labelMatch) {
635
- return { by: "accessibilityId", value: unquote2(labelMatch[1]) };
636
- }
637
- const textMatch = /^text=(.+)$/s.exec(trimmed);
638
- if (textMatch) {
639
- return { by: "text", value: unquote2(textMatch[1]) };
1298
+ function toAndroidQuery(selector) {
1299
+ switch (selector.kind) {
1300
+ case "focused":
1301
+ return { by: "focused" };
1302
+ case "id":
1303
+ return { by: "id", value: selector.value };
1304
+ case "role":
1305
+ return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
1306
+ case "label":
1307
+ return { by: "accessibilityId", value: selector.value };
1308
+ case "text":
1309
+ return { by: "text", value: selector.value };
640
1310
  }
641
- return { by: "text", value: trimmed };
1311
+ }
1312
+ function parseAndroidSelector(selector) {
1313
+ return toAndroidQuery(parseNativeSelector(selector));
642
1314
  }
643
1315
  function locator(strategy, selector) {
644
1316
  return { strategy, selector, context: "" };
645
1317
  }
646
- function qualifyResourceId(value, appPackage) {
647
- if (value.includes(":") || !appPackage) {
648
- return value;
649
- }
650
- return `${appPackage}:id/${value}`;
651
- }
652
1318
  function androidQueryToLocator(query, options = {}) {
653
1319
  switch (query.by) {
654
1320
  case "id":
@@ -675,11 +1341,7 @@ function androidQueryToLocator(query, options = {}) {
675
1341
  }
676
1342
  }
677
1343
  function unwrapAndroidTextSelector(selector) {
678
- const trimmed = selector.trim();
679
- if (!trimmed.startsWith("text=")) {
680
- return null;
681
- }
682
- return unquote2(trimmed.slice(5));
1344
+ return unwrapNativeTextSelector(selector);
683
1345
  }
684
1346
  function keyCodeFor(key) {
685
1347
  const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
@@ -805,7 +1467,7 @@ function createAndroidDriver(client, options = {}) {
805
1467
  },
806
1468
  async screenshot(screenshotOptions) {
807
1469
  const png = await client.screenshotPng();
808
- import_node_fs2.default.writeFileSync(screenshotOptions.path, png);
1470
+ import_node_fs3.default.writeFileSync(screenshotOptions.path, png);
809
1471
  },
810
1472
  // network / dialogs / downloads (all web-only) -------------------------
811
1473
  onResponse(_handler) {
@@ -831,14 +1493,14 @@ function createAndroidDriver(client, options = {}) {
831
1493
 
832
1494
  // src/browser/android-helper.ts
833
1495
  var import_node_module = require("module");
834
- var import_node_child_process4 = require("child_process");
835
- var import_node_fs4 = __toESM(require("fs"), 1);
836
- var import_node_path3 = __toESM(require("path"), 1);
1496
+ var import_node_child_process5 = require("child_process");
1497
+ var import_node_fs5 = __toESM(require("fs"), 1);
1498
+ var import_node_path5 = __toESM(require("path"), 1);
837
1499
 
838
1500
  // src/browser/android-adb.ts
839
- var import_node_child_process2 = require("child_process");
1501
+ var import_node_child_process3 = require("child_process");
840
1502
  var execFileAdbRunner = (args, options) => new Promise((resolve) => {
841
- (0, import_node_child_process2.execFile)(
1503
+ (0, import_node_child_process3.execFile)(
842
1504
  "adb",
843
1505
  args,
844
1506
  { encoding: "utf-8", timeout: options?.timeoutMs, maxBuffer: 16 * 1024 * 1024 },
@@ -854,7 +1516,7 @@ var execFileAdbRunner = (args, options) => new Promise((resolve) => {
854
1516
  );
855
1517
  });
856
1518
  var spawnAdbProcess = (args) => {
857
- const child = (0, import_node_child_process2.spawn)("adb", args, { stdio: "ignore" });
1519
+ const child = (0, import_node_child_process3.spawn)("adb", args, { stdio: "ignore" });
858
1520
  child.on("error", () => {
859
1521
  });
860
1522
  return { kill: () => child.kill() };
@@ -960,22 +1622,63 @@ async function installApk(runner, serial, apkPath) {
960
1622
  );
961
1623
  }
962
1624
  }
1625
+ function formatUnknownError(error) {
1626
+ return error instanceof Error ? error.message : String(error);
1627
+ }
1628
+ function runnerPhaseError(message, error) {
1629
+ return new Error(`${message}: ${formatUnknownError(error)}`, { cause: error });
1630
+ }
1631
+ function parseResolvedComponent(stdout, pkg) {
1632
+ const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
1633
+ for (let i = lines.length - 1; i >= 0; i--) {
1634
+ if (/^[\w.]+\/[\w.$]+$/.test(lines[i]) && lines[i].startsWith(`${pkg}/`)) {
1635
+ return lines[i];
1636
+ }
1637
+ }
1638
+ return null;
1639
+ }
963
1640
  async function launchPackage(runner, serial, pkg) {
964
- const result = await runner(
965
- withSerial(serial, [
966
- "shell",
967
- "monkey",
968
- "-p",
969
- pkg,
970
- "-c",
971
- "android.intent.category.LAUNCHER",
972
- "1"
973
- ]),
974
- { timeoutMs: 3e4 }
975
- );
976
- if (result.code !== 0 || /no activities found|aborted|cannot launch/i.test(result.stdout + result.stderr)) {
1641
+ let resolved;
1642
+ try {
1643
+ resolved = await runner(
1644
+ withSerial(serial, [
1645
+ "shell",
1646
+ "cmd",
1647
+ "package",
1648
+ "resolve-activity",
1649
+ "--brief",
1650
+ "-c",
1651
+ "android.intent.category.LAUNCHER",
1652
+ pkg
1653
+ ]),
1654
+ { timeoutMs: 3e4 }
1655
+ );
1656
+ } catch (error) {
1657
+ throw runnerPhaseError(
1658
+ `Failed to resolve the launcher activity for Android package "${pkg}" via adb`,
1659
+ error
1660
+ );
1661
+ }
1662
+ const component = parseResolvedComponent(resolved.stdout, pkg);
1663
+ if (resolved.code !== 0 || !component) {
1664
+ throw new Error(
1665
+ `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?`
1666
+ );
1667
+ }
1668
+ let start;
1669
+ try {
1670
+ start = await runner(withSerial(serial, ["shell", "am", "start", "-n", component]), {
1671
+ timeoutMs: 3e4
1672
+ });
1673
+ } catch (error) {
1674
+ throw runnerPhaseError(
1675
+ `Failed to launch Android package "${pkg}" with \`am start\` (${component}) via adb`,
1676
+ error
1677
+ );
1678
+ }
1679
+ if (start.code !== 0 || /error|does not exist|cannot start/i.test(start.stdout + start.stderr)) {
977
1680
  throw new Error(
978
- `Failed to launch Android package "${pkg}" (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 400)}. Is it installed?`
1681
+ `Failed to launch Android package "${pkg}" (${component}, exit ${start.code}): ${(start.stdout + start.stderr).trim().slice(0, 400)}.`
979
1682
  );
980
1683
  }
981
1684
  }
@@ -1037,12 +1740,12 @@ var Uia2Transport = class {
1037
1740
  * {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
1038
1741
  * per-request deadline elapses.
1039
1742
  */
1040
- async request(method, path18, body, timeoutMs) {
1743
+ async request(method, path20, body, timeoutMs) {
1041
1744
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
1042
1745
  const controller = new AbortController();
1043
1746
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
1044
1747
  timer.unref?.();
1045
- const url = `${this.baseUrl}${path18}`;
1748
+ const url = `${this.baseUrl}${path20}`;
1046
1749
  let response;
1047
1750
  try {
1048
1751
  response = await this.fetchImpl(url, {
@@ -1054,7 +1757,7 @@ var Uia2Transport = class {
1054
1757
  } catch (error) {
1055
1758
  if (controller.signal.aborted) {
1056
1759
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
1057
- throw new Error(`uiautomator2 request ${method} ${path18} timed out after ${shown}`);
1760
+ throw new Error(`uiautomator2 request ${method} ${path20} timed out after ${shown}`);
1058
1761
  }
1059
1762
  throw error instanceof Error ? error : new Error(String(error));
1060
1763
  } finally {
@@ -1065,7 +1768,7 @@ var Uia2Transport = class {
1065
1768
  if (!response.ok) {
1066
1769
  const wdError = extractWebdriverError(parsed);
1067
1770
  throw new Uia2HttpError(
1068
- `uiautomator2 ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
1771
+ `uiautomator2 ${method} ${path20} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
1069
1772
  response.status,
1070
1773
  wdError
1071
1774
  );
@@ -1148,10 +1851,10 @@ function sleep(ms) {
1148
1851
  }
1149
1852
  function createUia2AgentClient(transport, sessionId, options = {}) {
1150
1853
  const base = `/session/${sessionId}`;
1151
- async function locate(query, path18) {
1854
+ async function locate(query, path20) {
1152
1855
  return transport.request(
1153
1856
  "POST",
1154
- `${base}${path18}`,
1857
+ `${base}${path20}`,
1155
1858
  androidQueryToLocator(query, { appPackage: options.appPackage })
1156
1859
  );
1157
1860
  }
@@ -1193,6 +1896,13 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
1193
1896
  }
1194
1897
  return Buffer.from(value, "base64");
1195
1898
  },
1899
+ async source() {
1900
+ const value = await transport.request("GET", `${base}/source`);
1901
+ if (typeof value !== "string") {
1902
+ throw new Error("uiautomator2 /source did not return XML text; cannot analyze Android UI hierarchy");
1903
+ }
1904
+ return value;
1905
+ },
1196
1906
  async close() {
1197
1907
  await transport.request("DELETE", base).catch(() => void 0);
1198
1908
  }
@@ -1200,9 +1910,9 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
1200
1910
  }
1201
1911
 
1202
1912
  // src/config/target.ts
1203
- var import_node_child_process3 = require("child_process");
1204
- var import_node_fs3 = __toESM(require("fs"), 1);
1205
- var import_node_path2 = __toESM(require("path"), 1);
1913
+ var import_node_child_process4 = require("child_process");
1914
+ var import_node_fs4 = __toESM(require("fs"), 1);
1915
+ var import_node_path4 = __toESM(require("path"), 1);
1206
1916
  var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
1207
1917
  "navigate",
1208
1918
  "waitForUrl",
@@ -1287,7 +1997,7 @@ function looksLikeIosAppPath(app) {
1287
1997
  }
1288
1998
  if (trimmed.toLowerCase().endsWith(".app")) {
1289
1999
  try {
1290
- return import_node_fs3.default.statSync(normalizeAppPath(app)).isDirectory();
2000
+ return import_node_fs4.default.statSync(normalizeAppPath(app)).isDirectory();
1291
2001
  } catch {
1292
2002
  return false;
1293
2003
  }
@@ -1295,29 +2005,29 @@ function looksLikeIosAppPath(app) {
1295
2005
  return false;
1296
2006
  }
1297
2007
  function normalizeAppPath(app) {
1298
- return import_node_path2.default.resolve(trimTrailingPathSeparators(app));
2008
+ return import_node_path4.default.resolve(trimTrailingPathSeparators(app));
1299
2009
  }
1300
2010
  function parseBundleIdentifier(plist) {
1301
2011
  const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
1302
2012
  return match?.[1]?.trim() || null;
1303
2013
  }
1304
2014
  function readBundleIdentifier(appPath, ...plistSubPath) {
1305
- const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), ...plistSubPath);
1306
- if (!import_node_fs3.default.existsSync(infoPlistPath)) {
2015
+ const infoPlistPath = import_node_path4.default.join(normalizeAppPath(appPath), ...plistSubPath);
2016
+ if (!import_node_fs4.default.existsSync(infoPlistPath)) {
1307
2017
  return null;
1308
2018
  }
1309
2019
  try {
1310
- const parsed = parseBundleIdentifier(import_node_fs3.default.readFileSync(infoPlistPath, "utf-8"));
2020
+ const parsed = parseBundleIdentifier(import_node_fs4.default.readFileSync(infoPlistPath, "utf-8"));
1311
2021
  if (parsed) {
1312
2022
  return parsed;
1313
2023
  }
1314
2024
  } catch {
1315
2025
  }
1316
- if (process.platform !== "darwin" || !import_node_fs3.default.existsSync("/usr/bin/plutil")) {
2026
+ if (process.platform !== "darwin" || !import_node_fs4.default.existsSync("/usr/bin/plutil")) {
1317
2027
  return null;
1318
2028
  }
1319
2029
  try {
1320
- const output = (0, import_node_child_process3.execFileSync)(
2030
+ const output = (0, import_node_child_process4.execFileSync)(
1321
2031
  "/usr/bin/plutil",
1322
2032
  ["-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlistPath],
1323
2033
  { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1e3 }
@@ -1339,7 +2049,7 @@ function appBundleAllowedIdentities(app, readBundleId, isAppPath = looksLikeAppB
1339
2049
  const normalizedPath = normalizeAppPath(app);
1340
2050
  identities.add(trimTrailingPathSeparators(app));
1341
2051
  identities.add(normalizedPath);
1342
- const bundleName = import_node_path2.default.basename(normalizedPath).replace(/\.app$/i, "");
2052
+ const bundleName = import_node_path4.default.basename(normalizedPath).replace(/\.app$/i, "");
1343
2053
  if (bundleName) {
1344
2054
  identities.add(bundleName);
1345
2055
  }
@@ -1367,9 +2077,9 @@ function looksLikeApkPath(app) {
1367
2077
  return trimmed.includes("/") || trimmed.includes("\\") || trimmed.toLowerCase().endsWith(".apk");
1368
2078
  }
1369
2079
  function normalizeAndroidApkPath(app) {
1370
- const resolved = import_node_path2.default.resolve(trimTrailingPathSeparators(app));
2080
+ const resolved = import_node_path4.default.resolve(trimTrailingPathSeparators(app));
1371
2081
  try {
1372
- return import_node_fs3.default.realpathSync.native(resolved);
2082
+ return import_node_fs4.default.realpathSync.native(resolved);
1373
2083
  } catch {
1374
2084
  return resolved;
1375
2085
  }
@@ -1412,12 +2122,12 @@ function resolveAgentApks(requireFn = (0, import_node_module.createRequire)(impo
1412
2122
  "The Android target requires the `appium-uiautomator2-server` package (its prebuilt APKs). It is an optional dependency of prowl-tools; it may have been skipped (--omit=optional) or failed to install. Restore it for a global Prowl install with: npm install -g appium-uiautomator2-server@10.6.2. If Prowl is installed locally in a project, run: npm install appium-uiautomator2-server@10.6.2"
1413
2123
  );
1414
2124
  }
1415
- const pkgDir = import_node_path3.default.dirname(pkgJsonPath);
2125
+ const pkgDir = import_node_path5.default.dirname(pkgJsonPath);
1416
2126
  const version = requireFn(pkgJsonPath).version;
1417
- const serverApk = import_node_path3.default.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
1418
- const testApk = import_node_path3.default.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
2127
+ const serverApk = import_node_path5.default.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
2128
+ const testApk = import_node_path5.default.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
1419
2129
  for (const apk of [serverApk, testApk]) {
1420
- if (!import_node_fs4.default.existsSync(apk)) {
2130
+ if (!import_node_fs5.default.existsSync(apk)) {
1421
2131
  throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
1422
2132
  }
1423
2133
  }
@@ -1429,7 +2139,7 @@ function looksLikeApk(app) {
1429
2139
  var execFileAaptResolver = async (apkPath) => {
1430
2140
  for (const tool of ["aapt", "aapt2"]) {
1431
2141
  const output = await new Promise((resolve) => {
1432
- (0, import_node_child_process4.execFile)(
2142
+ (0, import_node_child_process5.execFile)(
1433
2143
  tool,
1434
2144
  ["dump", "badging", apkPath],
1435
2145
  { encoding: "utf-8", timeout: 2e4, maxBuffer: 8 * 1024 * 1024 },
@@ -1463,8 +2173,8 @@ async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
1463
2173
  assertAndroidAppAllowed(allowedApps, app);
1464
2174
  return app;
1465
2175
  }
1466
- const apkPath = import_node_path3.default.resolve(app);
1467
- if (!import_node_fs4.default.existsSync(apkPath)) {
2176
+ const apkPath = import_node_path5.default.resolve(app);
2177
+ if (!import_node_fs5.default.existsSync(apkPath)) {
1468
2178
  throw new Error(`APK not found: ${apkPath}`);
1469
2179
  }
1470
2180
  const pkg = await aaptResolver(apkPath);
@@ -1545,43 +2255,25 @@ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
1545
2255
  var WAIT_POLL_INTERVAL_MS2 = 250;
1546
2256
  var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
1547
2257
  var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
1548
- function unquote3(value) {
1549
- const trimmed = value.trim();
1550
- const first = trimmed[0];
1551
- if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
1552
- return trimmed.slice(1, -1);
1553
- }
1554
- return trimmed;
1555
- }
1556
2258
  function escapePredicateArg(value) {
1557
2259
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1558
2260
  }
1559
- function normalizeXcuiClassName(role) {
1560
- return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
2261
+ function toIosQuery(selector) {
2262
+ switch (selector.kind) {
2263
+ case "focused":
2264
+ return { by: "focused" };
2265
+ case "id":
2266
+ return { by: "accessibilityId", value: selector.value };
2267
+ case "role":
2268
+ return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
2269
+ case "label":
2270
+ return { by: "label", value: selector.value };
2271
+ case "text":
2272
+ return { by: "text", value: selector.value };
2273
+ }
1561
2274
  }
1562
2275
  function parseIosSelector(selector) {
1563
- const trimmed = selector.trim();
1564
- if (trimmed === ":focus") {
1565
- return { by: "focused" };
1566
- }
1567
- const idMatch = /^id=(.+)$/s.exec(trimmed);
1568
- if (idMatch) {
1569
- return { by: "accessibilityId", value: unquote3(idMatch[1]) };
1570
- }
1571
- const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
1572
- if (roleMatch) {
1573
- const name = roleMatch[2] !== void 0 ? unquote3(roleMatch[2]) : void 0;
1574
- return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
1575
- }
1576
- const labelMatch = /^label=(.+)$/s.exec(trimmed);
1577
- if (labelMatch) {
1578
- return { by: "label", value: unquote3(labelMatch[1]) };
1579
- }
1580
- const textMatch = /^text=(.+)$/s.exec(trimmed);
1581
- if (textMatch) {
1582
- return { by: "text", value: unquote3(textMatch[1]) };
1583
- }
1584
- return { by: "text", value: trimmed };
2276
+ return toIosQuery(parseNativeSelector(selector));
1585
2277
  }
1586
2278
  function iosQueryToLocator(query) {
1587
2279
  switch (query.by) {
@@ -1613,11 +2305,7 @@ function iosQueryToLocator(query) {
1613
2305
  }
1614
2306
  }
1615
2307
  function unwrapIosTextSelector(selector) {
1616
- const trimmed = selector.trim();
1617
- if (!trimmed.startsWith("text=")) {
1618
- return null;
1619
- }
1620
- return unquote3(trimmed.slice(5));
2308
+ return unwrapNativeTextSelector(selector);
1621
2309
  }
1622
2310
  function delay2(ms) {
1623
2311
  return new Promise((resolve) => {
@@ -1777,20 +2465,34 @@ function createIosDriver(client, options) {
1777
2465
 
1778
2466
  // src/browser/ios-helper.ts
1779
2467
  var import_node_module2 = require("module");
1780
- var import_node_fs5 = __toESM(require("fs"), 1);
1781
- var import_node_os2 = __toESM(require("os"), 1);
1782
- var import_node_path5 = __toESM(require("path"), 1);
2468
+ var import_node_child_process7 = require("child_process");
2469
+ var import_node_fs6 = __toESM(require("fs"), 1);
2470
+ var import_node_os5 = __toESM(require("os"), 1);
2471
+ var import_node_path7 = __toESM(require("path"), 1);
1783
2472
 
1784
2473
  // src/browser/ios-simctl.ts
1785
- var import_node_child_process5 = require("child_process");
2474
+ var import_node_child_process6 = require("child_process");
1786
2475
  var import_promises = require("fs/promises");
1787
2476
  var import_node_net = __toESM(require("net"), 1);
1788
- var import_node_os = __toESM(require("os"), 1);
1789
- var import_node_path4 = __toESM(require("path"), 1);
1790
- var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path4.default.join(import_node_os.default.tmpdir(), "prowl-ios-simulator-locks");
2477
+ var import_node_os4 = __toESM(require("os"), 1);
2478
+ var import_node_path6 = __toESM(require("path"), 1);
2479
+ var spawnXcrunProcess = (args, options) => {
2480
+ const child = (0, import_node_child_process6.spawn)("xcrun", args, {
2481
+ stdio: "ignore",
2482
+ env: options?.env ? { ...process.env, ...options.env } : process.env
2483
+ });
2484
+ child.on("error", () => {
2485
+ });
2486
+ return {
2487
+ kill: () => {
2488
+ child.kill();
2489
+ }
2490
+ };
2491
+ };
2492
+ var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path6.default.join(import_node_os4.default.tmpdir(), "prowl-ios-simulator-locks");
1791
2493
  var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
1792
2494
  var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
1793
- (0, import_node_child_process5.execFile)(
2495
+ (0, import_node_child_process6.execFile)(
1794
2496
  "xcrun",
1795
2497
  args,
1796
2498
  {
@@ -1827,7 +2529,7 @@ function isProcessAlive(pid) {
1827
2529
  }
1828
2530
  async function removeStaleSimulatorLock(lockPath) {
1829
2531
  try {
1830
- const ownerText = await (0, import_promises.readFile)(import_node_path4.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
2532
+ const ownerText = await (0, import_promises.readFile)(import_node_path6.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
1831
2533
  const owner = JSON.parse(ownerText);
1832
2534
  if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
1833
2535
  await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
@@ -1845,7 +2547,7 @@ function simulatorReservedError(udid) {
1845
2547
  }
1846
2548
  async function reserveSimulatorUdid(udid, options = {}) {
1847
2549
  const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
1848
- const lockPath = import_node_path4.default.join(lockRoot, simulatorLockName(udid));
2550
+ const lockPath = import_node_path6.default.join(lockRoot, simulatorLockName(udid));
1849
2551
  await (0, import_promises.mkdir)(lockRoot, { recursive: true });
1850
2552
  for (let attempt = 0; attempt < 2; attempt += 1) {
1851
2553
  try {
@@ -1869,7 +2571,7 @@ async function reserveSimulatorUdid(udid, options = {}) {
1869
2571
  };
1870
2572
  try {
1871
2573
  await (0, import_promises.writeFile)(
1872
- import_node_path4.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
2574
+ import_node_path6.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
1873
2575
  `${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
1874
2576
  `,
1875
2577
  { flag: "wx" }
@@ -2061,15 +2763,15 @@ var WdaTransport = class {
2061
2763
  * {@link WdaHttpError} on a non-2xx response, or a timeout error when the
2062
2764
  * per-request deadline elapses.
2063
2765
  */
2064
- async requestFull(method, path18, body, timeoutMs) {
2766
+ async requestFull(method, path20, body, timeoutMs) {
2065
2767
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
2066
2768
  const controller = new AbortController();
2067
2769
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
2068
2770
  timer.unref?.();
2069
- const url = `${this.baseUrl}${path18}`;
2771
+ const url = `${this.baseUrl}${path20}`;
2070
2772
  const timeoutError = () => {
2071
2773
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
2072
- return new Error(`WebDriverAgent request ${method} ${path18} timed out after ${shown}`);
2774
+ return new Error(`WebDriverAgent request ${method} ${path20} timed out after ${shown}`);
2073
2775
  };
2074
2776
  let response;
2075
2777
  try {
@@ -2101,7 +2803,7 @@ var WdaTransport = class {
2101
2803
  if (!response.ok) {
2102
2804
  const wdError = extractWebdriverError2(parsed);
2103
2805
  throw new WdaHttpError(
2104
- `WebDriverAgent ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2806
+ `WebDriverAgent ${method} ${path20} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2105
2807
  response.status,
2106
2808
  wdError
2107
2809
  );
@@ -2109,8 +2811,8 @@ var WdaTransport = class {
2109
2811
  return parsed;
2110
2812
  }
2111
2813
  /** Like {@link requestFull} but returns just the `value` field. */
2112
- async request(method, path18, body, timeoutMs) {
2113
- const parsed = await this.requestFull(method, path18, body, timeoutMs);
2814
+ async request(method, path20, body, timeoutMs) {
2815
+ const parsed = await this.requestFull(method, path20, body, timeoutMs);
2114
2816
  return parsed?.value;
2115
2817
  }
2116
2818
  };
@@ -2186,8 +2888,8 @@ function sleep2(ms) {
2186
2888
  }
2187
2889
  function createWdaAgentClient(transport, sessionId) {
2188
2890
  const base = `/session/${sessionId}`;
2189
- async function locate(query, path18) {
2190
- return transport.request("POST", `${base}${path18}`, iosQueryToLocator(query));
2891
+ async function locate(query, path20) {
2892
+ return transport.request("POST", `${base}${path20}`, iosQueryToLocator(query));
2191
2893
  }
2192
2894
  return {
2193
2895
  async findElement(query) {
@@ -2223,6 +2925,13 @@ function createWdaAgentClient(transport, sessionId) {
2223
2925
  async homescreen() {
2224
2926
  await transport.request("POST", "/wda/homescreen", {});
2225
2927
  },
2928
+ async source() {
2929
+ const value = await transport.request("GET", "/source");
2930
+ if (typeof value !== "string") {
2931
+ throw new Error("WebDriverAgent /source did not return XML text; cannot analyze iOS UI hierarchy");
2932
+ }
2933
+ return value;
2934
+ },
2226
2935
  async close() {
2227
2936
  await transport.request("DELETE", base).catch(() => void 0);
2228
2937
  }
@@ -2232,8 +2941,8 @@ function createWdaAgentClient(transport, sessionId) {
2232
2941
  // src/browser/ios-helper.ts
2233
2942
  var import_meta3 = {};
2234
2943
  var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
2235
- var WDA_RUNNER_APP_NAME = "WebDriverAgentRunner-Runner.app";
2236
2944
  var WDA_USE_PORT_ENV = "USE_PORT";
2945
+ var PREPARED_XCTESTRUN_PREFIX = "prowl-wda-xctestrun-";
2237
2946
  var WDA_STARTUP_ATTEMPTS = 2;
2238
2947
  var defaultIosAgentConnector = async ({
2239
2948
  host,
@@ -2256,32 +2965,63 @@ function resolveWdaProject(requireFn = (0, import_node_module2.createRequire)(im
2256
2965
  "The iOS target requires the `appium-webdriveragent` package (its WDA Xcode project). It is an optional dependency of prowl-tools; it may have been skipped (--omit=optional) or failed to install. Restore it for a global Prowl install with: npm install -g appium-webdriveragent@16.4.0. If Prowl is installed locally in a project, run: npm install appium-webdriveragent@16.4.0"
2257
2966
  );
2258
2967
  }
2259
- const pkgDir = import_node_path5.default.dirname(pkgJsonPath);
2968
+ const pkgDir = import_node_path7.default.dirname(pkgJsonPath);
2260
2969
  const version = requireFn(pkgJsonPath).version;
2261
- const projectPath = import_node_path5.default.join(pkgDir, "WebDriverAgent.xcodeproj");
2262
- if (!import_node_fs5.default.existsSync(projectPath)) {
2970
+ const projectPath = import_node_path7.default.join(pkgDir, "WebDriverAgent.xcodeproj");
2971
+ if (!import_node_fs6.default.existsSync(projectPath)) {
2263
2972
  throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
2264
2973
  }
2265
2974
  return { projectPath, version };
2266
2975
  }
2267
- function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os2.default.homedir()) {
2268
- return import_node_path5.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
2976
+ function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os5.default.homedir()) {
2977
+ return import_node_path7.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
2978
+ }
2979
+ function productsDir(derivedDataPath) {
2980
+ return import_node_path7.default.join(derivedDataPath, "Build", "Products");
2981
+ }
2982
+ function findXctestrunIn(dir) {
2983
+ let entries;
2984
+ try {
2985
+ entries = import_node_fs6.default.readdirSync(dir);
2986
+ } catch {
2987
+ return null;
2988
+ }
2989
+ const match = entries.filter((name) => name.endsWith(".xctestrun") && !name.startsWith(PREPARED_XCTESTRUN_PREFIX)).sort()[0];
2990
+ return match ? import_node_path7.default.join(dir, match) : null;
2269
2991
  }
2270
- function runnerAppPath(derivedDataPath) {
2271
- return import_node_path5.default.join(derivedDataPath, "Build", "Products", "Debug-iphonesimulator", WDA_RUNNER_APP_NAME);
2992
+ function resolveOverrideXctestrun(override) {
2993
+ if (!import_node_fs6.default.existsSync(override)) {
2994
+ throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
2995
+ }
2996
+ if (override.endsWith(".xctestrun")) {
2997
+ return override;
2998
+ }
2999
+ const candidates = [];
3000
+ const stat = import_node_fs6.default.statSync(override);
3001
+ if (override.endsWith(".app")) {
3002
+ candidates.push(import_node_path7.default.dirname(import_node_path7.default.dirname(override)));
3003
+ } else if (stat.isDirectory()) {
3004
+ candidates.push(override, productsDir(override));
3005
+ }
3006
+ for (const dir of candidates) {
3007
+ const found = findXctestrunIn(dir);
3008
+ if (found) {
3009
+ return found;
3010
+ }
3011
+ }
3012
+ throw new Error(
3013
+ `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\`.`
3014
+ );
2272
3015
  }
2273
- async function resolveWdaRunner(options = {}) {
3016
+ async function resolveWdaTestRun(options = {}) {
2274
3017
  const runner = options.runner ?? execFileXcrunRunner;
2275
3018
  const env = options.env ?? process.env;
2276
- const homeDir = options.homeDir ?? import_node_os2.default.homedir();
3019
+ const homeDir = options.homeDir ?? import_node_os5.default.homedir();
2277
3020
  const log = options.logger ?? ((message) => process.stderr.write(`${message}
2278
3021
  `));
2279
3022
  const override = env.PROWL_WDA_RUNNER;
2280
3023
  if (override) {
2281
- if (!import_node_fs5.default.existsSync(override)) {
2282
- throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
2283
- }
2284
- return override;
3024
+ return resolveOverrideXctestrun(override);
2285
3025
  }
2286
3026
  const { projectPath, version } = resolveWdaProject(options.requireFn);
2287
3027
  const xcode = await xcodeVersion(runner);
@@ -2291,14 +3031,14 @@ async function resolveWdaRunner(options = {}) {
2291
3031
  );
2292
3032
  }
2293
3033
  const cacheDir = wdaCacheDir(version, xcode, homeDir);
2294
- const cachedRunner = runnerAppPath(cacheDir);
2295
- if (import_node_fs5.default.existsSync(cachedRunner)) {
2296
- return cachedRunner;
3034
+ const cached = findXctestrunIn(productsDir(cacheDir));
3035
+ if (cached) {
3036
+ return cached;
2297
3037
  }
2298
3038
  log(
2299
3039
  `Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
2300
3040
  );
2301
- import_node_fs5.default.mkdirSync(cacheDir, { recursive: true });
3041
+ import_node_fs6.default.mkdirSync(cacheDir, { recursive: true });
2302
3042
  const result = await runner(
2303
3043
  [
2304
3044
  "xcodebuild",
@@ -2320,12 +3060,106 @@ async function resolveWdaRunner(options = {}) {
2320
3060
  `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)}`
2321
3061
  );
2322
3062
  }
2323
- if (!import_node_fs5.default.existsSync(cachedRunner)) {
3063
+ const built = findXctestrunIn(productsDir(cacheDir));
3064
+ if (!built) {
3065
+ throw new Error(
3066
+ `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.`
3067
+ );
3068
+ }
3069
+ return built;
3070
+ }
3071
+ function injectUsePortIntoXctestrun(plist, port) {
3072
+ const clone = structuredClone(plist);
3073
+ let injected = 0;
3074
+ const visit = (node) => {
3075
+ if (Array.isArray(node)) {
3076
+ for (const entry of node) {
3077
+ visit(entry);
3078
+ }
3079
+ return;
3080
+ }
3081
+ if (!node || typeof node !== "object") {
3082
+ return;
3083
+ }
3084
+ const obj = node;
3085
+ const isTarget = typeof obj.TestBundlePath === "string" || typeof obj.TestHostPath === "string";
3086
+ const existingEnv = obj.EnvironmentVariables && typeof obj.EnvironmentVariables === "object" && !Array.isArray(obj.EnvironmentVariables) ? obj.EnvironmentVariables : void 0;
3087
+ if (isTarget || existingEnv) {
3088
+ const env = existingEnv ?? {};
3089
+ env[WDA_USE_PORT_ENV] = String(port);
3090
+ obj.EnvironmentVariables = env;
3091
+ injected += 1;
3092
+ }
3093
+ for (const value of Object.values(obj)) {
3094
+ visit(value);
3095
+ }
3096
+ };
3097
+ visit(clone);
3098
+ if (injected === 0) {
2324
3099
  throw new Error(
2325
- `WebDriverAgent build succeeded but the runner app was not found at ${cachedRunner}. This may indicate an Xcode layout change; set PROWL_WDA_RUNNER to a prebuilt runner.`
3100
+ "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."
3101
+ );
3102
+ }
3103
+ return clone;
3104
+ }
3105
+ function runPlutil(args) {
3106
+ return new Promise((resolve, reject) => {
3107
+ (0, import_node_child_process7.execFile)(
3108
+ "plutil",
3109
+ args,
3110
+ { encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 },
3111
+ (error, stdout, stderr) => {
3112
+ if (error) {
3113
+ reject(
3114
+ new Error(
3115
+ `\`plutil ${args.join(" ")}\` failed: ${(stderr || error.message).slice(0, 400)}`
3116
+ )
3117
+ );
3118
+ return;
3119
+ }
3120
+ resolve(stdout ?? "");
3121
+ }
2326
3122
  );
3123
+ });
3124
+ }
3125
+ var defaultWdaTestRunPreparer = async ({ xctestrunPath, port }) => {
3126
+ const json = await runPlutil(["-convert", "json", "-o", "-", xctestrunPath]);
3127
+ let parsed;
3128
+ try {
3129
+ parsed = JSON.parse(json);
3130
+ } catch {
3131
+ throw new Error(`Could not parse the WebDriverAgent .xctestrun as JSON: ${xctestrunPath}`);
3132
+ }
3133
+ const injected = injectUsePortIntoXctestrun(parsed, port);
3134
+ const stem = `${PREPARED_XCTESTRUN_PREFIX}${port}-${process.pid}`;
3135
+ const outPath = import_node_path7.default.join(import_node_path7.default.dirname(xctestrunPath), `${stem}.xctestrun`);
3136
+ const jsonPath = import_node_path7.default.join(import_node_os5.default.tmpdir(), `${stem}.json`);
3137
+ import_node_fs6.default.writeFileSync(jsonPath, JSON.stringify(injected));
3138
+ try {
3139
+ await runPlutil(["-convert", "xml1", jsonPath, "-o", outPath]);
3140
+ } finally {
3141
+ import_node_fs6.default.rmSync(jsonPath, { force: true });
3142
+ }
3143
+ return outPath;
3144
+ };
3145
+ function cleanupPreparedTestRun(preparedPath) {
3146
+ if (!preparedPath) {
3147
+ return;
2327
3148
  }
2328
- return cachedRunner;
3149
+ if (!import_node_path7.default.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
3150
+ return;
3151
+ }
3152
+ import_node_fs6.default.rmSync(preparedPath, { force: true });
3153
+ }
3154
+ function wdaTestRunArgs(xctestrunPath, udid) {
3155
+ return [
3156
+ "xcodebuild",
3157
+ "test-without-building",
3158
+ "-xctestrun",
3159
+ xctestrunPath,
3160
+ "-destination",
3161
+ `id=${udid}`
3162
+ ];
2329
3163
  }
2330
3164
  async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
2331
3165
  if (!looksLikeIosAppPath(app)) {
@@ -2337,8 +3171,8 @@ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
2337
3171
  }
2338
3172
  return app;
2339
3173
  }
2340
- const appPath = import_node_path5.default.resolve(app);
2341
- if (!import_node_fs5.default.existsSync(appPath)) {
3174
+ const appPath = import_node_path7.default.resolve(app);
3175
+ if (!import_node_fs6.default.existsSync(appPath)) {
2342
3176
  throw new Error(`.app bundle not found: ${appPath}`);
2343
3177
  }
2344
3178
  const bundleId = readIosBundleIdentifier(appPath);
@@ -2356,6 +3190,8 @@ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
2356
3190
  }
2357
3191
  async function launchIosSession(options) {
2358
3192
  const runner = options.runner ?? execFileXcrunRunner;
3193
+ const spawner = options.spawner ?? spawnXcrunProcess;
3194
+ const preparer = options.testRunPreparer ?? defaultWdaTestRunPreparer;
2359
3195
  const portAllocator = options.portAllocator ?? findFreePort;
2360
3196
  const connector = options.agentConnector ?? defaultIosAgentConnector;
2361
3197
  const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
@@ -2379,14 +3215,14 @@ async function launchIosSession(options) {
2379
3215
  options.coldStart ?? false,
2380
3216
  options.allowedApps ?? []
2381
3217
  );
2382
- const wdaRunnerApp = options.wdaRunnerApp ?? await resolveWdaRunner({ runner, logger: options.logger });
2383
- await installApp(runner, udid, wdaRunnerApp);
3218
+ const baseTestRun = options.wdaTestRun ?? await resolveWdaTestRun({ runner, logger: options.logger });
2384
3219
  for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
2385
3220
  let client;
3221
+ let wdaProcess;
3222
+ let preparedTestRun;
2386
3223
  let tornDown = false;
2387
- let terminateWda = false;
2388
3224
  let terminateTarget = false;
2389
- let stage = "port";
3225
+ let stage = "prepare";
2390
3226
  const teardownAttempt = async () => {
2391
3227
  if (tornDown) {
2392
3228
  return;
@@ -2395,18 +3231,18 @@ async function launchIosSession(options) {
2395
3231
  if (client) {
2396
3232
  await client.close().catch(() => void 0);
2397
3233
  }
2398
- if (terminateWda) {
2399
- await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
2400
- }
3234
+ wdaProcess?.kill();
3235
+ await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
2401
3236
  if (terminateTarget) {
2402
3237
  await terminateApp(runner, udid, bundleId);
2403
3238
  }
3239
+ cleanupPreparedTestRun(preparedTestRun);
2404
3240
  };
2405
3241
  try {
2406
3242
  const port = await portAllocator();
3243
+ preparedTestRun = await preparer({ xctestrunPath: baseTestRun, port });
2407
3244
  stage = "wda-launch";
2408
- terminateWda = true;
2409
- await launchApp(runner, udid, WDA_RUNNER_BUNDLE_ID, { [WDA_USE_PORT_ENV]: String(port) });
3245
+ wdaProcess = spawner(wdaTestRunArgs(preparedTestRun, udid));
2410
3246
  stage = "target-launch";
2411
3247
  terminateTarget = true;
2412
3248
  await launchApp(runner, udid, bundleId);
@@ -2439,12 +3275,12 @@ async function closeIosSession(session) {
2439
3275
  }
2440
3276
 
2441
3277
  // src/runner/index.ts
2442
- var import_node_fs14 = __toESM(require("fs"), 1);
2443
- var import_node_path14 = __toESM(require("path"), 1);
3278
+ var import_node_fs15 = __toESM(require("fs"), 1);
3279
+ var import_node_path16 = __toESM(require("path"), 1);
2444
3280
 
2445
3281
  // src/config/loader.ts
2446
- var import_node_fs6 = __toESM(require("fs"), 1);
2447
- var import_node_path6 = __toESM(require("path"), 1);
3282
+ var import_node_fs7 = __toESM(require("fs"), 1);
3283
+ var import_node_path8 = __toESM(require("path"), 1);
2448
3284
  var import_yaml = __toESM(require("yaml"), 1);
2449
3285
  var import_dotenv = __toESM(require("dotenv"), 1);
2450
3286
 
@@ -2462,7 +3298,7 @@ function isValidHuntName(name) {
2462
3298
  function assertValidHuntName(name) {
2463
3299
  if (!isValidHuntName(name)) {
2464
3300
  throw new Error(
2465
- `Invalid hunt name: "${name}". Use only letters, numbers, hyphens, underscores, and forward slashes.`
3301
+ `Invalid hunt name: "${name}". A hunt name is its file name under .prowl/hunts/ (letters, numbers, hyphens, underscores, and forward slashes for nested hunts). Accepted forms: a bare name (homepage), a nested name (admin/users), or a path (.prowl/hunts/homepage.yml or hunts/homepage.yml).`
2466
3302
  );
2467
3303
  }
2468
3304
  }
@@ -2713,6 +3549,9 @@ var assertScreenshotStepSchema = import_zod.z.object({
2713
3549
  threshold: import_zod.z.number().min(0).max(1).optional()
2714
3550
  }).strict()
2715
3551
  }).strict();
3552
+ var assertWithAiStepSchema = import_zod.z.object({
3553
+ assertWithAI: import_zod.z.string().min(1)
3554
+ }).strict();
2716
3555
  var copyTextStepSchema = import_zod.z.object({
2717
3556
  copyText: import_zod.z.object({
2718
3557
  selector: import_zod.z.string().min(1),
@@ -2755,6 +3594,7 @@ var stepSchema = import_zod.z.union([
2755
3594
  evalScriptStepSchema,
2756
3595
  runScriptStepSchema,
2757
3596
  assertScreenshotStepSchema,
3597
+ assertWithAiStepSchema,
2758
3598
  copyTextStepSchema,
2759
3599
  waitForDownloadStepSchema
2760
3600
  ]);
@@ -2835,12 +3675,12 @@ function findConfigPath(startDir) {
2835
3675
  let current = startDir;
2836
3676
  while (current) {
2837
3677
  for (const dir of [CONFIG_DIR, LEGACY_CONFIG_DIR]) {
2838
- const candidate = import_node_path6.default.join(current, dir, "config.yml");
2839
- if (import_node_fs6.default.existsSync(candidate)) {
3678
+ const candidate = import_node_path8.default.join(current, dir, "config.yml");
3679
+ if (import_node_fs7.default.existsSync(candidate)) {
2840
3680
  return candidate;
2841
3681
  }
2842
3682
  }
2843
- const parent = import_node_path6.default.dirname(current);
3683
+ const parent = import_node_path8.default.dirname(current);
2844
3684
  if (parent === current) {
2845
3685
  break;
2846
3686
  }
@@ -2947,19 +3787,19 @@ function ensureAllowedDomain(allowed, urlValue) {
2947
3787
  return allowed;
2948
3788
  }
2949
3789
  function loadConfig(configPath) {
2950
- const resolvedPath = configPath ? import_node_path6.default.resolve(configPath) : findConfigPath(process.cwd());
3790
+ const resolvedPath = configPath ? import_node_path8.default.resolve(configPath) : findConfigPath(process.cwd());
2951
3791
  if (!resolvedPath) {
2952
3792
  throw new Error("Could not find .prowl/config.yml. Run `prowl init` first.");
2953
3793
  }
2954
- if (!import_node_fs6.default.existsSync(resolvedPath)) {
3794
+ if (!import_node_fs7.default.existsSync(resolvedPath)) {
2955
3795
  throw new Error(`Config file not found at ${resolvedPath}`);
2956
3796
  }
2957
- const configDir = import_node_path6.default.dirname(resolvedPath);
2958
- if (import_node_path6.default.basename(configDir) === LEGACY_CONFIG_DIR) {
3797
+ const configDir = import_node_path8.default.dirname(resolvedPath);
3798
+ if (import_node_path8.default.basename(configDir) === LEGACY_CONFIG_DIR) {
2959
3799
  warnLegacyConfigDir();
2960
3800
  }
2961
- import_dotenv.default.config({ path: import_node_path6.default.join(configDir, ".env"), override: false });
2962
- const raw = import_node_fs6.default.readFileSync(resolvedPath, "utf-8");
3801
+ import_dotenv.default.config({ path: import_node_path8.default.join(configDir, ".env"), override: false });
3802
+ const raw = import_node_fs7.default.readFileSync(resolvedPath, "utf-8");
2963
3803
  const parsed = import_yaml.default.parse(raw) ?? {};
2964
3804
  const validated = configSchema.parse(parsed);
2965
3805
  const config = mergeConfig(validated);
@@ -2973,32 +3813,32 @@ function loadConfig(configPath) {
2973
3813
  }
2974
3814
  function loadHunt(huntName, configDir) {
2975
3815
  assertValidHuntName(huntName);
2976
- const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
2977
- if (!import_node_fs6.default.existsSync(huntPath)) {
3816
+ const huntPath = import_node_path8.default.join(configDir, "hunts", `${huntName}.yml`);
3817
+ if (!import_node_fs7.default.existsSync(huntPath)) {
2978
3818
  throw new Error(`Hunt file not found: ${huntPath}`);
2979
3819
  }
2980
- const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
3820
+ const raw = import_node_fs7.default.readFileSync(huntPath, "utf-8");
2981
3821
  const parsed = import_yaml.default.parse(raw) ?? {};
2982
3822
  const validated = huntSchema.parse(parsed);
2983
3823
  return validated;
2984
3824
  }
2985
3825
  function loadHuntTags(huntName, configDir) {
2986
3826
  assertValidHuntName(huntName);
2987
- const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
2988
- if (!import_node_fs6.default.existsSync(huntPath)) {
3827
+ const huntPath = import_node_path8.default.join(configDir, "hunts", `${huntName}.yml`);
3828
+ if (!import_node_fs7.default.existsSync(huntPath)) {
2989
3829
  return [];
2990
3830
  }
2991
- const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
3831
+ const raw = import_node_fs7.default.readFileSync(huntPath, "utf-8");
2992
3832
  const parsed = import_yaml.default.parse(raw) ?? {};
2993
3833
  return Array.isArray(parsed.tags) ? parsed.tags : [];
2994
3834
  }
2995
3835
  function loadHuntMeta(huntName, configDir) {
2996
3836
  assertValidHuntName(huntName);
2997
- const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
2998
- if (!import_node_fs6.default.existsSync(huntPath)) {
3837
+ const huntPath = import_node_path8.default.join(configDir, "hunts", `${huntName}.yml`);
3838
+ if (!import_node_fs7.default.existsSync(huntPath)) {
2999
3839
  return { tags: [] };
3000
3840
  }
3001
- const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
3841
+ const raw = import_node_fs7.default.readFileSync(huntPath, "utf-8");
3002
3842
  const parsed = import_yaml.default.parse(raw) ?? {};
3003
3843
  return {
3004
3844
  description: typeof parsed.description === "string" ? parsed.description : void 0,
@@ -3006,24 +3846,24 @@ function loadHuntMeta(huntName, configDir) {
3006
3846
  };
3007
3847
  }
3008
3848
  function listHunts(configDir) {
3009
- const huntsDir = import_node_path6.default.join(configDir, "hunts");
3010
- if (!import_node_fs6.default.existsSync(huntsDir)) {
3849
+ const huntsDir = import_node_path8.default.join(configDir, "hunts");
3850
+ if (!import_node_fs7.default.existsSync(huntsDir)) {
3011
3851
  return [];
3012
3852
  }
3013
- const stats = import_node_fs6.default.statSync(huntsDir);
3853
+ const stats = import_node_fs7.default.statSync(huntsDir);
3014
3854
  if (!stats.isDirectory()) {
3015
3855
  throw new Error(`Hunts path is not a directory: ${huntsDir}`);
3016
3856
  }
3017
3857
  const results = [];
3018
3858
  function scanDir(dir) {
3019
- const entries = import_node_fs6.default.readdirSync(dir, { withFileTypes: true });
3859
+ const entries = import_node_fs7.default.readdirSync(dir, { withFileTypes: true });
3020
3860
  for (const entry of entries) {
3021
3861
  if (entry.isFile() && entry.name.endsWith(".yml")) {
3022
- const fullPath = import_node_path6.default.join(dir, entry.name);
3023
- const relative = import_node_path6.default.relative(huntsDir, fullPath);
3862
+ const fullPath = import_node_path8.default.join(dir, entry.name);
3863
+ const relative = import_node_path8.default.relative(huntsDir, fullPath);
3024
3864
  results.push(relative.replace(/\.yml$/, ""));
3025
3865
  } else if (entry.isDirectory()) {
3026
- scanDir(import_node_path6.default.join(dir, entry.name));
3866
+ scanDir(import_node_path8.default.join(dir, entry.name));
3027
3867
  }
3028
3868
  }
3029
3869
  }
@@ -3032,7 +3872,7 @@ function listHunts(configDir) {
3032
3872
  }
3033
3873
 
3034
3874
  // src/config/interpolate.ts
3035
- var import_node_crypto = __toESM(require("crypto"), 1);
3875
+ var import_node_crypto2 = __toESM(require("crypto"), 1);
3036
3876
  var VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
3037
3877
  function collectInterpolatedValues(input, vars, values) {
3038
3878
  if (typeof input === "string") {
@@ -3071,8 +3911,8 @@ var RANDOM_FIRST_NAMES = ["Alex", "Jordan", "Morgan", "Taylor", "Casey", "Riley"
3071
3911
  var RANDOM_LAST_NAMES = ["Smith", "Johnson", "Brown", "Davis", "Wilson", "Clark", "Hall", "Young"];
3072
3912
  function generateRandomVars(randomSource) {
3073
3913
  const random = randomSource?.random ?? Math.random;
3074
- const randomBytes = randomSource?.randomBytes ?? import_node_crypto.default.randomBytes;
3075
- const randomUUID = randomSource?.randomUUID ?? import_node_crypto.default.randomUUID;
3914
+ const randomBytes = randomSource?.randomBytes ?? import_node_crypto2.default.randomBytes;
3915
+ const randomUUID = randomSource?.randomUUID ?? import_node_crypto2.default.randomUUID;
3076
3916
  const hex = randomBytes(4).toString("hex");
3077
3917
  const firstIndex = Math.floor(random() * RANDOM_FIRST_NAMES.length);
3078
3918
  const lastIndex = Math.floor(random() * RANDOM_LAST_NAMES.length);
@@ -3325,6 +4165,11 @@ function interpolateStep(step, vars, stepPath2, redacted) {
3325
4165
  }
3326
4166
  };
3327
4167
  }
4168
+ if ("assertWithAI" in step) {
4169
+ return {
4170
+ assertWithAI: interpolateString(step.assertWithAI, vars).value
4171
+ };
4172
+ }
3328
4173
  if ("copyText" in step) {
3329
4174
  return {
3330
4175
  copyText: {
@@ -3398,8 +4243,8 @@ function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
3398
4243
  }
3399
4244
 
3400
4245
  // src/browser/playwright-driver.ts
3401
- var import_node_fs7 = __toESM(require("fs"), 1);
3402
- var import_node_path7 = __toESM(require("path"), 1);
4246
+ var import_node_fs8 = __toESM(require("fs"), 1);
4247
+ var import_node_path9 = __toESM(require("path"), 1);
3403
4248
  var import_playwright = require("playwright");
3404
4249
  var ENGINES = { chromium: import_playwright.chromium, firefox: import_playwright.firefox, webkit: import_playwright.webkit };
3405
4250
  async function launchBrowser(options) {
@@ -3421,14 +4266,14 @@ async function launchBrowser(options) {
3421
4266
  contextOptions.viewport = options.viewport;
3422
4267
  }
3423
4268
  if (options.storageStatePath) {
3424
- if (import_node_fs7.default.existsSync(options.storageStatePath)) {
4269
+ if (import_node_fs8.default.existsSync(options.storageStatePath)) {
3425
4270
  contextOptions.storageState = options.storageStatePath;
3426
4271
  } else {
3427
4272
  console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
3428
4273
  }
3429
4274
  }
3430
4275
  if (options.recordHar) {
3431
- contextOptions.recordHar = { path: import_node_path7.default.join(options.runDir, "network.har") };
4276
+ contextOptions.recordHar = { path: import_node_path9.default.join(options.runDir, "network.har") };
3432
4277
  }
3433
4278
  const context = await browser.newContext(contextOptions);
3434
4279
  const page = await context.newPage();
@@ -3436,7 +4281,7 @@ async function launchBrowser(options) {
3436
4281
  page.setDefaultNavigationTimeout(options.timeout);
3437
4282
  let tracePath;
3438
4283
  if (options.trace) {
3439
- tracePath = import_node_path7.default.join(options.runDir, "trace.zip");
4284
+ tracePath = import_node_path9.default.join(options.runDir, "trace.zip");
3440
4285
  await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
3441
4286
  }
3442
4287
  return { browser, context, page, tracePath };
@@ -3611,8 +4456,8 @@ function createPlaywrightDriver(page) {
3611
4456
  }
3612
4457
 
3613
4458
  // src/runner/steps.ts
3614
- var import_node_fs9 = __toESM(require("fs"), 1);
3615
- var import_node_path9 = __toESM(require("path"), 1);
4459
+ var import_node_fs10 = __toESM(require("fs"), 1);
4460
+ var import_node_path11 = __toESM(require("path"), 1);
3616
4461
 
3617
4462
  // src/runner/healing.ts
3618
4463
  var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
@@ -3756,14 +4601,254 @@ function createRunPolicy(driver, options) {
3756
4601
  return { selector: healed.selector, healedFrom: healed.healedFrom };
3757
4602
  }
3758
4603
  return {
3759
- assertWithinMaxSteps,
3760
- ensureUrlAllowed,
3761
- ensureAppAllowed,
3762
- ensureLocationAllowed,
3763
- assertAllowedSelector,
3764
- resolveActionSelector
4604
+ assertWithinMaxSteps,
4605
+ ensureUrlAllowed,
4606
+ ensureAppAllowed,
4607
+ ensureLocationAllowed,
4608
+ assertAllowedSelector,
4609
+ resolveActionSelector
4610
+ };
4611
+ }
4612
+
4613
+ // src/generator/ai.ts
4614
+ var DEFAULT_BASE_URL = {
4615
+ anthropic: "https://api.anthropic.com",
4616
+ openai: "https://api.openai.com"
4617
+ };
4618
+ var AI_REQUEST_TIMEOUT_MS = 3e4;
4619
+ function apiRoot(config) {
4620
+ return config.baseUrl ?? DEFAULT_BASE_URL[config.provider];
4621
+ }
4622
+ function defaultModelFor(provider) {
4623
+ return provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
4624
+ }
4625
+ function resolveAiEnv() {
4626
+ const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
4627
+ if (provider !== "anthropic" && provider !== "openai") {
4628
+ throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
4629
+ }
4630
+ const model = process.env.PROWL_AI_MODEL ?? defaultModelFor(provider);
4631
+ const baseUrl = normalizeBaseUrl(process.env.PROWL_AI_BASE_URL) ?? DEFAULT_BASE_URL[provider];
4632
+ return { provider, model, baseUrl, apiKey: process.env.PROWL_AI_KEY };
4633
+ }
4634
+ function normalizeBaseUrl(value) {
4635
+ if (value === void 0) return void 0;
4636
+ const trimmed = value.trim().replace(/\/+$/, "");
4637
+ return trimmed.length > 0 ? trimmed : void 0;
4638
+ }
4639
+ function resolveAiConfig() {
4640
+ const env = resolveAiEnv();
4641
+ if (!env.apiKey) {
4642
+ throw new Error(
4643
+ "PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
4644
+ );
4645
+ }
4646
+ return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
4647
+ }
4648
+ function tryResolveAiConfig() {
4649
+ const env = resolveAiEnv();
4650
+ if (!env.apiKey) {
4651
+ return null;
4652
+ }
4653
+ return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
4654
+ }
4655
+ async function generateWithAi(prompt, config) {
4656
+ if (config.provider === "anthropic") {
4657
+ return generateWithAnthropic(prompt, config);
4658
+ }
4659
+ return generateWithOpenAi(prompt, config);
4660
+ }
4661
+ async function generateWithAnthropic(prompt, config) {
4662
+ const data = await postJson(
4663
+ "Anthropic",
4664
+ `${apiRoot(config)}/v1/messages`,
4665
+ anthropicHeaders(config),
4666
+ {
4667
+ model: config.model,
4668
+ max_tokens: 4096,
4669
+ messages: [
4670
+ { role: "user", content: prompt }
4671
+ ]
4672
+ }
4673
+ );
4674
+ return extractAnthropicText(data);
4675
+ }
4676
+ async function generateWithOpenAi(prompt, config) {
4677
+ const data = await postJson(
4678
+ "OpenAI",
4679
+ `${apiRoot(config)}/v1/chat/completions`,
4680
+ openAiHeaders(config),
4681
+ {
4682
+ model: config.model,
4683
+ messages: [
4684
+ { role: "user", content: prompt }
4685
+ ],
4686
+ max_tokens: 4096
4687
+ }
4688
+ );
4689
+ return extractOpenAiText(data);
4690
+ }
4691
+ function buildVisionPrompt(assertion) {
4692
+ return [
4693
+ "You are a meticulous QA reviewer. You are given a screenshot of an application",
4694
+ "and a single assertion describing what should be true about it.",
4695
+ "",
4696
+ "Assertion:",
4697
+ assertion,
4698
+ "",
4699
+ "Decide whether the assertion holds for the screenshot. Judge ONLY what is",
4700
+ "visible; do not assume behavior you cannot see. Be strict: if the assertion",
4701
+ "is not clearly satisfied, it fails.",
4702
+ "",
4703
+ "Reply with ONLY a single JSON object on one line, no markdown, no code fences:",
4704
+ '{"pass": <true|false>, "reason": "<one concise sentence explaining the verdict>"}'
4705
+ ].join("\n");
4706
+ }
4707
+ function parseVisionVerdict(raw) {
4708
+ const cleaned = stripCodeFences(raw).trim();
4709
+ const candidate = extractJsonObject(cleaned);
4710
+ if (candidate === null) {
4711
+ throw new Error(
4712
+ `Could not parse a JSON verdict from the AI response: ${truncateForError(raw)}`
4713
+ );
4714
+ }
4715
+ let parsed;
4716
+ try {
4717
+ parsed = JSON.parse(candidate);
4718
+ } catch {
4719
+ throw new Error(
4720
+ `AI verdict was not valid JSON: ${truncateForError(raw)}`
4721
+ );
4722
+ }
4723
+ if (typeof parsed !== "object" || parsed === null) {
4724
+ throw new Error(`AI verdict was not a JSON object: ${truncateForError(raw)}`);
4725
+ }
4726
+ const record = parsed;
4727
+ if (typeof record.pass !== "boolean") {
4728
+ throw new Error(
4729
+ `AI verdict is missing a boolean "pass" field: ${truncateForError(raw)}`
4730
+ );
4731
+ }
4732
+ const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : record.pass ? "Assertion satisfied." : "Assertion not satisfied.";
4733
+ return { pass: record.pass, reason };
4734
+ }
4735
+ function stripCodeFences(text) {
4736
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
4737
+ return fence ? fence[1] : text;
4738
+ }
4739
+ function extractJsonObject(text) {
4740
+ const trimmed = text.trim();
4741
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
4742
+ return trimmed;
4743
+ }
4744
+ const first = trimmed.indexOf("{");
4745
+ const last = trimmed.lastIndexOf("}");
4746
+ if (first === -1 || last === -1 || last <= first) {
4747
+ return null;
4748
+ }
4749
+ return trimmed.slice(first, last + 1);
4750
+ }
4751
+ function truncateForError(text) {
4752
+ const collapsed = text.replace(/\s+/g, " ").trim();
4753
+ return collapsed.length > 200 ? `${collapsed.slice(0, 199)}\u2026` : collapsed;
4754
+ }
4755
+ async function assertWithAiVision(input, config) {
4756
+ const raw = config.provider === "anthropic" ? await visionWithAnthropic(input, config) : await visionWithOpenAi(input, config);
4757
+ return parseVisionVerdict(raw);
4758
+ }
4759
+ async function visionWithAnthropic(input, config) {
4760
+ const data = await postJson(
4761
+ "Anthropic",
4762
+ `${apiRoot(config)}/v1/messages`,
4763
+ anthropicHeaders(config),
4764
+ {
4765
+ model: config.model,
4766
+ max_tokens: 1024,
4767
+ temperature: 0,
4768
+ messages: [
4769
+ {
4770
+ role: "user",
4771
+ content: [
4772
+ {
4773
+ type: "image",
4774
+ source: {
4775
+ type: "base64",
4776
+ media_type: input.mediaType,
4777
+ data: input.imageBase64
4778
+ }
4779
+ },
4780
+ { type: "text", text: buildVisionPrompt(input.assertion) }
4781
+ ]
4782
+ }
4783
+ ]
4784
+ }
4785
+ );
4786
+ return extractAnthropicText(data);
4787
+ }
4788
+ async function visionWithOpenAi(input, config) {
4789
+ const data = await postJson(
4790
+ "OpenAI",
4791
+ `${apiRoot(config)}/v1/chat/completions`,
4792
+ openAiHeaders(config),
4793
+ {
4794
+ model: config.model,
4795
+ max_tokens: 1024,
4796
+ temperature: 0,
4797
+ messages: [
4798
+ {
4799
+ role: "user",
4800
+ content: [
4801
+ { type: "text", text: buildVisionPrompt(input.assertion) },
4802
+ {
4803
+ type: "image_url",
4804
+ image_url: { url: `data:${input.mediaType};base64,${input.imageBase64}` }
4805
+ }
4806
+ ]
4807
+ }
4808
+ ]
4809
+ }
4810
+ );
4811
+ return extractOpenAiText(data);
4812
+ }
4813
+ async function postJson(provider, url, headers, body) {
4814
+ const response = await fetch(url, {
4815
+ method: "POST",
4816
+ headers,
4817
+ body: JSON.stringify(body),
4818
+ signal: AbortSignal.timeout(AI_REQUEST_TIMEOUT_MS)
4819
+ });
4820
+ if (!response.ok) {
4821
+ const responseBody = await response.text();
4822
+ throw new Error(`${provider} API error (${response.status}): ${responseBody}`);
4823
+ }
4824
+ return await response.json();
4825
+ }
4826
+ function anthropicHeaders(config) {
4827
+ return {
4828
+ "Content-Type": "application/json",
4829
+ "x-api-key": config.apiKey,
4830
+ "anthropic-version": "2023-06-01"
4831
+ };
4832
+ }
4833
+ function openAiHeaders(config) {
4834
+ return {
4835
+ "Content-Type": "application/json",
4836
+ "Authorization": `Bearer ${config.apiKey}`
3765
4837
  };
3766
4838
  }
4839
+ function extractAnthropicText(data) {
4840
+ const textBlock = data.content?.find((c) => c.type === "text");
4841
+ if (!textBlock?.text) {
4842
+ throw new Error("Anthropic API returned no text content");
4843
+ }
4844
+ return textBlock.text;
4845
+ }
4846
+ function extractOpenAiText(data) {
4847
+ if (!data.choices?.[0]?.message?.content) {
4848
+ throw new Error("OpenAI API returned no content");
4849
+ }
4850
+ return data.choices[0].message.content;
4851
+ }
3767
4852
 
3768
4853
  // src/runner/steps.ts
3769
4854
  function getStepType(step) {
@@ -3793,6 +4878,7 @@ function getStepType(step) {
3793
4878
  if ("evalScript" in step) return "evalScript";
3794
4879
  if ("runScript" in step) return "runScript";
3795
4880
  if ("assertScreenshot" in step) return "assertScreenshot";
4881
+ if ("assertWithAI" in step) return "assertWithAI";
3796
4882
  if ("copyText" in step) return "copyText";
3797
4883
  if ("waitForDownload" in step) return "waitForDownload";
3798
4884
  return "step";
@@ -3852,6 +4938,9 @@ function applyRuntimeVars(step, vars) {
3852
4938
  }
3853
4939
  };
3854
4940
  }
4941
+ if ("assertWithAI" in step) {
4942
+ return { assertWithAI: sub(step.assertWithAI) };
4943
+ }
3855
4944
  if ("copyText" in step) {
3856
4945
  return { copyText: { selector: sub(step.copyText.selector), as: step.copyText.as } };
3857
4946
  }
@@ -4102,7 +5191,7 @@ async function runInlineAssert(driver, policy, assertion) {
4102
5191
  throw new Error("assert step is missing an assertion type");
4103
5192
  }
4104
5193
  function screenshotPath(screenshotsDir, fileName) {
4105
- return import_node_path9.default.join(screenshotsDir, fileName);
5194
+ return import_node_path11.default.join(screenshotsDir, fileName);
4106
5195
  }
4107
5196
  function stepPath(prefix, index) {
4108
5197
  return prefix ? `${prefix}.${index}` : `${index}`;
@@ -4119,7 +5208,7 @@ function validateDownloadFilename(suggestedFilename) {
4119
5208
  const safeFilename = suggestedFilename.trim();
4120
5209
  const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
4121
5210
  const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
4122
- if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path9.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
5211
+ if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path11.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
4123
5212
  throw new Error(`Invalid download filename: "${suggestedFilename}"`);
4124
5213
  }
4125
5214
  return safeFilename;
@@ -4299,7 +5388,7 @@ var STEP_HANDLERS = {
4299
5388
  if (!("setInputFiles" in h.step)) unknownStep();
4300
5389
  const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
4301
5390
  const rawFiles = h.step.setInputFiles.files;
4302
- const resolveFile = (f) => import_node_path9.default.isAbsolute(f) ? f : import_node_path9.default.join(h.context.configDir, f);
5391
+ const resolveFile = (f) => import_node_path11.default.isAbsolute(f) ? f : import_node_path11.default.join(h.context.configDir, f);
4303
5392
  const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
4304
5393
  await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
4305
5394
  h.policy.ensureLocationAllowed(h.driver);
@@ -4661,15 +5750,15 @@ var STEP_HANDLERS = {
4661
5750
  if (!responseFile) {
4662
5751
  throw new Error("mock.response must include either body or file");
4663
5752
  }
4664
- const candidateFilePath = import_node_path9.default.isAbsolute(responseFile) ? responseFile : import_node_path9.default.join(h.context.configDir, responseFile);
4665
- const resolvedConfigDir = import_node_path9.default.resolve(h.context.configDir);
4666
- const resolvedFilePath = import_node_path9.default.resolve(candidateFilePath);
4667
- const relativePath = import_node_path9.default.relative(resolvedConfigDir, resolvedFilePath);
4668
- const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path9.default.sep}`) && !import_node_path9.default.isAbsolute(relativePath);
5753
+ const candidateFilePath = import_node_path11.default.isAbsolute(responseFile) ? responseFile : import_node_path11.default.join(h.context.configDir, responseFile);
5754
+ const resolvedConfigDir = import_node_path11.default.resolve(h.context.configDir);
5755
+ const resolvedFilePath = import_node_path11.default.resolve(candidateFilePath);
5756
+ const relativePath = import_node_path11.default.relative(resolvedConfigDir, resolvedFilePath);
5757
+ const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path11.default.sep}`) && !import_node_path11.default.isAbsolute(relativePath);
4669
5758
  if (!isWithinConfigDir) {
4670
5759
  throw new Error("mock.response.file must resolve within config directory");
4671
5760
  }
4672
- responseBody = await import_node_fs9.default.promises.readFile(resolvedFilePath, "utf-8");
5761
+ responseBody = await import_node_fs10.default.promises.readFile(resolvedFilePath, "utf-8");
4673
5762
  }
4674
5763
  const contentType = mock.response.contentType ?? "application/json";
4675
5764
  const status = mock.response.status;
@@ -4732,8 +5821,8 @@ var STEP_HANDLERS = {
4732
5821
  capabilities: ["evaluate"],
4733
5822
  run: async (h) => {
4734
5823
  if (!("runScript" in h.step)) unknownStep();
4735
- const filePath = import_node_path9.default.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : import_node_path9.default.join(h.context.configDir, h.step.runScript.file);
4736
- const fileContents = import_node_fs9.default.readFileSync(filePath, "utf-8");
5824
+ const filePath = import_node_path11.default.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : import_node_path11.default.join(h.context.configDir, h.step.runScript.file);
5825
+ const fileContents = import_node_fs10.default.readFileSync(filePath, "utf-8");
4737
5826
  await h.driver.evaluate(fileContents);
4738
5827
  return {
4739
5828
  kind: "result",
@@ -4754,19 +5843,19 @@ var STEP_HANDLERS = {
4754
5843
  const name = h.step.assertScreenshot.name;
4755
5844
  const threshold = h.step.assertScreenshot.threshold ?? 0.1;
4756
5845
  const baselineDir = ensureBaselineDir2(h.context.configDir);
4757
- const baselinePath = import_node_path9.default.join(baselineDir, `${name}.png`);
4758
- const currentScreenshotPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
4759
- import_node_fs9.default.mkdirSync(import_node_path9.default.dirname(currentScreenshotPath), { recursive: true });
5846
+ const baselinePath = import_node_path11.default.join(baselineDir, `${name}.png`);
5847
+ const currentScreenshotPath = import_node_path11.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
5848
+ import_node_fs10.default.mkdirSync(import_node_path11.default.dirname(currentScreenshotPath), { recursive: true });
4760
5849
  await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
4761
- h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-current.png`));
4762
- if (!import_node_fs9.default.existsSync(baselinePath)) {
4763
- import_node_fs9.default.copyFileSync(currentScreenshotPath, baselinePath);
5850
+ h.screenshots.push(import_node_path11.default.join("screenshots", `${name}-current.png`));
5851
+ if (!import_node_fs10.default.existsSync(baselinePath)) {
5852
+ import_node_fs10.default.copyFileSync(currentScreenshotPath, baselinePath);
4764
5853
  return {
4765
5854
  kind: "result",
4766
5855
  result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
4767
5856
  };
4768
5857
  }
4769
- const diffPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
5858
+ const diffPath = import_node_path11.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
4770
5859
  const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
4771
5860
  if (comparison.match) {
4772
5861
  return {
@@ -4779,12 +5868,54 @@ var STEP_HANDLERS = {
4779
5868
  }
4780
5869
  };
4781
5870
  }
4782
- h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-diff.png`));
5871
+ h.screenshots.push(import_node_path11.default.join("screenshots", `${name}-diff.png`));
4783
5872
  throw new Error(
4784
5873
  `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
4785
5874
  );
4786
5875
  }
4787
5876
  },
5877
+ assertWithAI: {
5878
+ capabilities: ["screenshot"],
5879
+ run: async (h) => {
5880
+ if (!("assertWithAI" in h.step)) unknownStep();
5881
+ const assertion = h.step.assertWithAI;
5882
+ const resolve = h.context.resolveAiConfig ?? tryResolveAiConfig;
5883
+ const aiConfig = resolve();
5884
+ if (!aiConfig) {
5885
+ return {
5886
+ kind: "result",
5887
+ result: {
5888
+ type: "assertWithAI",
5889
+ status: "warn",
5890
+ durationMs: Date.now() - h.stepStart,
5891
+ value: `skipped: no AI provider configured (set PROWL_AI_KEY to enable) \u2014 "${assertion}"`
5892
+ }
5893
+ };
5894
+ }
5895
+ const fileName = `assertWithAI_step_${h.index + 1}.png`;
5896
+ const relative = await h.addScreenshot(fileName);
5897
+ const screenshotFullPath = import_node_path11.default.join(h.context.runDir, relative);
5898
+ const imageBase64 = import_node_fs10.default.readFileSync(screenshotFullPath).toString("base64");
5899
+ const assertVision = h.context.assertVision ?? assertWithAiVision;
5900
+ const verdict = await assertVision(
5901
+ { imageBase64, mediaType: "image/png", assertion },
5902
+ aiConfig
5903
+ );
5904
+ if (verdict.pass) {
5905
+ return {
5906
+ kind: "result",
5907
+ result: {
5908
+ type: "assertWithAI",
5909
+ status: "pass",
5910
+ durationMs: Date.now() - h.stepStart,
5911
+ value: verdict.reason,
5912
+ screenshot: relative
5913
+ }
5914
+ };
5915
+ }
5916
+ throw new Error(`AI assertion failed: ${verdict.reason}`);
5917
+ }
5918
+ },
4788
5919
  copyText: {
4789
5920
  capabilities: ["query"],
4790
5921
  run: async (h) => {
@@ -4821,7 +5952,7 @@ var STEP_HANDLERS = {
4821
5952
  `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
4822
5953
  );
4823
5954
  }
4824
- const savePath = import_node_path9.default.join(h.context.runDir, suggestedFilename);
5955
+ const savePath = import_node_path11.default.join(h.context.runDir, suggestedFilename);
4825
5956
  await download.saveAs(savePath);
4826
5957
  return {
4827
5958
  kind: "result",
@@ -4850,8 +5981,8 @@ async function executeSteps(context) {
4850
5981
  maxSteps: context.maxSteps,
4851
5982
  selfHealing: context.selfHealing
4852
5983
  });
4853
- const screenshotsDir = import_node_path9.default.join(context.runDir, "screenshots");
4854
- import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
5984
+ const screenshotsDir = import_node_path11.default.join(context.runDir, "screenshots");
5985
+ import_node_fs10.default.mkdirSync(screenshotsDir, { recursive: true });
4855
5986
  const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
4856
5987
  policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
4857
5988
  const results = [];
@@ -4861,7 +5992,7 @@ async function executeSteps(context) {
4861
5992
  const addScreenshot = async (fileName) => {
4862
5993
  const fullPath = screenshotPath(screenshotsDir, fileName);
4863
5994
  await captureScreenshot2(driver, fullPath);
4864
- const relative = import_node_path9.default.join("screenshots", fileName);
5995
+ const relative = import_node_path11.default.join("screenshots", fileName);
4865
5996
  screenshots.push(relative);
4866
5997
  return relative;
4867
5998
  };
@@ -4949,12 +6080,12 @@ async function executeSteps(context) {
4949
6080
  return { results, screenshots, failed: false };
4950
6081
  }
4951
6082
  async function captureFinalScreenshot(page, runDir) {
4952
- const screenshotsDir = import_node_path9.default.join(runDir, "screenshots");
4953
- import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
6083
+ const screenshotsDir = import_node_path11.default.join(runDir, "screenshots");
6084
+ import_node_fs10.default.mkdirSync(screenshotsDir, { recursive: true });
4954
6085
  const fileName = "final.png";
4955
6086
  const filePath = screenshotPath(screenshotsDir, fileName);
4956
6087
  await captureScreenshot2(page, filePath);
4957
- return import_node_path9.default.join("screenshots", fileName);
6088
+ return import_node_path11.default.join("screenshots", fileName);
4958
6089
  }
4959
6090
 
4960
6091
  // src/runner/assertions.ts
@@ -5076,6 +6207,59 @@ async function evaluateAssertions(options) {
5076
6207
  }
5077
6208
  return results;
5078
6209
  }
6210
+ async function evaluateNativeAssertions(options) {
6211
+ const assertions = mergeAssertions(options.config, options.huntAssertions);
6212
+ const authoredTypes = new Set(
6213
+ (options.huntAssertions ?? []).map((assertion) => Object.keys(assertion)[0])
6214
+ );
6215
+ const results = [];
6216
+ const warnings = [];
6217
+ for (const assertion of assertions) {
6218
+ const type = Object.keys(assertion)[0] ?? "assertion";
6219
+ try {
6220
+ if ("selectorExists" in assertion) {
6221
+ options.assertAllowedSelector?.(assertion.selectorExists);
6222
+ const count = await options.driver.count(assertion.selectorExists);
6223
+ results.push({
6224
+ type: "selectorExists",
6225
+ value: assertion.selectorExists,
6226
+ status: count > 0 ? "pass" : "fail",
6227
+ error: count > 0 ? void 0 : "Selector not found"
6228
+ });
6229
+ continue;
6230
+ }
6231
+ if ("selectorNotExists" in assertion) {
6232
+ options.assertAllowedSelector?.(assertion.selectorNotExists);
6233
+ const count = await options.driver.count(assertion.selectorNotExists);
6234
+ results.push({
6235
+ type: "selectorNotExists",
6236
+ value: assertion.selectorNotExists,
6237
+ status: count === 0 ? "pass" : "fail",
6238
+ error: count === 0 ? void 0 : "Selector exists"
6239
+ });
6240
+ continue;
6241
+ }
6242
+ const rawValue = assertion[type];
6243
+ results.push({
6244
+ type,
6245
+ value: rawValue,
6246
+ status: "skipped",
6247
+ error: `skipped (web-only): not supported on the ${options.targetLabel} target`
6248
+ });
6249
+ if (authoredTypes.has(type)) {
6250
+ warnings.push(`${type} is web-only; skipped on ${options.targetLabel} target`);
6251
+ }
6252
+ } catch (error) {
6253
+ const detail = error instanceof Error ? error.message : "no error details";
6254
+ results.push({
6255
+ type,
6256
+ status: "fail",
6257
+ error: `Native assertion "${type}" failed: ${detail}`
6258
+ });
6259
+ }
6260
+ }
6261
+ return { results, warnings };
6262
+ }
5079
6263
 
5080
6264
  // src/runner/tracing.ts
5081
6265
  var DEFAULT_TRACE_HEADER = "traceparent";
@@ -5114,18 +6298,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
5114
6298
  }
5115
6299
 
5116
6300
  // src/reporter/result.ts
5117
- var import_node_fs10 = __toESM(require("fs"), 1);
5118
- var import_node_path10 = __toESM(require("path"), 1);
6301
+ var import_node_fs11 = __toESM(require("fs"), 1);
6302
+ var import_node_path12 = __toESM(require("path"), 1);
5119
6303
  function writeResult(runDir, result) {
5120
6304
  const fileName = "result.json";
5121
- const fullPath = import_node_path10.default.join(runDir, fileName);
5122
- import_node_fs10.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
6305
+ const fullPath = import_node_path12.default.join(runDir, fileName);
6306
+ import_node_fs11.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
5123
6307
  return fileName;
5124
6308
  }
5125
6309
 
5126
6310
  // src/reporter/summary.ts
5127
- var import_node_fs11 = __toESM(require("fs"), 1);
5128
- var import_node_path11 = __toESM(require("path"), 1);
6311
+ var import_node_fs12 = __toESM(require("fs"), 1);
6312
+ var import_node_path13 = __toESM(require("path"), 1);
5129
6313
  function escapeMd(text) {
5130
6314
  return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
5131
6315
  }
@@ -5203,28 +6387,29 @@ function writeSummary(runDir, result) {
5203
6387
  }
5204
6388
  }
5205
6389
  const fileName = "summary.md";
5206
- const fullPath = import_node_path11.default.join(runDir, fileName);
5207
- import_node_fs11.default.writeFileSync(fullPath, `${lines.join("\n")}
6390
+ const fullPath = import_node_path13.default.join(runDir, fileName);
6391
+ import_node_fs12.default.writeFileSync(fullPath, `${lines.join("\n")}
5208
6392
  `);
5209
6393
  return fileName;
5210
6394
  }
5211
6395
 
5212
6396
  // src/reporter/junit.ts
5213
- var import_node_fs12 = __toESM(require("fs"), 1);
5214
- var import_node_path12 = __toESM(require("path"), 1);
6397
+ var import_node_fs13 = __toESM(require("fs"), 1);
6398
+ var import_node_path14 = __toESM(require("path"), 1);
5215
6399
  function escapeXml(text) {
5216
6400
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
5217
6401
  }
5218
6402
  function writeJunit(runDir, result) {
5219
6403
  const totalTests = result.steps.length + result.assertions.length;
5220
6404
  const failures = result.steps.filter((s) => s.status === "fail").length + result.assertions.filter((a) => a.status === "fail").length;
6405
+ const skipped = result.assertions.filter((a) => a.status === "skipped").length;
5221
6406
  const timeSeconds = (result.durationMs / 1e3).toFixed(3);
5222
6407
  const huntName = escapeXml(result.hunt);
5223
6408
  const lines = [];
5224
6409
  lines.push('<?xml version="1.0" encoding="UTF-8"?>');
5225
6410
  lines.push("<testsuites>");
5226
6411
  lines.push(
5227
- ` <testsuite name="${huntName}" tests="${totalTests}" failures="${failures}" errors="0" time="${timeSeconds}" timestamp="${escapeXml(result.startedAt)}">`
6412
+ ` <testsuite name="${huntName}" tests="${totalTests}" failures="${failures}" errors="0" skipped="${skipped}" time="${timeSeconds}" timestamp="${escapeXml(result.startedAt)}">`
5228
6413
  );
5229
6414
  for (let i = 0; i < result.steps.length; i++) {
5230
6415
  const step = result.steps[i];
@@ -5248,6 +6433,11 @@ function writeJunit(runDir, result) {
5248
6433
  lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0">`);
5249
6434
  lines.push(` <failure message="${escapedFailureText}" type="assertion">${escapedFailureText}</failure>`);
5250
6435
  lines.push(" </testcase>");
6436
+ } else if (assertion.status === "skipped") {
6437
+ const skipText = escapeXml(assertion.error ?? "skipped");
6438
+ lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0">`);
6439
+ lines.push(` <skipped message="${skipText}"/>`);
6440
+ lines.push(" </testcase>");
5251
6441
  } else {
5252
6442
  lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0"/>`);
5253
6443
  }
@@ -5255,8 +6445,8 @@ function writeJunit(runDir, result) {
5255
6445
  lines.push(" </testsuite>");
5256
6446
  lines.push("</testsuites>");
5257
6447
  const fileName = "junit.xml";
5258
- const fullPath = import_node_path12.default.join(runDir, fileName);
5259
- import_node_fs12.default.writeFileSync(fullPath, `${lines.join("\n")}
6448
+ const fullPath = import_node_path14.default.join(runDir, fileName);
6449
+ import_node_fs13.default.writeFileSync(fullPath, `${lines.join("\n")}
5260
6450
  `);
5261
6451
  return fileName;
5262
6452
  }
@@ -5290,15 +6480,15 @@ function timestamp(prefix) {
5290
6480
  }
5291
6481
 
5292
6482
  // src/runner/history.ts
5293
- var import_node_fs13 = __toESM(require("fs"), 1);
5294
- var import_node_path13 = __toESM(require("path"), 1);
6483
+ var import_node_fs14 = __toESM(require("fs"), 1);
6484
+ var import_node_path15 = __toESM(require("path"), 1);
5295
6485
  var HISTORY_FILE = "history.json";
5296
6486
  var LOCK_FILE_SUFFIX = ".lock";
5297
6487
  var LOCK_RETRY_MS = 10;
5298
6488
  var LOCK_TIMEOUT_MS = 5e3;
5299
6489
  var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
5300
6490
  function historyPath(configDir) {
5301
- return import_node_path13.default.join(configDir, HISTORY_FILE);
6491
+ return import_node_path15.default.join(configDir, HISTORY_FILE);
5302
6492
  }
5303
6493
  function isHistoryEntry(value) {
5304
6494
  if (!value || typeof value !== "object") {
@@ -5309,11 +6499,11 @@ function isHistoryEntry(value) {
5309
6499
  }
5310
6500
  function readHistory(configDir) {
5311
6501
  const filePath = historyPath(configDir);
5312
- if (!import_node_fs13.default.existsSync(filePath)) {
6502
+ if (!import_node_fs14.default.existsSync(filePath)) {
5313
6503
  return { entries: [] };
5314
6504
  }
5315
6505
  try {
5316
- const raw = import_node_fs13.default.readFileSync(filePath, "utf-8");
6506
+ const raw = import_node_fs14.default.readFileSync(filePath, "utf-8");
5317
6507
  const parsed = JSON.parse(raw);
5318
6508
  if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
5319
6509
  const validatedEntries = parsed.entries.filter(isHistoryEntry);
@@ -5352,12 +6542,12 @@ function sleepSync(ms) {
5352
6542
  function withHistoryLock(configDir, fn) {
5353
6543
  const filePath = historyPath(configDir);
5354
6544
  const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
5355
- import_node_fs13.default.mkdirSync(import_node_path13.default.dirname(filePath), { recursive: true });
6545
+ import_node_fs14.default.mkdirSync(import_node_path15.default.dirname(filePath), { recursive: true });
5356
6546
  const startedAt = Date.now();
5357
6547
  while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
5358
6548
  let fd;
5359
6549
  try {
5360
- fd = import_node_fs13.default.openSync(lockPath, "wx");
6550
+ fd = import_node_fs14.default.openSync(lockPath, "wx");
5361
6551
  } catch (error) {
5362
6552
  if (error.code === "EEXIST") {
5363
6553
  sleepSync(LOCK_RETRY_MS);
@@ -5369,10 +6559,10 @@ function withHistoryLock(configDir, fn) {
5369
6559
  return fn();
5370
6560
  } finally {
5371
6561
  try {
5372
- import_node_fs13.default.closeSync(fd);
6562
+ import_node_fs14.default.closeSync(fd);
5373
6563
  } catch {
5374
6564
  }
5375
- import_node_fs13.default.rmSync(lockPath, { force: true });
6565
+ import_node_fs14.default.rmSync(lockPath, { force: true });
5376
6566
  }
5377
6567
  }
5378
6568
  throw new Error(
@@ -5385,9 +6575,9 @@ function appendEntry(configDir, entry, maxRuns) {
5385
6575
  const current = readHistory(configDir);
5386
6576
  const next = pruneEntries([...current.entries, entry], maxRuns);
5387
6577
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
5388
- import_node_fs13.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
6578
+ import_node_fs14.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
5389
6579
  `);
5390
- import_node_fs13.default.renameSync(tempPath, filePath);
6580
+ import_node_fs14.default.renameSync(tempPath, filePath);
5391
6581
  });
5392
6582
  }
5393
6583
 
@@ -5400,11 +6590,11 @@ function parseViewportFlag(value) {
5400
6590
  return value;
5401
6591
  }
5402
6592
  function resolvePath(configDir, inputPath) {
5403
- if (import_node_path14.default.isAbsolute(inputPath)) {
6593
+ if (import_node_path16.default.isAbsolute(inputPath)) {
5404
6594
  return inputPath;
5405
6595
  }
5406
- const projectRoot = import_node_path14.default.dirname(configDir);
5407
- return import_node_path14.default.join(projectRoot, inputPath);
6596
+ const projectRoot = import_node_path16.default.dirname(configDir);
6597
+ return import_node_path16.default.join(projectRoot, inputPath);
5408
6598
  }
5409
6599
  function buildRunResult(options) {
5410
6600
  return {
@@ -5423,12 +6613,12 @@ function buildRunResult(options) {
5423
6613
  }
5424
6614
  function writeConsoleLog(runDir, entries) {
5425
6615
  const fileName = "console.log";
5426
- const filePath = import_node_path14.default.join(runDir, fileName);
6616
+ const filePath = import_node_path16.default.join(runDir, fileName);
5427
6617
  const lines = entries.map((entry) => {
5428
6618
  const location = entry.location ? ` (${entry.location})` : "";
5429
6619
  return `[${entry.type}] ${entry.text}${location}`;
5430
6620
  });
5431
- import_node_fs14.default.writeFileSync(filePath, `${lines.join("\n")}
6621
+ import_node_fs15.default.writeFileSync(filePath, `${lines.join("\n")}
5432
6622
  `);
5433
6623
  return fileName;
5434
6624
  }
@@ -5436,8 +6626,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
5436
6626
  const headless = options.headed ? false : config.browser.headless;
5437
6627
  const slowMo = options.slowMo ?? config.browser.slowMo;
5438
6628
  const maxSteps = config.guardrails.maxSteps;
5439
- const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
5440
- import_node_fs14.default.mkdirSync(runDir, { recursive: true });
6629
+ const runDir = import_node_path16.default.join(configDir, "runs", timestamp());
6630
+ import_node_fs15.default.mkdirSync(runDir, { recursive: true });
5441
6631
  const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
5442
6632
  const engine = options.browser ?? config.browser.engine;
5443
6633
  const channel = options.channel ?? config.browser.channel;
@@ -5620,8 +6810,8 @@ async function runHunt(options) {
5620
6810
  }
5621
6811
  async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
5622
6812
  const maxSteps = config.guardrails.maxSteps;
5623
- const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
5624
- import_node_fs14.default.mkdirSync(runDir, { recursive: true });
6813
+ const runDir = import_node_path16.default.join(configDir, "runs", timestamp());
6814
+ import_node_fs15.default.mkdirSync(runDir, { recursive: true });
5625
6815
  const session = await native.launchSession();
5626
6816
  let result;
5627
6817
  try {
@@ -5629,6 +6819,13 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
5629
6819
  const appIdentity = native.sessionAppIdentity(session);
5630
6820
  const targetLabel = `${native.targetType}:${appIdentity}`;
5631
6821
  const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
6822
+ const assertionPolicy = createRunPolicy(driver, {
6823
+ forbiddenSelectors: config.guardrails.forbiddenSelectors,
6824
+ allowedDomains: [],
6825
+ allowedApps: effectiveAllowedApps,
6826
+ maxSteps,
6827
+ selfHealing: config.guardrails.selfHealing
6828
+ });
5632
6829
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
5633
6830
  const startTime = Date.now();
5634
6831
  let stepResults = [];
@@ -5668,8 +6865,19 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
5668
6865
  } catch {
5669
6866
  finalScreenshot = void 0;
5670
6867
  }
6868
+ const { results: assertionResults, warnings: assertionWarnings } = await evaluateNativeAssertions({
6869
+ driver,
6870
+ config,
6871
+ huntAssertions: interpolatedHunt.assertions,
6872
+ assertAllowedSelector: assertionPolicy.assertAllowedSelector,
6873
+ targetLabel: nativeTargetLabel(native.targetType)
6874
+ });
6875
+ for (const warning of assertionWarnings) {
6876
+ console.warn(warning);
6877
+ }
5671
6878
  const durationMs = Date.now() - startTime;
5672
- const status = stepFailed ? "fail" : "pass";
6879
+ const assertionsFailed = assertionResults.some((assertion) => assertion.status === "fail");
6880
+ const status = stepFailed || assertionsFailed ? "fail" : "pass";
5673
6881
  const artifacts = {
5674
6882
  screenshots: finalScreenshot ? [...stepScreenshots, finalScreenshot] : stepScreenshots
5675
6883
  };
@@ -5680,7 +6888,7 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
5680
6888
  hunt: options.huntName,
5681
6889
  targetUrl: targetLabel,
5682
6890
  steps: stepResults,
5683
- assertions: [],
6891
+ assertions: assertionResults,
5684
6892
  artifacts
5685
6893
  });
5686
6894
  result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
@@ -5793,7 +7001,6 @@ async function runNativeHunt(options, config, configDir, target, native) {
5793
7001
  const hunt = loadHunt(options.huntName, configDir);
5794
7002
  const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
5795
7003
  assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
5796
- assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
5797
7004
  native.assertAppAllowed(config.guardrails.allowedApps, target);
5798
7005
  const maxSteps = config.guardrails.maxSteps;
5799
7006
  if (interpolatedHunt.steps.length > maxSteps) {
@@ -5834,7 +7041,7 @@ async function runNativeHunt(options, config, configDir, target, native) {
5834
7041
  }
5835
7042
  function recordHistory(configDir, outcome, maxRuns) {
5836
7043
  try {
5837
- const relativeRunDir = import_node_path14.default.relative(configDir, outcome.runDir);
7044
+ const relativeRunDir = import_node_path16.default.relative(configDir, outcome.runDir);
5838
7045
  appendEntry(
5839
7046
  configDir,
5840
7047
  {
@@ -5851,11 +7058,11 @@ function recordHistory(configDir, outcome, maxRuns) {
5851
7058
  }
5852
7059
 
5853
7060
  // src/runner/suite.ts
5854
- var import_node_path17 = __toESM(require("path"), 1);
7061
+ var import_node_path19 = __toESM(require("path"), 1);
5855
7062
 
5856
7063
  // src/reporter/ci-summary.ts
5857
- var import_node_fs15 = __toESM(require("fs"), 1);
5858
- var import_node_path15 = __toESM(require("path"), 1);
7064
+ var import_node_fs16 = __toESM(require("fs"), 1);
7065
+ var import_node_path17 = __toESM(require("path"), 1);
5859
7066
  var import_chalk = __toESM(require("chalk"), 1);
5860
7067
  function countCiResults(results) {
5861
7068
  return {
@@ -5885,9 +7092,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
5885
7092
  ...flaky.length > 0 ? { flaky } : {},
5886
7093
  ...clusters.length > 0 ? { clusters } : {}
5887
7094
  };
5888
- import_node_fs15.default.mkdirSync(ciRunDir, { recursive: true });
5889
- const filePath = import_node_path15.default.join(ciRunDir, "ci-result.json");
5890
- import_node_fs15.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
7095
+ import_node_fs16.default.mkdirSync(ciRunDir, { recursive: true });
7096
+ const filePath = import_node_path17.default.join(ciRunDir, "ci-result.json");
7097
+ import_node_fs16.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
5891
7098
  return filePath;
5892
7099
  }
5893
7100
 
@@ -5954,7 +7161,7 @@ function rankFlaky(configDir, options = {}) {
5954
7161
  }
5955
7162
 
5956
7163
  // src/backlog/fingerprint.ts
5957
- var import_node_crypto2 = require("crypto");
7164
+ var import_node_crypto3 = require("crypto");
5958
7165
  function normalizeError(error) {
5959
7166
  return error.toLowerCase().replace(/0x[0-9a-f]+/g, "").replace(/\b\d+(?:\.\d+)?\s*(?:ms|s|px)\b/g, "").replace(/\s+/g, " ").trim();
5960
7167
  }
@@ -5977,7 +7184,7 @@ function computeFingerprint(failure) {
5977
7184
  failure.selector ?? "",
5978
7185
  normalizeError(failure.error)
5979
7186
  ];
5980
- return (0, import_node_crypto2.createHash)("sha1").update(parts.join("|")).digest("hex").slice(0, 8);
7187
+ return (0, import_node_crypto3.createHash)("sha1").update(parts.join("|")).digest("hex").slice(0, 8);
5981
7188
  }
5982
7189
  function buildMarker(failure, hash) {
5983
7190
  const hunt = sanitizeMarkerValue(failure.hunt);
@@ -6021,8 +7228,8 @@ function clusterFailures(failures) {
6021
7228
  }
6022
7229
 
6023
7230
  // src/backlog/index.ts
6024
- var import_node_fs16 = __toESM(require("fs"), 1);
6025
- var import_node_path16 = __toESM(require("path"), 1);
7231
+ var import_node_fs17 = __toESM(require("fs"), 1);
7232
+ var import_node_path18 = __toESM(require("path"), 1);
6026
7233
 
6027
7234
  // src/backlog/parse.ts
6028
7235
  var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
@@ -6112,7 +7319,7 @@ ${after}`;
6112
7319
  // src/backlog/index.ts
6113
7320
  function readFileOrEmpty(filePath) {
6114
7321
  try {
6115
- return import_node_fs16.default.readFileSync(filePath, "utf-8");
7322
+ return import_node_fs17.default.readFileSync(filePath, "utf-8");
6116
7323
  } catch (error) {
6117
7324
  const err = error;
6118
7325
  if (err.code === "ENOENT") return "";
@@ -6128,7 +7335,7 @@ function buildFailure(hunt) {
6128
7335
  if (!hunt.runDir) return failure;
6129
7336
  let run;
6130
7337
  try {
6131
- const resultJson = readFileOrEmpty(import_node_path16.default.join(hunt.runDir, "result.json"));
7338
+ const resultJson = readFileOrEmpty(import_node_path18.default.join(hunt.runDir, "result.json"));
6132
7339
  if (!resultJson) return failure;
6133
7340
  run = JSON.parse(resultJson);
6134
7341
  } catch (error) {
@@ -6157,8 +7364,8 @@ function extractFailures(suiteResult) {
6157
7364
  }
6158
7365
  function updateBacklogFromSuite(suiteResult, options = {}) {
6159
7366
  const projectRoot = options.projectRoot ?? process.cwd();
6160
- const backlogPath = options.backlogPath ?? import_node_path16.default.join(projectRoot, "docs", "backlog.md");
6161
- const resolvedPath = options.resolvedPath ?? import_node_path16.default.join(projectRoot, "docs", "resolved.md");
7367
+ const backlogPath = options.backlogPath ?? import_node_path18.default.join(projectRoot, "docs", "backlog.md");
7368
+ const resolvedPath = options.resolvedPath ?? import_node_path18.default.join(projectRoot, "docs", "resolved.md");
6162
7369
  const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6163
7370
  const summary = { created: [], regressions: [], skipped: [], backlogPath };
6164
7371
  const failures = extractFailures(suiteResult);
@@ -6190,8 +7397,8 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
6190
7397
  }
6191
7398
  }
6192
7399
  if (ticketsToAdd.length > 0) {
6193
- import_node_fs16.default.mkdirSync(import_node_path16.default.dirname(backlogPath), { recursive: true });
6194
- import_node_fs16.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
7400
+ import_node_fs17.default.mkdirSync(import_node_path18.default.dirname(backlogPath), { recursive: true });
7401
+ import_node_fs17.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
6195
7402
  }
6196
7403
  return summary;
6197
7404
  }
@@ -6352,7 +7559,7 @@ async function runSuite(options = {}) {
6352
7559
  const clusters = clusterFailures(
6353
7560
  extractFailures({ result: { hunts: results }, resultPath: null })
6354
7561
  ).filter((cluster) => cluster.count > 1);
6355
- const ciRunDir = import_node_path17.default.join(configDir, "runs", timestamp("ci"));
7562
+ const ciRunDir = import_node_path19.default.join(configDir, "runs", timestamp("ci"));
6356
7563
  const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
6357
7564
  const { passed, failed, skipped } = countCiResults(results);
6358
7565
  return {
@@ -6615,6 +7822,273 @@ async function readStatusMenu(client, menuTimeoutSeconds) {
6615
7822
  }
6616
7823
  }
6617
7824
 
7825
+ // src/analyzer/android.ts
7826
+ var ANDROID_INTERACTIVE_CLASSES = /* @__PURE__ */ new Set([
7827
+ "android.widget.Button",
7828
+ "android.widget.ImageButton",
7829
+ "android.widget.EditText",
7830
+ "android.widget.CheckBox",
7831
+ "android.widget.RadioButton",
7832
+ "android.widget.Switch",
7833
+ "android.widget.ToggleButton",
7834
+ "android.widget.Spinner",
7835
+ "android.widget.SeekBar",
7836
+ "android.widget.RatingBar",
7837
+ "android.widget.CompoundButton",
7838
+ "android.widget.AutoCompleteTextView",
7839
+ "android.widget.MultiAutoCompleteTextView",
7840
+ "android.widget.CheckedTextView",
7841
+ "androidx.appcompat.widget.SwitchCompat",
7842
+ "androidx.appcompat.widget.AppCompatButton",
7843
+ "androidx.appcompat.widget.AppCompatEditText"
7844
+ ]);
7845
+ function str2(value) {
7846
+ if (value === void 0) {
7847
+ return void 0;
7848
+ }
7849
+ const trimmed = value.trim();
7850
+ return trimmed.length > 0 ? trimmed : void 0;
7851
+ }
7852
+ function bool(value) {
7853
+ if (value === void 0) {
7854
+ return void 0;
7855
+ }
7856
+ return value === "true";
7857
+ }
7858
+ function toAndroidNode(element) {
7859
+ const a = element.attrs;
7860
+ return {
7861
+ className: str2(a["class"]),
7862
+ resourceId: str2(a["resource-id"]),
7863
+ contentDesc: str2(a["content-desc"]),
7864
+ text: str2(a.text),
7865
+ package: str2(a.package),
7866
+ clickable: bool(a.clickable),
7867
+ longClickable: bool(a["long-clickable"]),
7868
+ checkable: bool(a.checkable),
7869
+ checked: bool(a.checked),
7870
+ scrollable: bool(a.scrollable),
7871
+ focusable: bool(a.focusable),
7872
+ focused: bool(a.focused),
7873
+ enabled: bool(a.enabled),
7874
+ children: element.children.map(toAndroidNode)
7875
+ };
7876
+ }
7877
+ function parseAndroidHierarchy(xml) {
7878
+ const root = parseXml(xml);
7879
+ return root ? toAndroidNode(root) : null;
7880
+ }
7881
+ function isAndroidInteractive(node) {
7882
+ if (node.clickable || node.longClickable || node.checkable || node.scrollable) {
7883
+ return true;
7884
+ }
7885
+ return node.className !== void 0 && ANDROID_INTERACTIVE_CLASSES.has(node.className);
7886
+ }
7887
+ function rankAndroidSelectors(node) {
7888
+ return rankNativeSelectors({
7889
+ id: node.resourceId,
7890
+ label: node.contentDesc,
7891
+ role: node.className,
7892
+ name: node.text
7893
+ });
7894
+ }
7895
+ function androidNodeToNative(node) {
7896
+ return {
7897
+ ...node.resourceId !== void 0 ? { id: node.resourceId } : {},
7898
+ ...node.contentDesc !== void 0 ? { label: node.contentDesc } : {},
7899
+ ...node.className !== void 0 ? { role: node.className } : {},
7900
+ textValues: node.text !== void 0 ? [node.text] : [],
7901
+ ...node.focused !== void 0 ? { focused: node.focused } : {}
7902
+ };
7903
+ }
7904
+ function matchAndroidSelector(xml, selector, options = {}) {
7905
+ const parsedSelector = parseNativeSelector(selector);
7906
+ const root = parseAndroidHierarchy(xml);
7907
+ if (!root) {
7908
+ return [];
7909
+ }
7910
+ return matchNativeTree(
7911
+ ANDROID_MATCH_DIALECT,
7912
+ parsedSelector,
7913
+ root,
7914
+ androidNodeToNative,
7915
+ (node) => node.children,
7916
+ options
7917
+ );
7918
+ }
7919
+ function toElement2(node) {
7920
+ return {
7921
+ className: node.className ?? "?",
7922
+ ...node.resourceId ? { resourceId: node.resourceId } : {},
7923
+ ...node.contentDesc ? { contentDesc: node.contentDesc } : {},
7924
+ ...node.text ? { text: node.text } : {},
7925
+ ...node.clickable !== void 0 ? { clickable: node.clickable } : {},
7926
+ ...node.checkable !== void 0 ? { checkable: node.checkable } : {},
7927
+ ...node.scrollable !== void 0 ? { scrollable: node.scrollable } : {},
7928
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
7929
+ selectors: rankAndroidSelectors(node)
7930
+ };
7931
+ }
7932
+ function collectInteractive2(root) {
7933
+ const out = [];
7934
+ const visit = (node) => {
7935
+ if (isAndroidInteractive(node)) {
7936
+ out.push(toElement2(node));
7937
+ }
7938
+ for (const child of node.children) {
7939
+ visit(child);
7940
+ }
7941
+ };
7942
+ visit(root);
7943
+ return out;
7944
+ }
7945
+ async function analyzeAndroidApp(client, options) {
7946
+ const xml = await client.source();
7947
+ const root = parseAndroidHierarchy(xml);
7948
+ const elements = root ? collectInteractive2(root) : [];
7949
+ return { app: options.app, elements };
7950
+ }
7951
+
7952
+ // src/analyzer/ios.ts
7953
+ var IOS_INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
7954
+ "XCUIElementTypeButton",
7955
+ "XCUIElementTypeCell",
7956
+ "XCUIElementTypeTextField",
7957
+ "XCUIElementTypeSecureTextField",
7958
+ "XCUIElementTypeSearchField",
7959
+ "XCUIElementTypeSwitch",
7960
+ "XCUIElementTypeToggle",
7961
+ "XCUIElementTypeLink",
7962
+ "XCUIElementTypeMenuItem",
7963
+ "XCUIElementTypeSlider",
7964
+ "XCUIElementTypeStepper",
7965
+ "XCUIElementTypeTextView",
7966
+ "XCUIElementTypePickerWheel",
7967
+ "XCUIElementTypeTab",
7968
+ "XCUIElementTypeSegmentedControl",
7969
+ "XCUIElementTypeCheckBox",
7970
+ "XCUIElementTypeRadioButton",
7971
+ "XCUIElementTypeKey"
7972
+ ]);
7973
+ var IOS_WINDOW_TYPE = "XCUIElementTypeWindow";
7974
+ function str3(value) {
7975
+ if (value === void 0) {
7976
+ return void 0;
7977
+ }
7978
+ const trimmed = value.trim();
7979
+ return trimmed.length > 0 ? trimmed : void 0;
7980
+ }
7981
+ function bool2(value) {
7982
+ if (value === void 0) {
7983
+ return void 0;
7984
+ }
7985
+ return value === "true" || value === "1";
7986
+ }
7987
+ function toIosNode(element) {
7988
+ const a = element.attrs;
7989
+ return {
7990
+ type: str3(a.type) ?? str3(element.tag),
7991
+ name: str3(a.name),
7992
+ label: str3(a.label),
7993
+ value: str3(a.value),
7994
+ enabled: bool2(a.enabled),
7995
+ visible: bool2(a.visible),
7996
+ children: element.children.map(toIosNode)
7997
+ };
7998
+ }
7999
+ function parseIosHierarchy(xml) {
8000
+ const root = parseXml(xml);
8001
+ return root ? toIosNode(root) : null;
8002
+ }
8003
+ function hasAccessibilityId(node) {
8004
+ return node.name !== void 0 && node.name !== node.label;
8005
+ }
8006
+ function rankIosSelectors(node) {
8007
+ return rankNativeSelectors({
8008
+ id: hasAccessibilityId(node) ? node.name : void 0,
8009
+ label: node.label,
8010
+ role: node.type ? shortIosType(node.type) : void 0,
8011
+ name: node.label ?? node.value
8012
+ });
8013
+ }
8014
+ function iosNodeToNative(node) {
8015
+ const textValues = [];
8016
+ if (node.label !== void 0) {
8017
+ textValues.push(node.label);
8018
+ }
8019
+ if (node.value !== void 0) {
8020
+ textValues.push(node.value);
8021
+ }
8022
+ return {
8023
+ ...node.name !== void 0 ? { id: node.name } : {},
8024
+ ...node.label !== void 0 ? { label: node.label } : {},
8025
+ ...node.type !== void 0 ? { role: node.type } : {},
8026
+ textValues
8027
+ };
8028
+ }
8029
+ function matchIosSelector(xml, selector) {
8030
+ const parsedSelector = parseNativeSelector(selector);
8031
+ const root = parseIosHierarchy(xml);
8032
+ if (!root) {
8033
+ return [];
8034
+ }
8035
+ return matchNativeTree(
8036
+ IOS_MATCH_DIALECT,
8037
+ parsedSelector,
8038
+ root,
8039
+ iosNodeToNative,
8040
+ (node) => node.children
8041
+ );
8042
+ }
8043
+ function toElement3(node) {
8044
+ return {
8045
+ type: node.type ?? "?",
8046
+ ...node.name ? { name: node.name } : {},
8047
+ ...node.label ? { label: node.label } : {},
8048
+ ...node.value ? { value: node.value } : {},
8049
+ ...node.enabled !== void 0 ? { enabled: node.enabled } : {},
8050
+ ...node.visible !== void 0 ? { visible: node.visible } : {},
8051
+ selectors: rankIosSelectors(node)
8052
+ };
8053
+ }
8054
+ function toWindow2(node) {
8055
+ const [best] = rankIosSelectors(node);
8056
+ return {
8057
+ ...node.name ? { name: node.name } : {},
8058
+ ...node.label ? { label: node.label } : {},
8059
+ selector: best ?? `role=${shortIosType(IOS_WINDOW_TYPE)}`
8060
+ };
8061
+ }
8062
+ function isIosInteractive(node) {
8063
+ return node.type !== void 0 && IOS_INTERACTIVE_TYPES.has(node.type);
8064
+ }
8065
+ function collect(root) {
8066
+ const elements = [];
8067
+ const windows = [];
8068
+ const visit = (node) => {
8069
+ if (node.type === IOS_WINDOW_TYPE) {
8070
+ windows.push(toWindow2(node));
8071
+ }
8072
+ if (isIosInteractive(node)) {
8073
+ elements.push(toElement3(node));
8074
+ }
8075
+ for (const child of node.children) {
8076
+ visit(child);
8077
+ }
8078
+ };
8079
+ visit(root);
8080
+ return { elements, windows };
8081
+ }
8082
+ async function analyzeIosApp(client, options) {
8083
+ const xml = await client.source();
8084
+ const root = parseIosHierarchy(xml);
8085
+ if (!root) {
8086
+ return { app: options.app, elements: [], windows: [] };
8087
+ }
8088
+ const { elements, windows } = collect(root);
8089
+ return { app: options.app, elements, windows };
8090
+ }
8091
+
6618
8092
  // src/generator/index.ts
6619
8093
  var import_yaml2 = __toESM(require("yaml"), 1);
6620
8094
 
@@ -6675,6 +8149,7 @@ var STEP_REFERENCE = `
6675
8149
 
6676
8150
  ### Visual Regression
6677
8151
  - assertScreenshot: { name: "baseline-name", threshold: 0.1 }
8152
+ - assertWithAI: "The login form should show email and password fields" \u2014 AI checks the screenshot against the claim
6678
8153
 
6679
8154
  ### Control Flow
6680
8155
  - if: { visible: ".banner", then: [steps...] }
@@ -6716,81 +8191,6 @@ function extractYamlFromResponse(response) {
6716
8191
  return response.trim();
6717
8192
  }
6718
8193
 
6719
- // src/generator/ai.ts
6720
- function resolveAiConfig() {
6721
- const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
6722
- if (provider !== "anthropic" && provider !== "openai") {
6723
- throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
6724
- }
6725
- const apiKey = process.env.PROWL_AI_KEY;
6726
- if (!apiKey) {
6727
- throw new Error(
6728
- "PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
6729
- );
6730
- }
6731
- const defaultModel = provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
6732
- const model = process.env.PROWL_AI_MODEL ?? defaultModel;
6733
- return { provider, model, apiKey };
6734
- }
6735
- async function generateWithAi(prompt, config) {
6736
- if (config.provider === "anthropic") {
6737
- return generateWithAnthropic(prompt, config);
6738
- }
6739
- return generateWithOpenAi(prompt, config);
6740
- }
6741
- async function generateWithAnthropic(prompt, config) {
6742
- const response = await fetch("https://api.anthropic.com/v1/messages", {
6743
- method: "POST",
6744
- headers: {
6745
- "Content-Type": "application/json",
6746
- "x-api-key": config.apiKey,
6747
- "anthropic-version": "2023-06-01"
6748
- },
6749
- body: JSON.stringify({
6750
- model: config.model,
6751
- max_tokens: 4096,
6752
- messages: [
6753
- { role: "user", content: prompt }
6754
- ]
6755
- })
6756
- });
6757
- if (!response.ok) {
6758
- const body = await response.text();
6759
- throw new Error(`Anthropic API error (${response.status}): ${body}`);
6760
- }
6761
- const data = await response.json();
6762
- const textBlock = data.content.find((c) => c.type === "text");
6763
- if (!textBlock?.text) {
6764
- throw new Error("Anthropic API returned no text content");
6765
- }
6766
- return textBlock.text;
6767
- }
6768
- async function generateWithOpenAi(prompt, config) {
6769
- const response = await fetch("https://api.openai.com/v1/chat/completions", {
6770
- method: "POST",
6771
- headers: {
6772
- "Content-Type": "application/json",
6773
- "Authorization": `Bearer ${config.apiKey}`
6774
- },
6775
- body: JSON.stringify({
6776
- model: config.model,
6777
- messages: [
6778
- { role: "user", content: prompt }
6779
- ],
6780
- max_tokens: 4096
6781
- })
6782
- });
6783
- if (!response.ok) {
6784
- const body = await response.text();
6785
- throw new Error(`OpenAI API error (${response.status}): ${body}`);
6786
- }
6787
- const data = await response.json();
6788
- if (!data.choices?.[0]?.message?.content) {
6789
- throw new Error("OpenAI API returned no content");
6790
- }
6791
- return data.choices[0].message.content;
6792
- }
6793
-
6794
8194
  // src/generator/index.ts
6795
8195
  function parseViewportFlag2(value) {
6796
8196
  const match = /^(\d+)x(\d+)$/i.exec(value);
@@ -6835,27 +8235,43 @@ async function generateHunt(options) {
6835
8235
  }
6836
8236
  // Annotate the CommonJS export names for ESM import in node:
6837
8237
  0 && (module.exports = {
8238
+ ANDROID_INTERACTIVE_CLASSES,
6838
8239
  ANDROID_KEYCODES,
8240
+ ANDROID_MATCH_DIALECT,
6839
8241
  DEFAULT_AGENT_REQUEST_TIMEOUT_MS,
6840
8242
  DEFAULT_ANALYZE_TREE_DEPTH,
6841
8243
  DEFAULT_FLAKY_THRESHOLD,
6842
8244
  DEFAULT_REQUEST_TIMEOUT_MS,
6843
8245
  DEFAULT_SIMULATOR_LOCK_ROOT,
6844
8246
  DEFAULT_WDA_REQUEST_TIMEOUT_MS,
8247
+ HELPER_BINARY,
6845
8248
  INTERACTIVE_ROLES,
8249
+ IOS_INTERACTIVE_TYPES,
8250
+ IOS_MATCH_DIALECT,
6846
8251
  IOS_PRESS_KEYS,
8252
+ IOS_WINDOW_TYPE,
8253
+ MACDRIVER_REPO,
8254
+ MACDRIVER_SIGNING_AUTHORITY_PREFIX,
8255
+ MACDRIVER_SIGNING_IDENTIFIER,
8256
+ MACDRIVER_VERSION,
8257
+ MACDRIVER_VERSION_PATTERN,
8258
+ MACOS_MATCH_DIALECT,
8259
+ NATIVE_ATTRIBUTE_MAP,
6847
8260
  SpawnMacHelperClient,
6848
8261
  UIA2_REMOTE_PORT,
6849
8262
  Uia2HttpError,
6850
8263
  Uia2Transport,
6851
- WDA_RUNNER_APP_NAME,
6852
8264
  WDA_RUNNER_BUNDLE_ID,
8265
+ WDA_USE_PORT_ENV,
6853
8266
  WEB_ONLY_STEP_TYPES,
6854
8267
  WdaHttpError,
6855
8268
  WdaTransport,
8269
+ analyzeAndroidApp,
8270
+ analyzeIosApp,
6856
8271
  analyzeMacApp,
6857
8272
  analyzePage,
6858
8273
  androidAppAllowedIdentities,
8274
+ androidNodeToNative,
6859
8275
  androidQueryToLocator,
6860
8276
  assertAndroidAppAllowed,
6861
8277
  assertIosAppAllowed,
@@ -6868,6 +8284,8 @@ async function generateHunt(options) {
6868
8284
  closeIosSession,
6869
8285
  closeMacSession,
6870
8286
  clusterFailures,
8287
+ codesignVerifier,
8288
+ collectMacdriverStatus,
6871
8289
  computeFlakeScore,
6872
8290
  configSchema,
6873
8291
  createAndroidDriver,
@@ -6877,8 +8295,12 @@ async function generateHunt(options) {
6877
8295
  createUia2Session,
6878
8296
  createWdaAgentClient,
6879
8297
  createWdaSession,
8298
+ decodeXmlEntities,
6880
8299
  defaultAgentConnector,
6881
8300
  defaultIosAgentConnector,
8301
+ defaultWdaTestRunPreparer,
8302
+ dittoExtractor,
8303
+ downloadAndVerify,
6882
8304
  escapePredicateArg,
6883
8305
  escapeUiSelectorArg,
6884
8306
  extractElementId,
@@ -6888,9 +8310,14 @@ async function generateHunt(options) {
6888
8310
  generateHunt,
6889
8311
  healSelector,
6890
8312
  huntSchema,
8313
+ injectUsePortIntoXctestrun,
8314
+ installMacdriver,
6891
8315
  interpolateHunt,
6892
8316
  iosAppAllowedIdentities,
8317
+ iosNodeToNative,
6893
8318
  iosQueryToLocator,
8319
+ isAndroidInteractive,
8320
+ isIosInteractive,
6894
8321
  launchAndroidSession,
6895
8322
  launchIosSession,
6896
8323
  launchMacSession,
@@ -6901,18 +8328,41 @@ async function generateHunt(options) {
6901
8328
  loadHunt,
6902
8329
  loadHuntMeta,
6903
8330
  loadHuntTags,
8331
+ macdriverAssetName,
8332
+ macdriverAssetUrl,
6904
8333
  macdriverBuildInstructions,
8334
+ macdriverChecksumName,
8335
+ macdriverInstallRoot,
8336
+ macdriverInstalledBinary,
8337
+ macdriverReleaseTag,
8338
+ macdriverVersionDir,
8339
+ matchAndroidSelector,
8340
+ matchIosSelector,
8341
+ matchNativeTree,
8342
+ nodeMatchesSelector,
6905
8343
  normalizeXcuiClassName,
6906
8344
  parseAaptPackage,
6907
8345
  parseAdbDevices,
8346
+ parseAndroidHierarchy,
6908
8347
  parseAndroidSelector,
8348
+ parseChecksumFile,
8349
+ parseCodesignDetails,
6909
8350
  parseForwardPort,
8351
+ parseIosHierarchy,
6910
8352
  parseIosSelector,
6911
8353
  parseMacSelector,
8354
+ parseNativeSelector,
6912
8355
  parseSimctlDevices,
8356
+ parseSnapshot,
6913
8357
  parseXcodeVersion,
8358
+ parseXml,
8359
+ qualifyResourceId,
8360
+ quoteSelectorValue,
8361
+ rankAndroidSelectors,
6914
8362
  rankFlaky,
8363
+ rankIosSelectors,
6915
8364
  rankMacSelectors,
8365
+ rankNativeSelectors,
6916
8366
  readHistory,
6917
8367
  readHuntHistory,
6918
8368
  readIosBundleIdentifier,
@@ -6920,18 +8370,29 @@ async function generateHunt(options) {
6920
8370
  resolveAgentApks,
6921
8371
  resolveHelperBinary,
6922
8372
  resolveWdaProject,
6923
- resolveWdaRunner,
8373
+ resolveWdaTestRun,
6924
8374
  runHunt,
6925
8375
  runSuite,
8376
+ runVersionProbe,
6926
8377
  selectDeviceSerial,
6927
8378
  selectSimulatorUdid,
8379
+ sha256Hex,
8380
+ shortIosType,
6928
8381
  stepSchema,
8382
+ tccGuidance,
8383
+ unquoteSelectorValue,
6929
8384
  unwrapAndroidTextSelector,
6930
8385
  unwrapIosTextSelector,
8386
+ unwrapNativeTextSelector,
6931
8387
  updateBacklogFromSuite,
8388
+ validateMacdriverArchiveEntries,
8389
+ validateMacdriverVersion,
8390
+ verifyMacdriverSignature,
6932
8391
  waitForAgentReady,
6933
8392
  waitForWdaReady,
6934
8393
  wdaCacheDir,
6935
- webOnlyReason
8394
+ wdaTestRunArgs,
8395
+ webOnlyReason,
8396
+ zipinfoArchiveLister
6936
8397
  });
6937
8398
  //# sourceMappingURL=lib.cjs.map