prowl-tools 0.1.6 → 0.1.8

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
  }
@@ -95,11 +95,17 @@ __export(src_exports, {
95
95
  DEFAULT_REQUEST_TIMEOUT_MS: () => DEFAULT_REQUEST_TIMEOUT_MS,
96
96
  DEFAULT_SIMULATOR_LOCK_ROOT: () => DEFAULT_SIMULATOR_LOCK_ROOT,
97
97
  DEFAULT_WDA_REQUEST_TIMEOUT_MS: () => DEFAULT_WDA_REQUEST_TIMEOUT_MS,
98
+ HELPER_BINARY: () => HELPER_BINARY,
98
99
  INTERACTIVE_ROLES: () => INTERACTIVE_ROLES,
99
100
  IOS_INTERACTIVE_TYPES: () => IOS_INTERACTIVE_TYPES,
100
101
  IOS_MATCH_DIALECT: () => IOS_MATCH_DIALECT,
101
102
  IOS_PRESS_KEYS: () => IOS_PRESS_KEYS,
102
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,
103
109
  MACOS_MATCH_DIALECT: () => MACOS_MATCH_DIALECT,
104
110
  NATIVE_ATTRIBUTE_MAP: () => NATIVE_ATTRIBUTE_MAP,
105
111
  SpawnMacHelperClient: () => SpawnMacHelperClient,
@@ -129,6 +135,8 @@ __export(src_exports, {
129
135
  closeIosSession: () => closeIosSession,
130
136
  closeMacSession: () => closeMacSession,
131
137
  clusterFailures: () => clusterFailures,
138
+ codesignVerifier: () => codesignVerifier,
139
+ collectMacdriverStatus: () => collectMacdriverStatus,
132
140
  computeFlakeScore: () => computeFlakeScore,
133
141
  configSchema: () => configSchema,
134
142
  createAndroidDriver: () => createAndroidDriver,
@@ -142,6 +150,8 @@ __export(src_exports, {
142
150
  defaultAgentConnector: () => defaultAgentConnector,
143
151
  defaultIosAgentConnector: () => defaultIosAgentConnector,
144
152
  defaultWdaTestRunPreparer: () => defaultWdaTestRunPreparer,
153
+ dittoExtractor: () => dittoExtractor,
154
+ downloadAndVerify: () => downloadAndVerify,
145
155
  escapePredicateArg: () => escapePredicateArg,
146
156
  escapeUiSelectorArg: () => escapeUiSelectorArg,
147
157
  extractElementId: () => extractElementId,
@@ -152,6 +162,7 @@ __export(src_exports, {
152
162
  healSelector: () => healSelector,
153
163
  huntSchema: () => huntSchema,
154
164
  injectUsePortIntoXctestrun: () => injectUsePortIntoXctestrun,
165
+ installMacdriver: () => installMacdriver,
155
166
  interpolateHunt: () => interpolateHunt,
156
167
  iosAppAllowedIdentities: () => iosAppAllowedIdentities,
157
168
  iosNodeToNative: () => iosNodeToNative,
@@ -168,7 +179,14 @@ __export(src_exports, {
168
179
  loadHunt: () => loadHunt,
169
180
  loadHuntMeta: () => loadHuntMeta,
170
181
  loadHuntTags: () => loadHuntTags,
182
+ macdriverAssetName: () => macdriverAssetName,
183
+ macdriverAssetUrl: () => macdriverAssetUrl,
171
184
  macdriverBuildInstructions: () => macdriverBuildInstructions,
185
+ macdriverChecksumName: () => macdriverChecksumName,
186
+ macdriverInstallRoot: () => macdriverInstallRoot,
187
+ macdriverInstalledBinary: () => macdriverInstalledBinary,
188
+ macdriverReleaseTag: () => macdriverReleaseTag,
189
+ macdriverVersionDir: () => macdriverVersionDir,
172
190
  matchAndroidSelector: () => matchAndroidSelector,
173
191
  matchIosSelector: () => matchIosSelector,
174
192
  matchNativeTree: () => matchNativeTree,
@@ -178,6 +196,8 @@ __export(src_exports, {
178
196
  parseAdbDevices: () => parseAdbDevices,
179
197
  parseAndroidHierarchy: () => parseAndroidHierarchy,
180
198
  parseAndroidSelector: () => parseAndroidSelector,
199
+ parseChecksumFile: () => parseChecksumFile,
200
+ parseCodesignDetails: () => parseCodesignDetails,
181
201
  parseForwardPort: () => parseForwardPort,
182
202
  parseIosHierarchy: () => parseIosHierarchy,
183
203
  parseIosSelector: () => parseIosSelector,
@@ -204,20 +224,27 @@ __export(src_exports, {
204
224
  resolveWdaTestRun: () => resolveWdaTestRun,
205
225
  runHunt: () => runHunt,
206
226
  runSuite: () => runSuite,
227
+ runVersionProbe: () => runVersionProbe,
207
228
  selectDeviceSerial: () => selectDeviceSerial,
208
229
  selectSimulatorUdid: () => selectSimulatorUdid,
230
+ sha256Hex: () => sha256Hex,
209
231
  shortIosType: () => shortIosType,
210
232
  stepSchema: () => stepSchema,
233
+ tccGuidance: () => tccGuidance,
211
234
  unquoteSelectorValue: () => unquoteSelectorValue,
212
235
  unwrapAndroidTextSelector: () => unwrapAndroidTextSelector,
213
236
  unwrapIosTextSelector: () => unwrapIosTextSelector,
214
237
  unwrapNativeTextSelector: () => unwrapNativeTextSelector,
215
238
  updateBacklogFromSuite: () => updateBacklogFromSuite,
239
+ validateMacdriverArchiveEntries: () => validateMacdriverArchiveEntries,
240
+ validateMacdriverVersion: () => validateMacdriverVersion,
241
+ verifyMacdriverSignature: () => verifyMacdriverSignature,
216
242
  waitForAgentReady: () => waitForAgentReady,
217
243
  waitForWdaReady: () => waitForWdaReady,
218
244
  wdaCacheDir: () => wdaCacheDir,
219
245
  wdaTestRunArgs: () => wdaTestRunArgs,
220
- webOnlyReason: () => webOnlyReason
246
+ webOnlyReason: () => webOnlyReason,
247
+ zipinfoArchiveLister: () => zipinfoArchiveLister
221
248
  });
222
249
  module.exports = __toCommonJS(src_exports);
223
250
 
@@ -329,6 +356,9 @@ function createMacDriver(client, options = {}) {
329
356
  async hover(selector) {
330
357
  await query("hover", selector);
331
358
  },
359
+ scroll() {
360
+ return rejectUnsupported("scroll");
361
+ },
332
362
  async scrollIntoView(selector) {
333
363
  await query("scrollTo", selector);
334
364
  },
@@ -400,25 +430,71 @@ function createMacDriver(client, options = {}) {
400
430
  // src/browser/mac-helper.ts
401
431
  var import_node_child_process = require("child_process");
402
432
  var import_node_fs = __toESM(require("fs"), 1);
403
- var import_node_path = __toESM(require("path"), 1);
433
+ var import_node_os2 = __toESM(require("os"), 1);
434
+ var import_node_path2 = __toESM(require("path"), 1);
404
435
  var import_node_url = require("url");
405
- var import_meta = {};
436
+
437
+ // src/browser/macdriver-release.ts
438
+ var import_node_os = __toESM(require("os"), 1);
439
+ var import_node_path = __toESM(require("path"), 1);
406
440
  var HELPER_BINARY = "prowl-macdriver";
441
+ var MACDRIVER_VERSION = "0.1.0";
442
+ var MACDRIVER_REPO = "prowl-tools/prowl";
443
+ var MACDRIVER_SIGNING_IDENTIFIER = "tools.prowl.macdriver";
444
+ var MACDRIVER_SIGNING_AUTHORITY_PREFIX = "Developer ID Application: Genkei Labs";
445
+ var MACDRIVER_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?$/;
446
+ function validateMacdriverVersion(version) {
447
+ if (!MACDRIVER_VERSION_PATTERN.test(version)) {
448
+ throw new Error(
449
+ `Invalid prowl-macdriver version "${version}". Expected a release version like 0.1.0.`
450
+ );
451
+ }
452
+ return version;
453
+ }
454
+ function macdriverReleaseTag(version = MACDRIVER_VERSION) {
455
+ return `macdriver-v${validateMacdriverVersion(version)}`;
456
+ }
457
+ function macdriverAssetName(version = MACDRIVER_VERSION) {
458
+ return `prowl-macdriver-v${validateMacdriverVersion(version)}-universal.zip`;
459
+ }
460
+ function macdriverChecksumName(version = MACDRIVER_VERSION) {
461
+ return `${macdriverAssetName(version)}.sha256`;
462
+ }
463
+ function macdriverAssetUrl(assetName, version = MACDRIVER_VERSION) {
464
+ return `https://github.com/${MACDRIVER_REPO}/releases/download/${macdriverReleaseTag(version)}/${assetName}`;
465
+ }
466
+ function macdriverInstallRoot(homedir = import_node_os.default.homedir()) {
467
+ return import_node_path.default.join(homedir, ".prowl", "macdriver");
468
+ }
469
+ function macdriverVersionDir(version = MACDRIVER_VERSION, homedir = import_node_os.default.homedir()) {
470
+ const root = import_node_path.default.resolve(macdriverInstallRoot(homedir));
471
+ const versionDir = import_node_path.default.resolve(root, validateMacdriverVersion(version));
472
+ if (!versionDir.startsWith(root + import_node_path.default.sep)) {
473
+ throw new Error(`Resolved prowl-macdriver version directory escaped install root: ${versionDir}`);
474
+ }
475
+ return versionDir;
476
+ }
477
+ function macdriverInstalledBinary(version = MACDRIVER_VERSION, homedir = import_node_os.default.homedir()) {
478
+ return import_node_path.default.join(macdriverVersionDir(version, homedir), HELPER_BINARY);
479
+ }
480
+
481
+ // src/browser/mac-helper.ts
482
+ var import_meta = {};
407
483
  function macdriverBuildInstructions() {
408
- 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.";
484
+ 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.";
409
485
  }
410
486
  function getPackageRoot() {
411
- let dir = import_node_path.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
412
- const root = import_node_path.default.parse(dir).root;
487
+ let dir = import_node_path2.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
488
+ const root = import_node_path2.default.parse(dir).root;
413
489
  while (dir !== root) {
414
- if (import_node_fs.default.existsSync(import_node_path.default.join(dir, "package.json"))) {
490
+ if (import_node_fs.default.existsSync(import_node_path2.default.join(dir, "package.json"))) {
415
491
  return dir;
416
492
  }
417
- dir = import_node_path.default.dirname(dir);
493
+ dir = import_node_path2.default.dirname(dir);
418
494
  }
419
495
  return root;
420
496
  }
421
- function resolveHelperBinary(env = process.env) {
497
+ function resolveHelperBinary(env = process.env, options = {}) {
422
498
  const override = env.PROWL_MACDRIVER_BIN;
423
499
  if (override) {
424
500
  if (!import_node_fs.default.existsSync(override)) {
@@ -429,10 +505,15 @@ ${macdriverBuildInstructions()}`
429
505
  }
430
506
  return override;
431
507
  }
508
+ const homedir = options.homedir ?? import_node_os2.default.homedir();
509
+ const userBinary = macdriverInstalledBinary(MACDRIVER_VERSION, homedir);
510
+ if (import_node_fs.default.existsSync(userBinary)) {
511
+ return userBinary;
512
+ }
432
513
  const root = getPackageRoot();
433
514
  const candidates = [
434
- import_node_path.default.join(root, "macdriver", ".build", "release", HELPER_BINARY),
435
- import_node_path.default.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
515
+ import_node_path2.default.join(root, "macdriver", ".build", "release", HELPER_BINARY),
516
+ import_node_path2.default.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
436
517
  ];
437
518
  for (const candidate of candidates) {
438
519
  if (import_node_fs.default.existsSync(candidate)) {
@@ -604,8 +685,300 @@ async function closeMacSession(session) {
604
685
  }
605
686
  }
606
687
 
607
- // src/browser/android-driver.ts
688
+ // src/browser/macdriver-install.ts
689
+ var import_node_child_process2 = require("child_process");
690
+ var import_node_crypto = require("crypto");
608
691
  var import_node_fs2 = __toESM(require("fs"), 1);
692
+ var import_node_os3 = __toESM(require("os"), 1);
693
+ var import_node_path3 = __toESM(require("path"), 1);
694
+ var import_node_util = require("util");
695
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process2.execFile);
696
+ function parseChecksumFile(text) {
697
+ const token = text.trim().split(/\s+/, 1)[0] ?? "";
698
+ const digest = token.toLowerCase();
699
+ if (!/^[0-9a-f]{64}$/.test(digest)) {
700
+ throw new Error(`Malformed .sha256 checksum file (expected a 64-char hex digest, got: ${text.trim().slice(0, 80)})`);
701
+ }
702
+ return digest;
703
+ }
704
+ function sha256Hex(bytes) {
705
+ return (0, import_node_crypto.createHash)("sha256").update(bytes).digest("hex");
706
+ }
707
+ var commandRunner = async (file, args) => execFileAsync(file, args);
708
+ function commandOutput(result) {
709
+ return [result.stdout, result.stderr].filter((value) => value !== void 0).map((value) => value.toString()).join("\n").trim();
710
+ }
711
+ function commandErrorDetail(error) {
712
+ const err = error;
713
+ return [err.stderr, err.stdout, err.message].filter((value) => value !== void 0 && value !== "").map((value) => value.toString()).join("\n").trim();
714
+ }
715
+ async function runRequiredCommand(run, binaryPath, command, args, label) {
716
+ try {
717
+ return await run(command, args);
718
+ } catch (error) {
719
+ const detail = commandErrorDetail(error);
720
+ throw new Error(`${label} failed for ${binaryPath}${detail ? `: ${detail}` : ""}`);
721
+ }
722
+ }
723
+ var zipinfoArchiveLister = async (zipPath) => {
724
+ try {
725
+ const { stdout } = await execFileAsync("zipinfo", ["-1", zipPath]);
726
+ return stdout.toString().split(/\r?\n/).filter((entry) => entry.length > 0);
727
+ } catch (error) {
728
+ const detail = commandErrorDetail(error);
729
+ throw new Error(`Failed to inspect release archive ${zipPath} with zipinfo${detail ? `: ${detail}` : ""}`);
730
+ }
731
+ };
732
+ function normalizeArchiveEntryName(entry) {
733
+ if (entry.length === 0 || entry !== entry.trim() || entry.includes("\0") || entry.includes("\\")) {
734
+ throw new Error(`Unsafe path in prowl-macdriver release archive: ${JSON.stringify(entry)}`);
735
+ }
736
+ if (entry.endsWith("/")) {
737
+ throw new Error(`Unexpected directory in prowl-macdriver release archive: ${entry}`);
738
+ }
739
+ if (import_node_path3.default.posix.isAbsolute(entry)) {
740
+ throw new Error(`Unsafe absolute path in prowl-macdriver release archive: ${entry}`);
741
+ }
742
+ const parts = entry.split("/");
743
+ if (parts.some((part) => part === "" || part === "." || part === "..")) {
744
+ throw new Error(`Unsafe path in prowl-macdriver release archive: ${entry}`);
745
+ }
746
+ return entry;
747
+ }
748
+ function validateMacdriverArchiveEntries(entries) {
749
+ const normalized = entries.map(normalizeArchiveEntryName);
750
+ if (normalized.length !== 1 || normalized[0] !== HELPER_BINARY) {
751
+ const shown = normalized.length > 0 ? normalized.join(", ") : "(empty archive)";
752
+ throw new Error(
753
+ `Unexpected prowl-macdriver release archive contents: ${shown}. Expected exactly "${HELPER_BINARY}" at the archive root.`
754
+ );
755
+ }
756
+ }
757
+ var dittoExtractor = async (zipPath, destDir) => {
758
+ await execFileAsync("ditto", ["-x", "-k", zipPath, destDir]);
759
+ };
760
+ function parseCodesignDetails(text) {
761
+ const details = { identifier: null, authorities: [], teamIdentifier: null };
762
+ for (const line of text.split(/\r?\n/)) {
763
+ const trimmed = line.trim();
764
+ if (trimmed.startsWith("Identifier=")) {
765
+ details.identifier = trimmed.slice("Identifier=".length);
766
+ } else if (trimmed.startsWith("Authority=")) {
767
+ details.authorities.push(trimmed.slice("Authority=".length));
768
+ } else if (trimmed.startsWith("TeamIdentifier=")) {
769
+ details.teamIdentifier = trimmed.slice("TeamIdentifier=".length);
770
+ }
771
+ }
772
+ return details;
773
+ }
774
+ function validateCodesignDetails(details, binaryPath) {
775
+ if (details.identifier !== MACDRIVER_SIGNING_IDENTIFIER) {
776
+ throw new Error(
777
+ `codesign verification failed for ${binaryPath}: expected identifier "${MACDRIVER_SIGNING_IDENTIFIER}", got "${details.identifier ?? "missing"}"`
778
+ );
779
+ }
780
+ const developerIdAuthority = details.authorities.find(
781
+ (authority) => authority.startsWith(`${MACDRIVER_SIGNING_AUTHORITY_PREFIX} (`)
782
+ );
783
+ const authorityTeamId = developerIdAuthority?.match(/\(([A-Z0-9]{10})\)$/)?.[1] ?? null;
784
+ if (!developerIdAuthority || !authorityTeamId) {
785
+ const shown = details.authorities.length > 0 ? details.authorities.join(" / ") : "missing";
786
+ throw new Error(
787
+ `codesign verification failed for ${binaryPath}: expected ${MACDRIVER_SIGNING_AUTHORITY_PREFIX} signer, got ${shown}`
788
+ );
789
+ }
790
+ if (!details.teamIdentifier) {
791
+ throw new Error(`codesign verification failed for ${binaryPath}: missing TeamIdentifier`);
792
+ }
793
+ if (details.teamIdentifier !== authorityTeamId) {
794
+ throw new Error(
795
+ `codesign verification failed for ${binaryPath}: TeamIdentifier ${details.teamIdentifier} does not match Developer ID authority team ${authorityTeamId}`
796
+ );
797
+ }
798
+ }
799
+ async function verifyMacdriverSignature(binaryPath, run = commandRunner) {
800
+ await runRequiredCommand(run, binaryPath, "codesign", ["--verify", "--strict", binaryPath], "codesign verification");
801
+ const display = await runRequiredCommand(
802
+ run,
803
+ binaryPath,
804
+ "codesign",
805
+ ["--display", "--verbose=4", binaryPath],
806
+ "codesign detail inspection"
807
+ );
808
+ validateCodesignDetails(parseCodesignDetails(commandOutput(display)), binaryPath);
809
+ await runRequiredCommand(
810
+ run,
811
+ binaryPath,
812
+ "spctl",
813
+ ["--assess", "--type", "execute", "--verbose=4", binaryPath],
814
+ "spctl assessment"
815
+ );
816
+ }
817
+ var codesignVerifier = async (binaryPath) => {
818
+ await verifyMacdriverSignature(binaryPath);
819
+ };
820
+ async function downloadAndVerify(options = {}) {
821
+ const version = options.version ?? MACDRIVER_VERSION;
822
+ const fetchImpl = options.fetchImpl ?? fetch;
823
+ const assetName = macdriverAssetName(version);
824
+ const zipUrl = macdriverAssetUrl(assetName, version);
825
+ const sumUrl = macdriverAssetUrl(macdriverChecksumName(version), version);
826
+ const [zipRes, sumRes] = await Promise.all([
827
+ fetchImpl(zipUrl, { redirect: "follow" }),
828
+ fetchImpl(sumUrl, { redirect: "follow" })
829
+ ]);
830
+ if (zipRes.status === 404 || sumRes.status === 404) {
831
+ throw new Error(
832
+ `No published prowl-macdriver release for ${macdriverReleaseTag(version)} yet.
833
+ The signed binary is cut by the maintainer; until then build from source:
834
+ cd macdriver && swift build -c release`
835
+ );
836
+ }
837
+ if (!zipRes.ok) {
838
+ throw new Error(`Failed to download ${assetName} (HTTP ${zipRes.status}) from ${zipUrl}`);
839
+ }
840
+ if (!sumRes.ok) {
841
+ throw new Error(`Failed to download the checksum (HTTP ${sumRes.status}) from ${sumUrl}`);
842
+ }
843
+ const zipBytes = Buffer.from(await zipRes.arrayBuffer());
844
+ const expected = parseChecksumFile(await sumRes.text());
845
+ const actual = sha256Hex(zipBytes);
846
+ if (actual !== expected) {
847
+ throw new Error(
848
+ `Checksum mismatch for ${assetName}.
849
+ expected: ${expected}
850
+ actual: ${actual}
851
+ The download was rejected and discarded; re-run the install, and report it if it repeats.`
852
+ );
853
+ }
854
+ return zipBytes;
855
+ }
856
+ async function installMacdriver(options = {}) {
857
+ const version = validateMacdriverVersion(options.version ?? MACDRIVER_VERSION);
858
+ const homedir = options.homedir ?? import_node_os3.default.homedir();
859
+ const listArchiveEntries = options.listArchiveEntries ?? zipinfoArchiveLister;
860
+ const extract = options.extract ?? dittoExtractor;
861
+ const verifySignature = options.verifySignature ?? codesignVerifier;
862
+ const installRoot = macdriverInstallRoot(homedir);
863
+ const versionDir = macdriverVersionDir(version, homedir);
864
+ const binaryPath = macdriverInstalledBinary(version, homedir);
865
+ if (!options.force && import_node_fs2.default.existsSync(binaryPath)) {
866
+ return { version, binaryPath, alreadyInstalled: true };
867
+ }
868
+ const zipBytes = await downloadAndVerify({ version, fetchImpl: options.fetchImpl });
869
+ import_node_fs2.default.mkdirSync(installRoot, { recursive: true });
870
+ const stagingDir = import_node_fs2.default.mkdtempSync(import_node_path3.default.join(installRoot, `.tmp-${version}-`));
871
+ const extractDir = import_node_path3.default.join(stagingDir, "extract");
872
+ const zipPath = import_node_path3.default.join(stagingDir, macdriverAssetName(version));
873
+ try {
874
+ import_node_fs2.default.mkdirSync(extractDir);
875
+ import_node_fs2.default.writeFileSync(zipPath, zipBytes);
876
+ validateMacdriverArchiveEntries(await listArchiveEntries(zipPath));
877
+ await extract(zipPath, extractDir);
878
+ const stagedBinaryPath = import_node_path3.default.join(extractDir, HELPER_BINARY);
879
+ assertExtractedHelper(extractDir, stagedBinaryPath);
880
+ import_node_fs2.default.chmodSync(stagedBinaryPath, 493);
881
+ await verifySignature(stagedBinaryPath);
882
+ replaceVersionDir(versionDir, extractDir, installRoot);
883
+ } finally {
884
+ import_node_fs2.default.rmSync(stagingDir, { recursive: true, force: true });
885
+ }
886
+ return { version, binaryPath, alreadyInstalled: false };
887
+ }
888
+ function assertExtractedHelper(extractDir, binaryPath) {
889
+ const entries = import_node_fs2.default.readdirSync(extractDir);
890
+ if (!entries.includes(HELPER_BINARY)) {
891
+ throw new Error(`The release archive did not contain a "${HELPER_BINARY}" binary.`);
892
+ }
893
+ if (entries.length !== 1 || entries[0] !== HELPER_BINARY) {
894
+ const shown = entries.length > 0 ? entries.join(", ") : "(empty directory)";
895
+ throw new Error(
896
+ `Unexpected extracted prowl-macdriver archive contents: ${shown}. Expected exactly "${HELPER_BINARY}".`
897
+ );
898
+ }
899
+ let stat = null;
900
+ try {
901
+ stat = import_node_fs2.default.lstatSync(binaryPath);
902
+ } catch {
903
+ }
904
+ if (!stat?.isFile()) {
905
+ throw new Error(`The release archive "${HELPER_BINARY}" entry is not a regular file.`);
906
+ }
907
+ }
908
+ function replaceVersionDir(versionDir, stagedVersionDir, installRoot) {
909
+ const backupDir = import_node_path3.default.join(installRoot, `.previous-${import_node_path3.default.basename(versionDir)}-${process.pid}-${Date.now()}`);
910
+ let backedUp = false;
911
+ try {
912
+ if (import_node_fs2.default.existsSync(versionDir)) {
913
+ import_node_fs2.default.renameSync(versionDir, backupDir);
914
+ backedUp = true;
915
+ }
916
+ import_node_fs2.default.renameSync(stagedVersionDir, versionDir);
917
+ if (backedUp) {
918
+ import_node_fs2.default.rmSync(backupDir, { recursive: true, force: true });
919
+ }
920
+ } catch (error) {
921
+ if (backedUp && !import_node_fs2.default.existsSync(versionDir) && import_node_fs2.default.existsSync(backupDir)) {
922
+ import_node_fs2.default.renameSync(backupDir, versionDir);
923
+ }
924
+ throw error;
925
+ }
926
+ }
927
+ var runVersionProbe = async (binaryPath) => {
928
+ try {
929
+ const { stdout } = await execFileAsync(binaryPath, ["version"], { timeout: 5e3 });
930
+ const match = stdout.match(/prowl-macdriver\s+(\S+)/);
931
+ return match ? match[1] : stdout.trim() || null;
932
+ } catch {
933
+ return null;
934
+ }
935
+ };
936
+ async function collectMacdriverStatus(options = {}) {
937
+ const env = options.env ?? process.env;
938
+ const homedir = options.homedir ?? import_node_os3.default.homedir();
939
+ const probe = options.probe ?? runVersionProbe;
940
+ let resolved = null;
941
+ try {
942
+ const resolvedPath = resolveHelperBinary(env, { homedir });
943
+ resolved = { path: resolvedPath, source: classifyResolvedSource(resolvedPath, env, homedir) };
944
+ } catch {
945
+ resolved = null;
946
+ }
947
+ const installed = [];
948
+ const root = macdriverInstallRoot(homedir);
949
+ if (import_node_fs2.default.existsSync(root)) {
950
+ for (const entry of import_node_fs2.default.readdirSync(root, { withFileTypes: true })) {
951
+ if (!entry.isDirectory()) continue;
952
+ let binaryPath;
953
+ try {
954
+ binaryPath = macdriverInstalledBinary(entry.name, homedir);
955
+ } catch {
956
+ continue;
957
+ }
958
+ if (import_node_fs2.default.existsSync(binaryPath)) {
959
+ installed.push({ version: entry.name, binaryPath });
960
+ }
961
+ }
962
+ installed.sort((a, b) => a.version.localeCompare(b.version));
963
+ }
964
+ const probedVersion = resolved ? await probe(resolved.path) : null;
965
+ return { resolved, pinnedVersion: MACDRIVER_VERSION, installed, probedVersion };
966
+ }
967
+ function classifyResolvedSource(resolvedPath, env, homedir) {
968
+ if (env.PROWL_MACDRIVER_BIN && resolvedPath === env.PROWL_MACDRIVER_BIN) {
969
+ return "env";
970
+ }
971
+ if (resolvedPath.startsWith(macdriverInstallRoot(homedir) + import_node_path3.default.sep)) {
972
+ return "user-install";
973
+ }
974
+ return "source-build";
975
+ }
976
+ function tccGuidance() {
977
+ 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.";
978
+ }
979
+
980
+ // src/browser/android-driver.ts
981
+ var import_node_fs3 = __toESM(require("fs"), 1);
609
982
 
610
983
  // src/analyzer/xml.ts
611
984
  var ENTITIES = {
@@ -888,6 +1261,101 @@ function parseSnapshot(xml) {
888
1261
  return parseXml(xml);
889
1262
  }
890
1263
 
1264
+ // src/browser/touch-gestures.ts
1265
+ var DEFAULT_SWIPE_FRACTION = 0.75;
1266
+ var SCROLL_TO_PROBE_SWIPE_FRACTION = 0.6;
1267
+ var MAX_SWIPE_FRACTION = 0.9;
1268
+ var SWIPE_HOLD_MS = 100;
1269
+ var SWIPE_MOVE_DURATION_MS = 300;
1270
+ var SCROLL_TO_SWEEP_DEPTH = 10;
1271
+ var SCROLL_TO_PROBE_DIRECTIONS = [
1272
+ ...Array.from({ length: SCROLL_TO_SWEEP_DEPTH }, () => "down"),
1273
+ ...Array.from({ length: SCROLL_TO_SWEEP_DEPTH * 2 }, () => "up")
1274
+ ];
1275
+ var MAX_SCROLL_TO_SWIPES = SCROLL_TO_PROBE_DIRECTIONS.length;
1276
+ var OPPOSITE_SWIPE_DIRECTIONS = {
1277
+ up: "down",
1278
+ down: "up",
1279
+ left: "right",
1280
+ right: "left"
1281
+ };
1282
+ async function probeScrollIntoView({
1283
+ isVisible,
1284
+ swipe,
1285
+ directions = SCROLL_TO_PROBE_DIRECTIONS
1286
+ }) {
1287
+ if (await isVisible()) {
1288
+ return true;
1289
+ }
1290
+ for (const direction of directions) {
1291
+ await swipe(direction);
1292
+ if (await isVisible()) {
1293
+ return true;
1294
+ }
1295
+ }
1296
+ return false;
1297
+ }
1298
+ function toScreenSize(value, source) {
1299
+ const record = value ?? {};
1300
+ const width = Number(record.width);
1301
+ const height = Number(record.height);
1302
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
1303
+ throw new Error(`${source} did not return a usable screen size`);
1304
+ }
1305
+ return { width, height };
1306
+ }
1307
+ function isVertical(direction) {
1308
+ return direction === "up" || direction === "down";
1309
+ }
1310
+ function swipeDistanceFor(direction, size, amount) {
1311
+ if (amount !== void 0 && !Number.isFinite(amount)) {
1312
+ throw new Error("scroll amount must be a finite number");
1313
+ }
1314
+ const axis = isVertical(direction) ? size.height : size.width;
1315
+ const requested = amount === void 0 ? axis * DEFAULT_SWIPE_FRACTION : Math.abs(amount);
1316
+ const max = axis * MAX_SWIPE_FRACTION;
1317
+ return Math.max(1, Math.round(Math.min(requested, max)));
1318
+ }
1319
+ function scrollToProbeDistanceFor(direction, size) {
1320
+ const axis = isVertical(direction) ? size.height : size.width;
1321
+ return Math.max(1, Math.round(axis * SCROLL_TO_PROBE_SWIPE_FRACTION));
1322
+ }
1323
+ function swipeEndpoints(direction, size, distance) {
1324
+ const cx = Math.round(size.width / 2);
1325
+ const cy = Math.round(size.height / 2);
1326
+ const half = Math.round(distance / 2);
1327
+ switch (direction) {
1328
+ case "down":
1329
+ return { start: { x: cx, y: cy + half }, end: { x: cx, y: cy - half } };
1330
+ case "up":
1331
+ return { start: { x: cx, y: cy - half }, end: { x: cx, y: cy + half } };
1332
+ case "right":
1333
+ return { start: { x: cx + half, y: cy }, end: { x: cx - half, y: cy } };
1334
+ case "left":
1335
+ return { start: { x: cx - half, y: cy }, end: { x: cx + half, y: cy } };
1336
+ }
1337
+ }
1338
+ function buildSwipeActions(start, end) {
1339
+ return {
1340
+ type: "pointer",
1341
+ id: "finger1",
1342
+ parameters: { pointerType: "touch" },
1343
+ actions: [
1344
+ { type: "pointerMove", duration: 0, x: start.x, y: start.y, origin: "viewport" },
1345
+ { type: "pointerDown", button: 0 },
1346
+ { type: "pause", duration: SWIPE_HOLD_MS },
1347
+ { type: "pointerMove", duration: SWIPE_MOVE_DURATION_MS, x: end.x, y: end.y, origin: "viewport" },
1348
+ { type: "pointerUp", button: 0 }
1349
+ ]
1350
+ };
1351
+ }
1352
+ function buildDirectionalSwipe(direction, size, amount) {
1353
+ const normalizedDirection = amount !== void 0 && amount < 0 ? OPPOSITE_SWIPE_DIRECTIONS[direction] : direction;
1354
+ const distance = swipeDistanceFor(normalizedDirection, size, amount);
1355
+ const { start, end } = swipeEndpoints(normalizedDirection, size, distance);
1356
+ return { actions: buildSwipeActions(start, end), distance, start, end };
1357
+ }
1358
+
891
1359
  // src/browser/android-driver.ts
892
1360
  var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
893
1361
  "query",
@@ -1003,6 +1471,11 @@ function createAndroidDriver(client, options = {}) {
1003
1471
  async function fillSelector(selector, value) {
1004
1472
  await client.setValue(await resolveOne(selector), value);
1005
1473
  }
1474
+ async function swipe(direction, amount, size) {
1475
+ const actualSize = size ?? await client.windowSize();
1476
+ const { actions } = buildDirectionalSwipe(direction, actualSize, amount);
1477
+ await client.performActions(actions);
1478
+ }
1006
1479
  return {
1007
1480
  capabilities: ANDROID_CAPABILITIES,
1008
1481
  // navigation -----------------------------------------------------------
@@ -1040,8 +1513,31 @@ function createAndroidDriver(client, options = {}) {
1040
1513
  hover() {
1041
1514
  return rejectUnsupported("hover");
1042
1515
  },
1043
- scrollIntoView() {
1044
- return rejectUnsupported("scrollTo");
1516
+ // Screen-centred swipe via the W3C actions endpoint (PROWL-080). Direction
1517
+ // semantics match the web step: scrolling "down" reveals lower content, so
1518
+ // the finger drags up. See ./touch-gestures.ts.
1519
+ async scroll(direction, amount) {
1520
+ await swipe(direction, amount);
1521
+ },
1522
+ // Resolve the element, short-circuiting if it is already present in the
1523
+ // hierarchy; otherwise use the shared bounded down/up mobile probe before
1524
+ // failing with a message naming the selector and attempts.
1525
+ async scrollIntoView(selector) {
1526
+ const query = parseAndroidSelector(selector);
1527
+ let probeSize;
1528
+ const found = await probeScrollIntoView({
1529
+ isVisible: async () => (await client.findElements(query)).length > 0,
1530
+ swipe: async (direction) => {
1531
+ probeSize ??= await client.windowSize();
1532
+ await swipe(direction, scrollToProbeDistanceFor(direction, probeSize), probeSize);
1533
+ }
1534
+ });
1535
+ if (found) {
1536
+ return;
1537
+ }
1538
+ throw new Error(
1539
+ `scrollTo: element "${selector}" not visible after ${MAX_SCROLL_TO_SWIPES} scroll attempts on the Android target`
1540
+ );
1045
1541
  },
1046
1542
  setInputFiles() {
1047
1543
  return rejectUnsupported("setInputFiles");
@@ -1097,7 +1593,7 @@ function createAndroidDriver(client, options = {}) {
1097
1593
  },
1098
1594
  async screenshot(screenshotOptions) {
1099
1595
  const png = await client.screenshotPng();
1100
- import_node_fs2.default.writeFileSync(screenshotOptions.path, png);
1596
+ import_node_fs3.default.writeFileSync(screenshotOptions.path, png);
1101
1597
  },
1102
1598
  // network / dialogs / downloads (all web-only) -------------------------
1103
1599
  onResponse(_handler) {
@@ -1123,14 +1619,14 @@ function createAndroidDriver(client, options = {}) {
1123
1619
 
1124
1620
  // src/browser/android-helper.ts
1125
1621
  var import_node_module = require("module");
1126
- var import_node_child_process4 = require("child_process");
1127
- var import_node_fs4 = __toESM(require("fs"), 1);
1128
- var import_node_path3 = __toESM(require("path"), 1);
1622
+ var import_node_child_process5 = require("child_process");
1623
+ var import_node_fs5 = __toESM(require("fs"), 1);
1624
+ var import_node_path5 = __toESM(require("path"), 1);
1129
1625
 
1130
1626
  // src/browser/android-adb.ts
1131
- var import_node_child_process2 = require("child_process");
1627
+ var import_node_child_process3 = require("child_process");
1132
1628
  var execFileAdbRunner = (args, options) => new Promise((resolve) => {
1133
- (0, import_node_child_process2.execFile)(
1629
+ (0, import_node_child_process3.execFile)(
1134
1630
  "adb",
1135
1631
  args,
1136
1632
  { encoding: "utf-8", timeout: options?.timeoutMs, maxBuffer: 16 * 1024 * 1024 },
@@ -1146,7 +1642,7 @@ var execFileAdbRunner = (args, options) => new Promise((resolve) => {
1146
1642
  );
1147
1643
  });
1148
1644
  var spawnAdbProcess = (args) => {
1149
- const child = (0, import_node_child_process2.spawn)("adb", args, { stdio: "ignore" });
1645
+ const child = (0, import_node_child_process3.spawn)("adb", args, { stdio: "ignore" });
1150
1646
  child.on("error", () => {
1151
1647
  });
1152
1648
  return { kill: () => child.kill() };
@@ -1370,12 +1866,12 @@ var Uia2Transport = class {
1370
1866
  * {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
1371
1867
  * per-request deadline elapses.
1372
1868
  */
1373
- async request(method, path18, body, timeoutMs) {
1869
+ async request(method, path20, body, timeoutMs) {
1374
1870
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
1375
1871
  const controller = new AbortController();
1376
1872
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
1377
1873
  timer.unref?.();
1378
- const url = `${this.baseUrl}${path18}`;
1874
+ const url = `${this.baseUrl}${path20}`;
1379
1875
  let response;
1380
1876
  try {
1381
1877
  response = await this.fetchImpl(url, {
@@ -1387,7 +1883,7 @@ var Uia2Transport = class {
1387
1883
  } catch (error) {
1388
1884
  if (controller.signal.aborted) {
1389
1885
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
1390
- throw new Error(`uiautomator2 request ${method} ${path18} timed out after ${shown}`);
1886
+ throw new Error(`uiautomator2 request ${method} ${path20} timed out after ${shown}`);
1391
1887
  }
1392
1888
  throw error instanceof Error ? error : new Error(String(error));
1393
1889
  } finally {
@@ -1398,7 +1894,7 @@ var Uia2Transport = class {
1398
1894
  if (!response.ok) {
1399
1895
  const wdError = extractWebdriverError(parsed);
1400
1896
  throw new Uia2HttpError(
1401
- `uiautomator2 ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
1897
+ `uiautomator2 ${method} ${path20} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
1402
1898
  response.status,
1403
1899
  wdError
1404
1900
  );
@@ -1481,10 +1977,10 @@ function sleep(ms) {
1481
1977
  }
1482
1978
  function createUia2AgentClient(transport, sessionId, options = {}) {
1483
1979
  const base = `/session/${sessionId}`;
1484
- async function locate(query, path18) {
1980
+ async function locate(query, path20) {
1485
1981
  return transport.request(
1486
1982
  "POST",
1487
- `${base}${path18}`,
1983
+ `${base}${path20}`,
1488
1984
  androidQueryToLocator(query, { appPackage: options.appPackage })
1489
1985
  );
1490
1986
  }
@@ -1519,6 +2015,13 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
1519
2015
  async pressKeyCode(keyCode) {
1520
2016
  await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
1521
2017
  },
2018
+ async windowSize() {
2019
+ const value = await transport.request("GET", `${base}/window/current/size`);
2020
+ return toScreenSize(value, "uiautomator2 /window/current/size");
2021
+ },
2022
+ async performActions(actions) {
2023
+ await transport.request("POST", `${base}/actions`, { actions: [actions] });
2024
+ },
1522
2025
  async screenshotPng() {
1523
2026
  const value = await transport.request("GET", `${base}/screenshot`);
1524
2027
  if (typeof value !== "string") {
@@ -1540,9 +2043,9 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
1540
2043
  }
1541
2044
 
1542
2045
  // src/config/target.ts
1543
- var import_node_child_process3 = require("child_process");
1544
- var import_node_fs3 = __toESM(require("fs"), 1);
1545
- var import_node_path2 = __toESM(require("path"), 1);
2046
+ var import_node_child_process4 = require("child_process");
2047
+ var import_node_fs4 = __toESM(require("fs"), 1);
2048
+ var import_node_path4 = __toESM(require("path"), 1);
1546
2049
  var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
1547
2050
  "navigate",
1548
2051
  "waitForUrl",
@@ -1555,10 +2058,17 @@ var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
1555
2058
  "select",
1556
2059
  "selectOption",
1557
2060
  "setInputFiles",
1558
- "waitForDownload",
1559
- "scroll"
1560
- // directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
2061
+ "waitForDownload"
1561
2062
  ]);
2063
+ var MACOS_UNSUPPORTED_STEP_TYPES = /* @__PURE__ */ new Set(["scroll"]);
2064
+ function macosUnsupportedReason(step) {
2065
+ for (const type of MACOS_UNSUPPORTED_STEP_TYPES) {
2066
+ if (type in step) {
2067
+ return type;
2068
+ }
2069
+ }
2070
+ return null;
2071
+ }
1562
2072
  function webOnlyReason(step) {
1563
2073
  for (const type of WEB_ONLY_STEP_TYPES) {
1564
2074
  if (type in step) {
@@ -1594,6 +2104,14 @@ function assertStepsSupportedByTarget(steps, target) {
1594
2104
  `Step "${reason}" is not supported by the ${label} target. It is web-only; use a portable step (click, fill, type, press, wait, assert visible, screenshot, etc.).`
1595
2105
  );
1596
2106
  }
2107
+ if (target === "macos") {
2108
+ const macReason = macosUnsupportedReason(step);
2109
+ if (macReason) {
2110
+ throw new Error(
2111
+ `Step "${macReason}" is not supported by the macOS target. Directional scroll is a touch swipe available on the iOS and Android targets; there is no macOS accessibility equivalent (use scrollTo to bring a specific element into view instead).`
2112
+ );
2113
+ }
2114
+ }
1597
2115
  if ("if" in step) {
1598
2116
  assertStepsSupportedByTarget(step.if.then, target);
1599
2117
  if (step.if.else) {
@@ -1627,7 +2145,7 @@ function looksLikeIosAppPath(app) {
1627
2145
  }
1628
2146
  if (trimmed.toLowerCase().endsWith(".app")) {
1629
2147
  try {
1630
- return import_node_fs3.default.statSync(normalizeAppPath(app)).isDirectory();
2148
+ return import_node_fs4.default.statSync(normalizeAppPath(app)).isDirectory();
1631
2149
  } catch {
1632
2150
  return false;
1633
2151
  }
@@ -1635,29 +2153,29 @@ function looksLikeIosAppPath(app) {
1635
2153
  return false;
1636
2154
  }
1637
2155
  function normalizeAppPath(app) {
1638
- return import_node_path2.default.resolve(trimTrailingPathSeparators(app));
2156
+ return import_node_path4.default.resolve(trimTrailingPathSeparators(app));
1639
2157
  }
1640
2158
  function parseBundleIdentifier(plist) {
1641
2159
  const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
1642
2160
  return match?.[1]?.trim() || null;
1643
2161
  }
1644
2162
  function readBundleIdentifier(appPath, ...plistSubPath) {
1645
- const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), ...plistSubPath);
1646
- if (!import_node_fs3.default.existsSync(infoPlistPath)) {
2163
+ const infoPlistPath = import_node_path4.default.join(normalizeAppPath(appPath), ...plistSubPath);
2164
+ if (!import_node_fs4.default.existsSync(infoPlistPath)) {
1647
2165
  return null;
1648
2166
  }
1649
2167
  try {
1650
- const parsed = parseBundleIdentifier(import_node_fs3.default.readFileSync(infoPlistPath, "utf-8"));
2168
+ const parsed = parseBundleIdentifier(import_node_fs4.default.readFileSync(infoPlistPath, "utf-8"));
1651
2169
  if (parsed) {
1652
2170
  return parsed;
1653
2171
  }
1654
2172
  } catch {
1655
2173
  }
1656
- if (process.platform !== "darwin" || !import_node_fs3.default.existsSync("/usr/bin/plutil")) {
2174
+ if (process.platform !== "darwin" || !import_node_fs4.default.existsSync("/usr/bin/plutil")) {
1657
2175
  return null;
1658
2176
  }
1659
2177
  try {
1660
- const output = (0, import_node_child_process3.execFileSync)(
2178
+ const output = (0, import_node_child_process4.execFileSync)(
1661
2179
  "/usr/bin/plutil",
1662
2180
  ["-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlistPath],
1663
2181
  { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1e3 }
@@ -1679,7 +2197,7 @@ function appBundleAllowedIdentities(app, readBundleId, isAppPath = looksLikeAppB
1679
2197
  const normalizedPath = normalizeAppPath(app);
1680
2198
  identities.add(trimTrailingPathSeparators(app));
1681
2199
  identities.add(normalizedPath);
1682
- const bundleName = import_node_path2.default.basename(normalizedPath).replace(/\.app$/i, "");
2200
+ const bundleName = import_node_path4.default.basename(normalizedPath).replace(/\.app$/i, "");
1683
2201
  if (bundleName) {
1684
2202
  identities.add(bundleName);
1685
2203
  }
@@ -1707,9 +2225,9 @@ function looksLikeApkPath(app) {
1707
2225
  return trimmed.includes("/") || trimmed.includes("\\") || trimmed.toLowerCase().endsWith(".apk");
1708
2226
  }
1709
2227
  function normalizeAndroidApkPath(app) {
1710
- const resolved = import_node_path2.default.resolve(trimTrailingPathSeparators(app));
2228
+ const resolved = import_node_path4.default.resolve(trimTrailingPathSeparators(app));
1711
2229
  try {
1712
- return import_node_fs3.default.realpathSync.native(resolved);
2230
+ return import_node_fs4.default.realpathSync.native(resolved);
1713
2231
  } catch {
1714
2232
  return resolved;
1715
2233
  }
@@ -1752,12 +2270,12 @@ function resolveAgentApks(requireFn = (0, import_node_module.createRequire)(impo
1752
2270
  "The Android target requires the `appium-uiautomator2-server` package (its prebuilt APKs). It is an optional dependency of prowl-tools; it may have been skipped (--omit=optional) or failed to install. Restore it for a global Prowl install with: npm install -g appium-uiautomator2-server@10.6.2. If Prowl is installed locally in a project, run: npm install appium-uiautomator2-server@10.6.2"
1753
2271
  );
1754
2272
  }
1755
- const pkgDir = import_node_path3.default.dirname(pkgJsonPath);
2273
+ const pkgDir = import_node_path5.default.dirname(pkgJsonPath);
1756
2274
  const version = requireFn(pkgJsonPath).version;
1757
- const serverApk = import_node_path3.default.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
1758
- const testApk = import_node_path3.default.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
2275
+ const serverApk = import_node_path5.default.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
2276
+ const testApk = import_node_path5.default.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
1759
2277
  for (const apk of [serverApk, testApk]) {
1760
- if (!import_node_fs4.default.existsSync(apk)) {
2278
+ if (!import_node_fs5.default.existsSync(apk)) {
1761
2279
  throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
1762
2280
  }
1763
2281
  }
@@ -1769,7 +2287,7 @@ function looksLikeApk(app) {
1769
2287
  var execFileAaptResolver = async (apkPath) => {
1770
2288
  for (const tool of ["aapt", "aapt2"]) {
1771
2289
  const output = await new Promise((resolve) => {
1772
- (0, import_node_child_process4.execFile)(
2290
+ (0, import_node_child_process5.execFile)(
1773
2291
  tool,
1774
2292
  ["dump", "badging", apkPath],
1775
2293
  { encoding: "utf-8", timeout: 2e4, maxBuffer: 8 * 1024 * 1024 },
@@ -1803,8 +2321,8 @@ async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
1803
2321
  assertAndroidAppAllowed(allowedApps, app);
1804
2322
  return app;
1805
2323
  }
1806
- const apkPath = import_node_path3.default.resolve(app);
1807
- if (!import_node_fs4.default.existsSync(apkPath)) {
2324
+ const apkPath = import_node_path5.default.resolve(app);
2325
+ if (!import_node_fs5.default.existsSync(apkPath)) {
1808
2326
  throw new Error(`APK not found: ${apkPath}`);
1809
2327
  }
1810
2328
  const pkg = await aaptResolver(apkPath);
@@ -1864,6 +2382,7 @@ async function launchAndroidSession(options) {
1864
2382
  readyDeadlineMs,
1865
2383
  appPackage: pkg
1866
2384
  });
2385
+ await launchPackage(runner, serial, pkg);
1867
2386
  const driver = createAndroidDriver(client, { appLabel: pkg });
1868
2387
  return { client, driver, package: pkg, serial, teardown };
1869
2388
  } catch (error) {
@@ -1884,6 +2403,7 @@ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
1884
2403
  ]);
1885
2404
  var WAIT_POLL_INTERVAL_MS2 = 250;
1886
2405
  var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
2406
+ var DISPLAYED_CHECK_CONCURRENCY = 4;
1887
2407
  var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
1888
2408
  function escapePredicateArg(value) {
1889
2409
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
@@ -1958,6 +2478,64 @@ function createIosDriver(client, options) {
1958
2478
  async function fillSelector(selector, value) {
1959
2479
  await client.setValue(await resolveOne(selector), value);
1960
2480
  }
2481
+ async function swipe(direction, amount, size) {
2482
+ const actualSize = size ?? await client.windowSize();
2483
+ const { actions } = buildDirectionalSwipe(direction, actualSize, amount);
2484
+ await client.performActions(actions);
2485
+ }
2486
+ async function visibleElementIds(query) {
2487
+ const ids = await client.findElements(query);
2488
+ const visible = Array.from({ length: ids.length }, () => null);
2489
+ let nextIndex = 0;
2490
+ const workerCount = Math.min(DISPLAYED_CHECK_CONCURRENCY, ids.length);
2491
+ await Promise.all(
2492
+ Array.from({ length: workerCount }, async () => {
2493
+ for (; ; ) {
2494
+ const index = nextIndex;
2495
+ nextIndex += 1;
2496
+ if (index >= ids.length) {
2497
+ return;
2498
+ }
2499
+ const id = ids[index];
2500
+ if (await client.isDisplayed(id)) {
2501
+ visible[index] = id;
2502
+ }
2503
+ }
2504
+ })
2505
+ );
2506
+ return visible.filter((id) => id !== null);
2507
+ }
2508
+ async function hasVisibleElement(query) {
2509
+ const ids = await client.findElements(query);
2510
+ if (ids.length === 0) {
2511
+ return false;
2512
+ }
2513
+ if (await client.isDisplayed(ids[0])) {
2514
+ return true;
2515
+ }
2516
+ let found = false;
2517
+ let nextIndex = 1;
2518
+ const workerCount = Math.min(DISPLAYED_CHECK_CONCURRENCY, ids.length - 1);
2519
+ await Promise.all(
2520
+ Array.from({ length: workerCount }, async () => {
2521
+ for (; ; ) {
2522
+ if (found) {
2523
+ return;
2524
+ }
2525
+ const index = nextIndex;
2526
+ nextIndex += 1;
2527
+ if (index >= ids.length) {
2528
+ return;
2529
+ }
2530
+ if (await client.isDisplayed(ids[index])) {
2531
+ found = true;
2532
+ return;
2533
+ }
2534
+ }
2535
+ })
2536
+ );
2537
+ return found;
2538
+ }
1961
2539
  async function pressKey(key) {
1962
2540
  const name = key.trim().toLowerCase();
1963
2541
  if (name === "enter" || name === "return") {
@@ -1989,6 +2567,9 @@ function createIosDriver(client, options) {
1989
2567
  async count(selector) {
1990
2568
  return (await client.findElements(parseIosSelector(selector))).length;
1991
2569
  },
2570
+ async visibleCount(selector) {
2571
+ return (await visibleElementIds(parseIosSelector(selector))).length;
2572
+ },
1992
2573
  async textContent(selector) {
1993
2574
  const id = await client.findElement(parseIosSelector(selector));
1994
2575
  if (id === null) {
@@ -2013,15 +2594,38 @@ function createIosDriver(client, options) {
2013
2594
  hover() {
2014
2595
  return rejectUnsupported("hover");
2015
2596
  },
2016
- scrollIntoView() {
2017
- return rejectUnsupported("scrollTo");
2597
+ // Screen-centred swipe via the W3C actions endpoint (PROWL-080). Direction
2598
+ // semantics match the web step: scrolling "down" reveals lower content, so
2599
+ // the finger drags up. See ./touch-gestures.ts.
2600
+ async scroll(direction, amount) {
2601
+ await swipe(direction, amount);
2602
+ },
2603
+ // Resolve the element, short-circuiting only if WDA reports a matching
2604
+ // element displayed in the viewport; hierarchy-only matches can be offscreen.
2605
+ // Otherwise use the shared bounded down/up mobile probe before failing.
2606
+ async scrollIntoView(selector) {
2607
+ const query = parseIosSelector(selector);
2608
+ let probeSize;
2609
+ const found = await probeScrollIntoView({
2610
+ isVisible: () => hasVisibleElement(query),
2611
+ swipe: async (direction) => {
2612
+ probeSize ??= await client.windowSize();
2613
+ await swipe(direction, scrollToProbeDistanceFor(direction, probeSize), probeSize);
2614
+ }
2615
+ });
2616
+ if (found) {
2617
+ return;
2618
+ }
2619
+ throw new Error(
2620
+ `scrollTo: element "${selector}" not visible after ${MAX_SCROLL_TO_SWIPES} scroll attempts on the iOS target`
2621
+ );
2018
2622
  },
2019
2623
  setInputFiles() {
2020
2624
  return rejectUnsupported("setInputFiles");
2021
2625
  },
2022
2626
  // semantic locators ----------------------------------------------------
2023
2627
  async countByRole(role, name) {
2024
- return (await client.findElements({ by: "role", role, name })).length;
2628
+ return (await visibleElementIds({ by: "role", role, name })).length;
2025
2629
  },
2026
2630
  async clickFirstByRole(role, name) {
2027
2631
  const id = await client.findElement({ by: "role", role, name });
@@ -2031,7 +2635,7 @@ function createIosDriver(client, options) {
2031
2635
  await client.click(id);
2032
2636
  },
2033
2637
  async countByLabel(label) {
2034
- return (await client.findElements({ by: "label", value: label })).length;
2638
+ return (await visibleElementIds({ by: "label", value: label })).length;
2035
2639
  },
2036
2640
  async fillFirstByLabel(label, value) {
2037
2641
  const id = await client.findElement({ by: "label", value: label });
@@ -2049,7 +2653,7 @@ function createIosDriver(client, options) {
2049
2653
  const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
2050
2654
  const deadline = Date.now() + timeoutMs;
2051
2655
  for (; ; ) {
2052
- if ((await client.findElements(query)).length > 0) {
2656
+ if (await hasVisibleElement(query)) {
2053
2657
  return;
2054
2658
  }
2055
2659
  if (Date.now() >= deadline) {
@@ -2095,19 +2699,19 @@ function createIosDriver(client, options) {
2095
2699
 
2096
2700
  // src/browser/ios-helper.ts
2097
2701
  var import_node_module2 = require("module");
2098
- var import_node_child_process6 = require("child_process");
2099
- var import_node_fs5 = __toESM(require("fs"), 1);
2100
- var import_node_os2 = __toESM(require("os"), 1);
2101
- var import_node_path5 = __toESM(require("path"), 1);
2702
+ var import_node_child_process7 = require("child_process");
2703
+ var import_node_fs6 = __toESM(require("fs"), 1);
2704
+ var import_node_os5 = __toESM(require("os"), 1);
2705
+ var import_node_path7 = __toESM(require("path"), 1);
2102
2706
 
2103
2707
  // src/browser/ios-simctl.ts
2104
- var import_node_child_process5 = require("child_process");
2708
+ var import_node_child_process6 = require("child_process");
2105
2709
  var import_promises = require("fs/promises");
2106
2710
  var import_node_net = __toESM(require("net"), 1);
2107
- var import_node_os = __toESM(require("os"), 1);
2108
- var import_node_path4 = __toESM(require("path"), 1);
2711
+ var import_node_os4 = __toESM(require("os"), 1);
2712
+ var import_node_path6 = __toESM(require("path"), 1);
2109
2713
  var spawnXcrunProcess = (args, options) => {
2110
- const child = (0, import_node_child_process5.spawn)("xcrun", args, {
2714
+ const child = (0, import_node_child_process6.spawn)("xcrun", args, {
2111
2715
  stdio: "ignore",
2112
2716
  env: options?.env ? { ...process.env, ...options.env } : process.env
2113
2717
  });
@@ -2119,10 +2723,10 @@ var spawnXcrunProcess = (args, options) => {
2119
2723
  }
2120
2724
  };
2121
2725
  };
2122
- var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path4.default.join(import_node_os.default.tmpdir(), "prowl-ios-simulator-locks");
2726
+ var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path6.default.join(import_node_os4.default.tmpdir(), "prowl-ios-simulator-locks");
2123
2727
  var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
2124
2728
  var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
2125
- (0, import_node_child_process5.execFile)(
2729
+ (0, import_node_child_process6.execFile)(
2126
2730
  "xcrun",
2127
2731
  args,
2128
2732
  {
@@ -2159,7 +2763,7 @@ function isProcessAlive(pid) {
2159
2763
  }
2160
2764
  async function removeStaleSimulatorLock(lockPath) {
2161
2765
  try {
2162
- const ownerText = await (0, import_promises.readFile)(import_node_path4.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
2766
+ const ownerText = await (0, import_promises.readFile)(import_node_path6.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
2163
2767
  const owner = JSON.parse(ownerText);
2164
2768
  if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
2165
2769
  await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
@@ -2177,7 +2781,7 @@ function simulatorReservedError(udid) {
2177
2781
  }
2178
2782
  async function reserveSimulatorUdid(udid, options = {}) {
2179
2783
  const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
2180
- const lockPath = import_node_path4.default.join(lockRoot, simulatorLockName(udid));
2784
+ const lockPath = import_node_path6.default.join(lockRoot, simulatorLockName(udid));
2181
2785
  await (0, import_promises.mkdir)(lockRoot, { recursive: true });
2182
2786
  for (let attempt = 0; attempt < 2; attempt += 1) {
2183
2787
  try {
@@ -2201,7 +2805,7 @@ async function reserveSimulatorUdid(udid, options = {}) {
2201
2805
  };
2202
2806
  try {
2203
2807
  await (0, import_promises.writeFile)(
2204
- import_node_path4.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
2808
+ import_node_path6.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
2205
2809
  `${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
2206
2810
  `,
2207
2811
  { flag: "wx" }
@@ -2393,15 +2997,15 @@ var WdaTransport = class {
2393
2997
  * {@link WdaHttpError} on a non-2xx response, or a timeout error when the
2394
2998
  * per-request deadline elapses.
2395
2999
  */
2396
- async requestFull(method, path18, body, timeoutMs) {
3000
+ async requestFull(method, path20, body, timeoutMs) {
2397
3001
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
2398
3002
  const controller = new AbortController();
2399
3003
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
2400
3004
  timer.unref?.();
2401
- const url = `${this.baseUrl}${path18}`;
3005
+ const url = `${this.baseUrl}${path20}`;
2402
3006
  const timeoutError = () => {
2403
3007
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
2404
- return new Error(`WebDriverAgent request ${method} ${path18} timed out after ${shown}`);
3008
+ return new Error(`WebDriverAgent request ${method} ${path20} timed out after ${shown}`);
2405
3009
  };
2406
3010
  let response;
2407
3011
  try {
@@ -2433,7 +3037,7 @@ var WdaTransport = class {
2433
3037
  if (!response.ok) {
2434
3038
  const wdError = extractWebdriverError2(parsed);
2435
3039
  throw new WdaHttpError(
2436
- `WebDriverAgent ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
3040
+ `WebDriverAgent ${method} ${path20} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2437
3041
  response.status,
2438
3042
  wdError
2439
3043
  );
@@ -2441,8 +3045,8 @@ var WdaTransport = class {
2441
3045
  return parsed;
2442
3046
  }
2443
3047
  /** Like {@link requestFull} but returns just the `value` field. */
2444
- async request(method, path18, body, timeoutMs) {
2445
- const parsed = await this.requestFull(method, path18, body, timeoutMs);
3048
+ async request(method, path20, body, timeoutMs) {
3049
+ const parsed = await this.requestFull(method, path20, body, timeoutMs);
2446
3050
  return parsed?.value;
2447
3051
  }
2448
3052
  };
@@ -2518,8 +3122,8 @@ function sleep2(ms) {
2518
3122
  }
2519
3123
  function createWdaAgentClient(transport, sessionId) {
2520
3124
  const base = `/session/${sessionId}`;
2521
- async function locate(query, path18) {
2522
- return transport.request("POST", `${base}${path18}`, iosQueryToLocator(query));
3125
+ async function locate(query, path20) {
3126
+ return transport.request("POST", `${base}${path20}`, iosQueryToLocator(query));
2523
3127
  }
2524
3128
  return {
2525
3129
  async findElement(query) {
@@ -2549,12 +3153,29 @@ function createWdaAgentClient(transport, sessionId) {
2549
3153
  const value = await transport.request("GET", `${base}/element/${elementId}/text`);
2550
3154
  return typeof value === "string" ? value : value == null ? null : String(value);
2551
3155
  },
3156
+ async isDisplayed(elementId) {
3157
+ try {
3158
+ return await transport.request("GET", `${base}/element/${elementId}/displayed`) === true;
3159
+ } catch (error) {
3160
+ if (isNoSuchElement2(error)) {
3161
+ return false;
3162
+ }
3163
+ throw error;
3164
+ }
3165
+ },
2552
3166
  async sendKeys(keys) {
2553
3167
  await transport.request("POST", `${base}/wda/keys`, { value: keys });
2554
3168
  },
2555
3169
  async homescreen() {
2556
3170
  await transport.request("POST", "/wda/homescreen", {});
2557
3171
  },
3172
+ async windowSize() {
3173
+ const value = await transport.request("GET", `${base}/window/size`);
3174
+ return toScreenSize(value, "WebDriverAgent /window/size");
3175
+ },
3176
+ async performActions(actions) {
3177
+ await transport.request("POST", `${base}/actions`, { actions: [actions] });
3178
+ },
2558
3179
  async source() {
2559
3180
  const value = await transport.request("GET", "/source");
2560
3181
  if (typeof value !== "string") {
@@ -2595,41 +3216,41 @@ function resolveWdaProject(requireFn = (0, import_node_module2.createRequire)(im
2595
3216
  "The iOS target requires the `appium-webdriveragent` package (its WDA Xcode project). It is an optional dependency of prowl-tools; it may have been skipped (--omit=optional) or failed to install. Restore it for a global Prowl install with: npm install -g appium-webdriveragent@16.4.0. If Prowl is installed locally in a project, run: npm install appium-webdriveragent@16.4.0"
2596
3217
  );
2597
3218
  }
2598
- const pkgDir = import_node_path5.default.dirname(pkgJsonPath);
3219
+ const pkgDir = import_node_path7.default.dirname(pkgJsonPath);
2599
3220
  const version = requireFn(pkgJsonPath).version;
2600
- const projectPath = import_node_path5.default.join(pkgDir, "WebDriverAgent.xcodeproj");
2601
- if (!import_node_fs5.default.existsSync(projectPath)) {
3221
+ const projectPath = import_node_path7.default.join(pkgDir, "WebDriverAgent.xcodeproj");
3222
+ if (!import_node_fs6.default.existsSync(projectPath)) {
2602
3223
  throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
2603
3224
  }
2604
3225
  return { projectPath, version };
2605
3226
  }
2606
- function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os2.default.homedir()) {
2607
- return import_node_path5.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
3227
+ function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os5.default.homedir()) {
3228
+ return import_node_path7.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
2608
3229
  }
2609
3230
  function productsDir(derivedDataPath) {
2610
- return import_node_path5.default.join(derivedDataPath, "Build", "Products");
3231
+ return import_node_path7.default.join(derivedDataPath, "Build", "Products");
2611
3232
  }
2612
3233
  function findXctestrunIn(dir) {
2613
3234
  let entries;
2614
3235
  try {
2615
- entries = import_node_fs5.default.readdirSync(dir);
3236
+ entries = import_node_fs6.default.readdirSync(dir);
2616
3237
  } catch {
2617
3238
  return null;
2618
3239
  }
2619
3240
  const match = entries.filter((name) => name.endsWith(".xctestrun") && !name.startsWith(PREPARED_XCTESTRUN_PREFIX)).sort()[0];
2620
- return match ? import_node_path5.default.join(dir, match) : null;
3241
+ return match ? import_node_path7.default.join(dir, match) : null;
2621
3242
  }
2622
3243
  function resolveOverrideXctestrun(override) {
2623
- if (!import_node_fs5.default.existsSync(override)) {
3244
+ if (!import_node_fs6.default.existsSync(override)) {
2624
3245
  throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
2625
3246
  }
2626
3247
  if (override.endsWith(".xctestrun")) {
2627
3248
  return override;
2628
3249
  }
2629
3250
  const candidates = [];
2630
- const stat = import_node_fs5.default.statSync(override);
3251
+ const stat = import_node_fs6.default.statSync(override);
2631
3252
  if (override.endsWith(".app")) {
2632
- candidates.push(import_node_path5.default.dirname(import_node_path5.default.dirname(override)));
3253
+ candidates.push(import_node_path7.default.dirname(import_node_path7.default.dirname(override)));
2633
3254
  } else if (stat.isDirectory()) {
2634
3255
  candidates.push(override, productsDir(override));
2635
3256
  }
@@ -2646,7 +3267,7 @@ function resolveOverrideXctestrun(override) {
2646
3267
  async function resolveWdaTestRun(options = {}) {
2647
3268
  const runner = options.runner ?? execFileXcrunRunner;
2648
3269
  const env = options.env ?? process.env;
2649
- const homeDir = options.homeDir ?? import_node_os2.default.homedir();
3270
+ const homeDir = options.homeDir ?? import_node_os5.default.homedir();
2650
3271
  const log = options.logger ?? ((message) => process.stderr.write(`${message}
2651
3272
  `));
2652
3273
  const override = env.PROWL_WDA_RUNNER;
@@ -2668,7 +3289,7 @@ async function resolveWdaTestRun(options = {}) {
2668
3289
  log(
2669
3290
  `Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
2670
3291
  );
2671
- import_node_fs5.default.mkdirSync(cacheDir, { recursive: true });
3292
+ import_node_fs6.default.mkdirSync(cacheDir, { recursive: true });
2672
3293
  const result = await runner(
2673
3294
  [
2674
3295
  "xcodebuild",
@@ -2734,7 +3355,7 @@ function injectUsePortIntoXctestrun(plist, port) {
2734
3355
  }
2735
3356
  function runPlutil(args) {
2736
3357
  return new Promise((resolve, reject) => {
2737
- (0, import_node_child_process6.execFile)(
3358
+ (0, import_node_child_process7.execFile)(
2738
3359
  "plutil",
2739
3360
  args,
2740
3361
  { encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 },
@@ -2762,13 +3383,13 @@ var defaultWdaTestRunPreparer = async ({ xctestrunPath, port }) => {
2762
3383
  }
2763
3384
  const injected = injectUsePortIntoXctestrun(parsed, port);
2764
3385
  const stem = `${PREPARED_XCTESTRUN_PREFIX}${port}-${process.pid}`;
2765
- const outPath = import_node_path5.default.join(import_node_path5.default.dirname(xctestrunPath), `${stem}.xctestrun`);
2766
- const jsonPath = import_node_path5.default.join(import_node_os2.default.tmpdir(), `${stem}.json`);
2767
- import_node_fs5.default.writeFileSync(jsonPath, JSON.stringify(injected));
3386
+ const outPath = import_node_path7.default.join(import_node_path7.default.dirname(xctestrunPath), `${stem}.xctestrun`);
3387
+ const jsonPath = import_node_path7.default.join(import_node_os5.default.tmpdir(), `${stem}.json`);
3388
+ import_node_fs6.default.writeFileSync(jsonPath, JSON.stringify(injected));
2768
3389
  try {
2769
3390
  await runPlutil(["-convert", "xml1", jsonPath, "-o", outPath]);
2770
3391
  } finally {
2771
- import_node_fs5.default.rmSync(jsonPath, { force: true });
3392
+ import_node_fs6.default.rmSync(jsonPath, { force: true });
2772
3393
  }
2773
3394
  return outPath;
2774
3395
  };
@@ -2776,10 +3397,10 @@ function cleanupPreparedTestRun(preparedPath) {
2776
3397
  if (!preparedPath) {
2777
3398
  return;
2778
3399
  }
2779
- if (!import_node_path5.default.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
3400
+ if (!import_node_path7.default.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
2780
3401
  return;
2781
3402
  }
2782
- import_node_fs5.default.rmSync(preparedPath, { force: true });
3403
+ import_node_fs6.default.rmSync(preparedPath, { force: true });
2783
3404
  }
2784
3405
  function wdaTestRunArgs(xctestrunPath, udid) {
2785
3406
  return [
@@ -2801,8 +3422,8 @@ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
2801
3422
  }
2802
3423
  return app;
2803
3424
  }
2804
- const appPath = import_node_path5.default.resolve(app);
2805
- if (!import_node_fs5.default.existsSync(appPath)) {
3425
+ const appPath = import_node_path7.default.resolve(app);
3426
+ if (!import_node_fs6.default.existsSync(appPath)) {
2806
3427
  throw new Error(`.app bundle not found: ${appPath}`);
2807
3428
  }
2808
3429
  const bundleId = readIosBundleIdentifier(appPath);
@@ -2905,12 +3526,12 @@ async function closeIosSession(session) {
2905
3526
  }
2906
3527
 
2907
3528
  // src/runner/index.ts
2908
- var import_node_fs14 = __toESM(require("fs"), 1);
2909
- var import_node_path14 = __toESM(require("path"), 1);
3529
+ var import_node_fs15 = __toESM(require("fs"), 1);
3530
+ var import_node_path16 = __toESM(require("path"), 1);
2910
3531
 
2911
3532
  // src/config/loader.ts
2912
- var import_node_fs6 = __toESM(require("fs"), 1);
2913
- var import_node_path6 = __toESM(require("path"), 1);
3533
+ var import_node_fs7 = __toESM(require("fs"), 1);
3534
+ var import_node_path8 = __toESM(require("path"), 1);
2914
3535
  var import_yaml = __toESM(require("yaml"), 1);
2915
3536
  var import_dotenv = __toESM(require("dotenv"), 1);
2916
3537
 
@@ -2928,7 +3549,7 @@ function isValidHuntName(name) {
2928
3549
  function assertValidHuntName(name) {
2929
3550
  if (!isValidHuntName(name)) {
2930
3551
  throw new Error(
2931
- `Invalid hunt name: "${name}". Use only letters, numbers, hyphens, underscores, and forward slashes.`
3552
+ `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).`
2932
3553
  );
2933
3554
  }
2934
3555
  }
@@ -3249,260 +3870,8 @@ var huntSchema = import_zod.z.object({
3249
3870
  }).strict().optional()
3250
3871
  }).strict();
3251
3872
 
3252
- // src/config/loader.ts
3253
- var DEFAULT_WEB_URL = "http://localhost:3000";
3254
- var DEFAULT_CONFIG = {
3255
- target: {
3256
- type: "web",
3257
- url: DEFAULT_WEB_URL
3258
- },
3259
- browser: {
3260
- headless: true,
3261
- slowMo: 0,
3262
- timeout: 3e4,
3263
- engine: "chromium",
3264
- viewport: { width: 1280, height: 720 }
3265
- },
3266
- artifacts: {
3267
- screenshots: "on-failure",
3268
- networkHar: false,
3269
- console: true,
3270
- junit: false
3271
- },
3272
- assertions: {
3273
- noConsoleErrors: true,
3274
- noNetworkErrors: true,
3275
- maxTotalTimeMs: 3e4,
3276
- networkIgnorePatterns: []
3277
- },
3278
- guardrails: {
3279
- maxSteps: 50,
3280
- allowedDomains: ["localhost", "127.0.0.1", "0.0.0.0"],
3281
- allowedApps: [],
3282
- forbiddenSelectors: ["[data-danger]", ".delete-btn"],
3283
- selfHealing: false
3284
- },
3285
- auth: {
3286
- storageStatePath: ".prowl/auth-state.json"
3287
- },
3288
- history: {
3289
- maxRuns: 100
3290
- }
3291
- };
3292
- var CONFIG_DIR = ".prowl";
3293
- var LEGACY_CONFIG_DIR = ".prowlqa";
3294
- var legacyDirWarned = false;
3295
- function warnLegacyConfigDir() {
3296
- if (legacyDirWarned) {
3297
- return;
3298
- }
3299
- legacyDirWarned = true;
3300
- console.warn(
3301
- 'Warning: the ".prowlqa/" config directory is deprecated; rename it to ".prowl/". Support for ".prowlqa/" will be removed in a future release.'
3302
- );
3303
- }
3304
- function findConfigPath(startDir) {
3305
- let current = startDir;
3306
- while (current) {
3307
- for (const dir of [CONFIG_DIR, LEGACY_CONFIG_DIR]) {
3308
- const candidate = import_node_path6.default.join(current, dir, "config.yml");
3309
- if (import_node_fs6.default.existsSync(candidate)) {
3310
- return candidate;
3311
- }
3312
- }
3313
- const parent = import_node_path6.default.dirname(current);
3314
- if (parent === current) {
3315
- break;
3316
- }
3317
- current = parent;
3318
- }
3319
- return null;
3320
- }
3321
- var VIEWPORT_PRESETS = {
3322
- mobile: { width: 375, height: 812 },
3323
- tablet: { width: 768, height: 1024 },
3324
- desktop: { width: 1280, height: 720 }
3325
- };
3326
- function resolveViewport(value) {
3327
- if (value === void 0) {
3328
- return DEFAULT_CONFIG.browser.viewport;
3329
- }
3330
- if (typeof value === "string") {
3331
- const preset = VIEWPORT_PRESETS[value];
3332
- if (!preset) {
3333
- throw new Error(`Unknown viewport preset: "${value}". Use mobile, tablet, or desktop.`);
3334
- }
3335
- return preset;
3336
- }
3337
- return value;
3338
- }
3339
- function resolveTarget(target) {
3340
- const type = target?.type;
3341
- if (type === "macos") {
3342
- return { type: "macos", app: target.app };
3343
- }
3344
- if (type === "android") {
3345
- const androidTarget = target;
3346
- return {
3347
- type: "android",
3348
- app: androidTarget.app,
3349
- ...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
3350
- ...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
3351
- };
3352
- }
3353
- if (type === "ios") {
3354
- const iosTarget = target;
3355
- return {
3356
- type: "ios",
3357
- app: iosTarget.app,
3358
- ...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
3359
- ...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
3360
- };
3361
- }
3362
- return {
3363
- type: "web",
3364
- url: target?.url ?? DEFAULT_WEB_URL
3365
- };
3366
- }
3367
- function mergeConfig(partial) {
3368
- return {
3369
- target: resolveTarget(partial.target),
3370
- browser: {
3371
- headless: partial.browser?.headless ?? DEFAULT_CONFIG.browser.headless,
3372
- slowMo: partial.browser?.slowMo ?? DEFAULT_CONFIG.browser.slowMo,
3373
- timeout: partial.browser?.timeout ?? DEFAULT_CONFIG.browser.timeout,
3374
- engine: partial.browser?.engine ?? DEFAULT_CONFIG.browser.engine,
3375
- channel: partial.browser?.channel,
3376
- viewport: resolveViewport(partial.browser?.viewport)
3377
- },
3378
- artifacts: {
3379
- screenshots: partial.artifacts?.screenshots ?? DEFAULT_CONFIG.artifacts.screenshots,
3380
- networkHar: partial.artifacts?.networkHar ?? DEFAULT_CONFIG.artifacts.networkHar,
3381
- console: partial.artifacts?.console ?? DEFAULT_CONFIG.artifacts.console,
3382
- junit: partial.artifacts?.junit ?? DEFAULT_CONFIG.artifacts.junit
3383
- },
3384
- assertions: {
3385
- noConsoleErrors: partial.assertions?.noConsoleErrors ?? DEFAULT_CONFIG.assertions.noConsoleErrors,
3386
- noNetworkErrors: partial.assertions?.noNetworkErrors ?? DEFAULT_CONFIG.assertions.noNetworkErrors,
3387
- maxTotalTimeMs: partial.assertions?.maxTotalTimeMs ?? DEFAULT_CONFIG.assertions.maxTotalTimeMs,
3388
- networkIgnorePatterns: partial.assertions?.networkIgnorePatterns ?? DEFAULT_CONFIG.assertions.networkIgnorePatterns
3389
- },
3390
- guardrails: {
3391
- maxSteps: partial.guardrails?.maxSteps ?? DEFAULT_CONFIG.guardrails.maxSteps,
3392
- allowedDomains: partial.guardrails?.allowedDomains ?? DEFAULT_CONFIG.guardrails.allowedDomains,
3393
- allowedApps: partial.guardrails?.allowedApps ?? DEFAULT_CONFIG.guardrails.allowedApps,
3394
- forbiddenSelectors: partial.guardrails?.forbiddenSelectors ?? DEFAULT_CONFIG.guardrails.forbiddenSelectors,
3395
- selfHealing: partial.guardrails?.selfHealing ?? DEFAULT_CONFIG.guardrails.selfHealing
3396
- },
3397
- auth: {
3398
- storageStatePath: partial.auth?.storageStatePath ?? (partial.auth !== void 0 ? DEFAULT_CONFIG.auth.storageStatePath : void 0)
3399
- },
3400
- history: {
3401
- maxRuns: partial.history?.maxRuns ?? DEFAULT_CONFIG.history.maxRuns
3402
- },
3403
- bugLog: partial.bugLog,
3404
- tracing: partial.tracing,
3405
- reliability: partial.reliability
3406
- };
3407
- }
3408
- function ensureAllowedDomain(allowed, urlValue) {
3409
- try {
3410
- const host = new URL(urlValue).hostname;
3411
- if (!allowed.includes(host)) {
3412
- return [...allowed, host];
3413
- }
3414
- } catch {
3415
- return allowed;
3416
- }
3417
- return allowed;
3418
- }
3419
- function loadConfig(configPath) {
3420
- const resolvedPath = configPath ? import_node_path6.default.resolve(configPath) : findConfigPath(process.cwd());
3421
- if (!resolvedPath) {
3422
- throw new Error("Could not find .prowl/config.yml. Run `prowl init` first.");
3423
- }
3424
- if (!import_node_fs6.default.existsSync(resolvedPath)) {
3425
- throw new Error(`Config file not found at ${resolvedPath}`);
3426
- }
3427
- const configDir = import_node_path6.default.dirname(resolvedPath);
3428
- if (import_node_path6.default.basename(configDir) === LEGACY_CONFIG_DIR) {
3429
- warnLegacyConfigDir();
3430
- }
3431
- import_dotenv.default.config({ path: import_node_path6.default.join(configDir, ".env"), override: false });
3432
- const raw = import_node_fs6.default.readFileSync(resolvedPath, "utf-8");
3433
- const parsed = import_yaml.default.parse(raw) ?? {};
3434
- const validated = configSchema.parse(parsed);
3435
- const config = mergeConfig(validated);
3436
- if (config.target.type === "web") {
3437
- config.guardrails.allowedDomains = ensureAllowedDomain(
3438
- config.guardrails.allowedDomains,
3439
- config.target.url
3440
- );
3441
- }
3442
- return { config, configPath: resolvedPath, configDir };
3443
- }
3444
- function loadHunt(huntName, configDir) {
3445
- assertValidHuntName(huntName);
3446
- const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
3447
- if (!import_node_fs6.default.existsSync(huntPath)) {
3448
- throw new Error(`Hunt file not found: ${huntPath}`);
3449
- }
3450
- const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
3451
- const parsed = import_yaml.default.parse(raw) ?? {};
3452
- const validated = huntSchema.parse(parsed);
3453
- return validated;
3454
- }
3455
- function loadHuntTags(huntName, configDir) {
3456
- assertValidHuntName(huntName);
3457
- const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
3458
- if (!import_node_fs6.default.existsSync(huntPath)) {
3459
- return [];
3460
- }
3461
- const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
3462
- const parsed = import_yaml.default.parse(raw) ?? {};
3463
- return Array.isArray(parsed.tags) ? parsed.tags : [];
3464
- }
3465
- function loadHuntMeta(huntName, configDir) {
3466
- assertValidHuntName(huntName);
3467
- const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
3468
- if (!import_node_fs6.default.existsSync(huntPath)) {
3469
- return { tags: [] };
3470
- }
3471
- const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
3472
- const parsed = import_yaml.default.parse(raw) ?? {};
3473
- return {
3474
- description: typeof parsed.description === "string" ? parsed.description : void 0,
3475
- tags: Array.isArray(parsed.tags) ? parsed.tags : []
3476
- };
3477
- }
3478
- function listHunts(configDir) {
3479
- const huntsDir = import_node_path6.default.join(configDir, "hunts");
3480
- if (!import_node_fs6.default.existsSync(huntsDir)) {
3481
- return [];
3482
- }
3483
- const stats = import_node_fs6.default.statSync(huntsDir);
3484
- if (!stats.isDirectory()) {
3485
- throw new Error(`Hunts path is not a directory: ${huntsDir}`);
3486
- }
3487
- const results = [];
3488
- function scanDir(dir) {
3489
- const entries = import_node_fs6.default.readdirSync(dir, { withFileTypes: true });
3490
- for (const entry of entries) {
3491
- if (entry.isFile() && entry.name.endsWith(".yml")) {
3492
- const fullPath = import_node_path6.default.join(dir, entry.name);
3493
- const relative = import_node_path6.default.relative(huntsDir, fullPath);
3494
- results.push(relative.replace(/\.yml$/, ""));
3495
- } else if (entry.isDirectory()) {
3496
- scanDir(import_node_path6.default.join(dir, entry.name));
3497
- }
3498
- }
3499
- }
3500
- scanDir(huntsDir);
3501
- return results.sort((a, b) => a.localeCompare(b));
3502
- }
3503
-
3504
3873
  // src/config/interpolate.ts
3505
- var import_node_crypto = __toESM(require("crypto"), 1);
3874
+ var import_node_crypto2 = __toESM(require("crypto"), 1);
3506
3875
  var VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
3507
3876
  function collectInterpolatedValues(input, vars, values) {
3508
3877
  if (typeof input === "string") {
@@ -3541,8 +3910,8 @@ var RANDOM_FIRST_NAMES = ["Alex", "Jordan", "Morgan", "Taylor", "Casey", "Riley"
3541
3910
  var RANDOM_LAST_NAMES = ["Smith", "Johnson", "Brown", "Davis", "Wilson", "Clark", "Hall", "Young"];
3542
3911
  function generateRandomVars(randomSource) {
3543
3912
  const random = randomSource?.random ?? Math.random;
3544
- const randomBytes = randomSource?.randomBytes ?? import_node_crypto.default.randomBytes;
3545
- const randomUUID = randomSource?.randomUUID ?? import_node_crypto.default.randomUUID;
3913
+ const randomBytes = randomSource?.randomBytes ?? import_node_crypto2.default.randomBytes;
3914
+ const randomUUID = randomSource?.randomUUID ?? import_node_crypto2.default.randomUUID;
3546
3915
  const hex = randomBytes(4).toString("hex");
3547
3916
  const firstIndex = Math.floor(random() * RANDOM_FIRST_NAMES.length);
3548
3917
  const lastIndex = Math.floor(random() * RANDOM_LAST_NAMES.length);
@@ -3795,86 +4164,358 @@ function interpolateStep(step, vars, stepPath2, redacted) {
3795
4164
  }
3796
4165
  };
3797
4166
  }
3798
- if ("assertWithAI" in step) {
3799
- return {
3800
- assertWithAI: interpolateString(step.assertWithAI, vars).value
3801
- };
4167
+ if ("assertWithAI" in step) {
4168
+ return {
4169
+ assertWithAI: interpolateString(step.assertWithAI, vars).value
4170
+ };
4171
+ }
4172
+ if ("copyText" in step) {
4173
+ return {
4174
+ copyText: {
4175
+ selector: interpolateString(step.copyText.selector, vars).value,
4176
+ as: step.copyText.as
4177
+ }
4178
+ };
4179
+ }
4180
+ if ("waitForDownload" in step) {
4181
+ if (step.waitForDownload === null) {
4182
+ return { waitForDownload: null };
4183
+ }
4184
+ return {
4185
+ waitForDownload: {
4186
+ ...step.waitForDownload.filename !== void 0 ? { filename: interpolateString(step.waitForDownload.filename, vars).value } : {},
4187
+ ...step.waitForDownload.timeout !== void 0 ? { timeout: step.waitForDownload.timeout } : {}
4188
+ }
4189
+ };
4190
+ }
4191
+ return step;
4192
+ }
4193
+ function interpolateAssertion(assertion, vars) {
4194
+ if ("selectorExists" in assertion) {
4195
+ return { selectorExists: interpolateString(assertion.selectorExists, vars).value };
4196
+ }
4197
+ if ("selectorNotExists" in assertion) {
4198
+ return { selectorNotExists: interpolateString(assertion.selectorNotExists, vars).value };
4199
+ }
4200
+ if ("urlIncludes" in assertion) {
4201
+ return { urlIncludes: interpolateString(assertion.urlIncludes, vars).value };
4202
+ }
4203
+ if ("urlEquals" in assertion) {
4204
+ return { urlEquals: interpolateString(assertion.urlEquals, vars).value };
4205
+ }
4206
+ if ("noConsoleErrors" in assertion) {
4207
+ return { noConsoleErrors: assertion.noConsoleErrors };
4208
+ }
4209
+ if ("noNetworkErrors" in assertion) {
4210
+ return { noNetworkErrors: assertion.noNetworkErrors };
4211
+ }
4212
+ return assertion;
4213
+ }
4214
+ function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
4215
+ const redactedFillSteps = /* @__PURE__ */ new Set();
4216
+ const envVars = Object.fromEntries(
4217
+ Object.entries(env).filter(([, value]) => value !== void 0)
4218
+ );
4219
+ const baseVars = { ...randomVars, ...envVars };
4220
+ const resolvedHuntVars = {};
4221
+ for (const [key, value] of Object.entries(hunt.vars ?? {})) {
4222
+ resolvedHuntVars[key] = interpolateString(value, baseVars).value;
4223
+ }
4224
+ const vars = { ...baseVars, ...resolvedHuntVars };
4225
+ const redactionValues = /* @__PURE__ */ new Set();
4226
+ collectInterpolatedValues(hunt.steps, vars, redactionValues);
4227
+ collectInterpolatedValues(hunt.assertions, vars, redactionValues);
4228
+ const steps = hunt.steps.map(
4229
+ (step, index) => interpolateStep(step, vars, `${index}`, redactedFillSteps)
4230
+ );
4231
+ const assertions = hunt.assertions?.map((assertion) => interpolateAssertion(assertion, vars));
4232
+ return {
4233
+ hunt: {
4234
+ ...hunt,
4235
+ steps,
4236
+ assertions
4237
+ },
4238
+ redactedFillSteps,
4239
+ randomVars,
4240
+ redactionValues: [...redactionValues]
4241
+ };
4242
+ }
4243
+
4244
+ // src/config/loader.ts
4245
+ var DEFAULT_WEB_URL = "http://localhost:3000";
4246
+ var DEFAULT_CONFIG = {
4247
+ target: {
4248
+ type: "web",
4249
+ url: DEFAULT_WEB_URL
4250
+ },
4251
+ browser: {
4252
+ headless: true,
4253
+ slowMo: 0,
4254
+ timeout: 3e4,
4255
+ engine: "chromium",
4256
+ viewport: { width: 1280, height: 720 }
4257
+ },
4258
+ artifacts: {
4259
+ screenshots: "on-failure",
4260
+ networkHar: false,
4261
+ console: true,
4262
+ junit: false
4263
+ },
4264
+ assertions: {
4265
+ noConsoleErrors: true,
4266
+ noNetworkErrors: true,
4267
+ maxTotalTimeMs: 3e4,
4268
+ networkIgnorePatterns: []
4269
+ },
4270
+ guardrails: {
4271
+ maxSteps: 50,
4272
+ allowedDomains: ["localhost", "127.0.0.1", "0.0.0.0"],
4273
+ allowedApps: [],
4274
+ forbiddenSelectors: ["[data-danger]", ".delete-btn"],
4275
+ selfHealing: false
4276
+ },
4277
+ auth: {
4278
+ storageStatePath: ".prowl/auth-state.json"
4279
+ },
4280
+ history: {
4281
+ maxRuns: 100
4282
+ }
4283
+ };
4284
+ var CONFIG_DIR = ".prowl";
4285
+ var LEGACY_CONFIG_DIR = ".prowlqa";
4286
+ var legacyDirWarned = false;
4287
+ function warnLegacyConfigDir() {
4288
+ if (legacyDirWarned) {
4289
+ return;
4290
+ }
4291
+ legacyDirWarned = true;
4292
+ console.warn(
4293
+ 'Warning: the ".prowlqa/" config directory is deprecated; rename it to ".prowl/". Support for ".prowlqa/" will be removed in a future release.'
4294
+ );
4295
+ }
4296
+ function findConfigPath(startDir) {
4297
+ let current = startDir;
4298
+ while (current) {
4299
+ for (const dir of [CONFIG_DIR, LEGACY_CONFIG_DIR]) {
4300
+ const candidate = import_node_path8.default.join(current, dir, "config.yml");
4301
+ if (import_node_fs7.default.existsSync(candidate)) {
4302
+ return candidate;
4303
+ }
4304
+ }
4305
+ const parent = import_node_path8.default.dirname(current);
4306
+ if (parent === current) {
4307
+ break;
4308
+ }
4309
+ current = parent;
4310
+ }
4311
+ return null;
4312
+ }
4313
+ var VIEWPORT_PRESETS = {
4314
+ mobile: { width: 375, height: 812 },
4315
+ tablet: { width: 768, height: 1024 },
4316
+ desktop: { width: 1280, height: 720 }
4317
+ };
4318
+ function resolveViewport(value) {
4319
+ if (value === void 0) {
4320
+ return DEFAULT_CONFIG.browser.viewport;
4321
+ }
4322
+ if (typeof value === "string") {
4323
+ const preset = VIEWPORT_PRESETS[value];
4324
+ if (!preset) {
4325
+ throw new Error(`Unknown viewport preset: "${value}". Use mobile, tablet, or desktop.`);
4326
+ }
4327
+ return preset;
4328
+ }
4329
+ return value;
4330
+ }
4331
+ function resolveTarget(target) {
4332
+ const type = target?.type;
4333
+ if (type === "macos") {
4334
+ return { type: "macos", app: target.app };
4335
+ }
4336
+ if (type === "android") {
4337
+ const androidTarget = target;
4338
+ return {
4339
+ type: "android",
4340
+ app: androidTarget.app,
4341
+ ...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
4342
+ ...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
4343
+ };
4344
+ }
4345
+ if (type === "ios") {
4346
+ const iosTarget = target;
4347
+ return {
4348
+ type: "ios",
4349
+ app: iosTarget.app,
4350
+ ...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
4351
+ ...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
4352
+ };
4353
+ }
4354
+ return {
4355
+ type: "web",
4356
+ url: target?.url ?? DEFAULT_WEB_URL
4357
+ };
4358
+ }
4359
+ function mergeConfig(partial) {
4360
+ return {
4361
+ target: resolveTarget(partial.target),
4362
+ browser: {
4363
+ headless: partial.browser?.headless ?? DEFAULT_CONFIG.browser.headless,
4364
+ slowMo: partial.browser?.slowMo ?? DEFAULT_CONFIG.browser.slowMo,
4365
+ timeout: partial.browser?.timeout ?? DEFAULT_CONFIG.browser.timeout,
4366
+ engine: partial.browser?.engine ?? DEFAULT_CONFIG.browser.engine,
4367
+ channel: partial.browser?.channel,
4368
+ viewport: resolveViewport(partial.browser?.viewport)
4369
+ },
4370
+ artifacts: {
4371
+ screenshots: partial.artifacts?.screenshots ?? DEFAULT_CONFIG.artifacts.screenshots,
4372
+ networkHar: partial.artifacts?.networkHar ?? DEFAULT_CONFIG.artifacts.networkHar,
4373
+ console: partial.artifacts?.console ?? DEFAULT_CONFIG.artifacts.console,
4374
+ junit: partial.artifacts?.junit ?? DEFAULT_CONFIG.artifacts.junit
4375
+ },
4376
+ assertions: {
4377
+ noConsoleErrors: partial.assertions?.noConsoleErrors ?? DEFAULT_CONFIG.assertions.noConsoleErrors,
4378
+ noNetworkErrors: partial.assertions?.noNetworkErrors ?? DEFAULT_CONFIG.assertions.noNetworkErrors,
4379
+ maxTotalTimeMs: partial.assertions?.maxTotalTimeMs ?? DEFAULT_CONFIG.assertions.maxTotalTimeMs,
4380
+ networkIgnorePatterns: partial.assertions?.networkIgnorePatterns ?? DEFAULT_CONFIG.assertions.networkIgnorePatterns
4381
+ },
4382
+ guardrails: {
4383
+ maxSteps: partial.guardrails?.maxSteps ?? DEFAULT_CONFIG.guardrails.maxSteps,
4384
+ allowedDomains: partial.guardrails?.allowedDomains ?? DEFAULT_CONFIG.guardrails.allowedDomains,
4385
+ allowedApps: partial.guardrails?.allowedApps ?? DEFAULT_CONFIG.guardrails.allowedApps,
4386
+ forbiddenSelectors: partial.guardrails?.forbiddenSelectors ?? DEFAULT_CONFIG.guardrails.forbiddenSelectors,
4387
+ selfHealing: partial.guardrails?.selfHealing ?? DEFAULT_CONFIG.guardrails.selfHealing
4388
+ },
4389
+ auth: {
4390
+ storageStatePath: partial.auth?.storageStatePath ?? (partial.auth !== void 0 ? DEFAULT_CONFIG.auth.storageStatePath : void 0)
4391
+ },
4392
+ history: {
4393
+ maxRuns: partial.history?.maxRuns ?? DEFAULT_CONFIG.history.maxRuns
4394
+ },
4395
+ bugLog: partial.bugLog,
4396
+ tracing: partial.tracing,
4397
+ reliability: partial.reliability
4398
+ };
4399
+ }
4400
+ function envStringVars(env) {
4401
+ return Object.fromEntries(
4402
+ Object.entries(env).filter(([, value]) => value !== void 0)
4403
+ );
4404
+ }
4405
+ function interpolateConfigStrings(value, vars) {
4406
+ if (typeof value === "string") {
4407
+ return interpolateString(value, vars).value;
4408
+ }
4409
+ if (Array.isArray(value)) {
4410
+ return value.map((item) => interpolateConfigStrings(item, vars));
3802
4411
  }
3803
- if ("copyText" in step) {
3804
- return {
3805
- copyText: {
3806
- selector: interpolateString(step.copyText.selector, vars).value,
3807
- as: step.copyText.as
3808
- }
3809
- };
4412
+ if (value && typeof value === "object") {
4413
+ return Object.fromEntries(
4414
+ Object.entries(value).map(([key, item]) => [key, interpolateConfigStrings(item, vars)])
4415
+ );
3810
4416
  }
3811
- if ("waitForDownload" in step) {
3812
- if (step.waitForDownload === null) {
3813
- return { waitForDownload: null };
4417
+ return value;
4418
+ }
4419
+ function ensureAllowedDomain(allowed, urlValue) {
4420
+ try {
4421
+ const host = new URL(urlValue).hostname;
4422
+ if (!allowed.includes(host)) {
4423
+ return [...allowed, host];
3814
4424
  }
3815
- return {
3816
- waitForDownload: {
3817
- ...step.waitForDownload.filename !== void 0 ? { filename: interpolateString(step.waitForDownload.filename, vars).value } : {},
3818
- ...step.waitForDownload.timeout !== void 0 ? { timeout: step.waitForDownload.timeout } : {}
3819
- }
3820
- };
4425
+ } catch {
4426
+ return allowed;
3821
4427
  }
3822
- return step;
4428
+ return allowed;
3823
4429
  }
3824
- function interpolateAssertion(assertion, vars) {
3825
- if ("selectorExists" in assertion) {
3826
- return { selectorExists: interpolateString(assertion.selectorExists, vars).value };
4430
+ function loadConfig(configPath) {
4431
+ const resolvedPath = configPath ? import_node_path8.default.resolve(configPath) : findConfigPath(process.cwd());
4432
+ if (!resolvedPath) {
4433
+ throw new Error("Could not find .prowl/config.yml. Run `prowl init` first.");
3827
4434
  }
3828
- if ("selectorNotExists" in assertion) {
3829
- return { selectorNotExists: interpolateString(assertion.selectorNotExists, vars).value };
4435
+ if (!import_node_fs7.default.existsSync(resolvedPath)) {
4436
+ throw new Error(`Config file not found at ${resolvedPath}`);
3830
4437
  }
3831
- if ("urlIncludes" in assertion) {
3832
- return { urlIncludes: interpolateString(assertion.urlIncludes, vars).value };
4438
+ const configDir = import_node_path8.default.dirname(resolvedPath);
4439
+ if (import_node_path8.default.basename(configDir) === LEGACY_CONFIG_DIR) {
4440
+ warnLegacyConfigDir();
3833
4441
  }
3834
- if ("urlEquals" in assertion) {
3835
- return { urlEquals: interpolateString(assertion.urlEquals, vars).value };
4442
+ import_dotenv.default.config({ path: import_node_path8.default.join(configDir, ".env"), override: false });
4443
+ const raw = import_node_fs7.default.readFileSync(resolvedPath, "utf-8");
4444
+ const parsed = import_yaml.default.parse(raw) ?? {};
4445
+ const interpolated = interpolateConfigStrings(parsed, envStringVars(process.env));
4446
+ const validated = configSchema.parse(interpolated);
4447
+ const config = mergeConfig(validated);
4448
+ if (config.target.type === "web") {
4449
+ config.guardrails.allowedDomains = ensureAllowedDomain(
4450
+ config.guardrails.allowedDomains,
4451
+ config.target.url
4452
+ );
3836
4453
  }
3837
- if ("noConsoleErrors" in assertion) {
3838
- return { noConsoleErrors: assertion.noConsoleErrors };
4454
+ return { config, configPath: resolvedPath, configDir };
4455
+ }
4456
+ function loadHunt(huntName, configDir) {
4457
+ assertValidHuntName(huntName);
4458
+ const huntPath = import_node_path8.default.join(configDir, "hunts", `${huntName}.yml`);
4459
+ if (!import_node_fs7.default.existsSync(huntPath)) {
4460
+ throw new Error(`Hunt file not found: ${huntPath}`);
3839
4461
  }
3840
- if ("noNetworkErrors" in assertion) {
3841
- return { noNetworkErrors: assertion.noNetworkErrors };
4462
+ const raw = import_node_fs7.default.readFileSync(huntPath, "utf-8");
4463
+ const parsed = import_yaml.default.parse(raw) ?? {};
4464
+ const validated = huntSchema.parse(parsed);
4465
+ return validated;
4466
+ }
4467
+ function loadHuntTags(huntName, configDir) {
4468
+ assertValidHuntName(huntName);
4469
+ const huntPath = import_node_path8.default.join(configDir, "hunts", `${huntName}.yml`);
4470
+ if (!import_node_fs7.default.existsSync(huntPath)) {
4471
+ return [];
3842
4472
  }
3843
- return assertion;
4473
+ const raw = import_node_fs7.default.readFileSync(huntPath, "utf-8");
4474
+ const parsed = import_yaml.default.parse(raw) ?? {};
4475
+ return Array.isArray(parsed.tags) ? parsed.tags : [];
3844
4476
  }
3845
- function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
3846
- const redactedFillSteps = /* @__PURE__ */ new Set();
3847
- const envVars = Object.fromEntries(
3848
- Object.entries(env).filter(([, value]) => value !== void 0)
3849
- );
3850
- const baseVars = { ...randomVars, ...envVars };
3851
- const resolvedHuntVars = {};
3852
- for (const [key, value] of Object.entries(hunt.vars ?? {})) {
3853
- resolvedHuntVars[key] = interpolateString(value, baseVars).value;
4477
+ function loadHuntMeta(huntName, configDir) {
4478
+ assertValidHuntName(huntName);
4479
+ const huntPath = import_node_path8.default.join(configDir, "hunts", `${huntName}.yml`);
4480
+ if (!import_node_fs7.default.existsSync(huntPath)) {
4481
+ return { tags: [] };
3854
4482
  }
3855
- const vars = { ...baseVars, ...resolvedHuntVars };
3856
- const redactionValues = /* @__PURE__ */ new Set();
3857
- collectInterpolatedValues(hunt.steps, vars, redactionValues);
3858
- collectInterpolatedValues(hunt.assertions, vars, redactionValues);
3859
- const steps = hunt.steps.map(
3860
- (step, index) => interpolateStep(step, vars, `${index}`, redactedFillSteps)
3861
- );
3862
- const assertions = hunt.assertions?.map((assertion) => interpolateAssertion(assertion, vars));
4483
+ const raw = import_node_fs7.default.readFileSync(huntPath, "utf-8");
4484
+ const parsed = import_yaml.default.parse(raw) ?? {};
3863
4485
  return {
3864
- hunt: {
3865
- ...hunt,
3866
- steps,
3867
- assertions
3868
- },
3869
- redactedFillSteps,
3870
- randomVars,
3871
- redactionValues: [...redactionValues]
4486
+ description: typeof parsed.description === "string" ? parsed.description : void 0,
4487
+ tags: Array.isArray(parsed.tags) ? parsed.tags : []
3872
4488
  };
3873
4489
  }
4490
+ function listHunts(configDir) {
4491
+ const huntsDir = import_node_path8.default.join(configDir, "hunts");
4492
+ if (!import_node_fs7.default.existsSync(huntsDir)) {
4493
+ return [];
4494
+ }
4495
+ const stats = import_node_fs7.default.statSync(huntsDir);
4496
+ if (!stats.isDirectory()) {
4497
+ throw new Error(`Hunts path is not a directory: ${huntsDir}`);
4498
+ }
4499
+ const results = [];
4500
+ function scanDir(dir) {
4501
+ const entries = import_node_fs7.default.readdirSync(dir, { withFileTypes: true });
4502
+ for (const entry of entries) {
4503
+ if (entry.isFile() && entry.name.endsWith(".yml")) {
4504
+ const fullPath = import_node_path8.default.join(dir, entry.name);
4505
+ const relative = import_node_path8.default.relative(huntsDir, fullPath);
4506
+ results.push(relative.replace(/\.yml$/, ""));
4507
+ } else if (entry.isDirectory()) {
4508
+ scanDir(import_node_path8.default.join(dir, entry.name));
4509
+ }
4510
+ }
4511
+ }
4512
+ scanDir(huntsDir);
4513
+ return results.sort((a, b) => a.localeCompare(b));
4514
+ }
3874
4515
 
3875
4516
  // src/browser/playwright-driver.ts
3876
- var import_node_fs7 = __toESM(require("fs"), 1);
3877
- var import_node_path7 = __toESM(require("path"), 1);
4517
+ var import_node_fs8 = __toESM(require("fs"), 1);
4518
+ var import_node_path9 = __toESM(require("path"), 1);
3878
4519
  var import_playwright = require("playwright");
3879
4520
  var ENGINES = { chromium: import_playwright.chromium, firefox: import_playwright.firefox, webkit: import_playwright.webkit };
3880
4521
  async function launchBrowser(options) {
@@ -3896,14 +4537,14 @@ async function launchBrowser(options) {
3896
4537
  contextOptions.viewport = options.viewport;
3897
4538
  }
3898
4539
  if (options.storageStatePath) {
3899
- if (import_node_fs7.default.existsSync(options.storageStatePath)) {
4540
+ if (import_node_fs8.default.existsSync(options.storageStatePath)) {
3900
4541
  contextOptions.storageState = options.storageStatePath;
3901
4542
  } else {
3902
4543
  console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
3903
4544
  }
3904
4545
  }
3905
4546
  if (options.recordHar) {
3906
- contextOptions.recordHar = { path: import_node_path7.default.join(options.runDir, "network.har") };
4547
+ contextOptions.recordHar = { path: import_node_path9.default.join(options.runDir, "network.har") };
3907
4548
  }
3908
4549
  const context = await browser.newContext(contextOptions);
3909
4550
  const page = await context.newPage();
@@ -3911,7 +4552,7 @@ async function launchBrowser(options) {
3911
4552
  page.setDefaultNavigationTimeout(options.timeout);
3912
4553
  let tracePath;
3913
4554
  if (options.trace) {
3914
- tracePath = import_node_path7.default.join(options.runDir, "trace.zip");
4555
+ tracePath = import_node_path9.default.join(options.runDir, "trace.zip");
3915
4556
  await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
3916
4557
  }
3917
4558
  return { browser, context, page, tracePath };
@@ -4006,6 +4647,16 @@ function createPlaywrightDriver(page) {
4006
4647
  async hover(selector) {
4007
4648
  await page.locator(selector).hover();
4008
4649
  },
4650
+ async scroll(direction, amount = 500) {
4651
+ const deltas = {
4652
+ up: [0, -amount],
4653
+ down: [0, amount],
4654
+ left: [-amount, 0],
4655
+ right: [amount, 0]
4656
+ };
4657
+ const [x, y] = deltas[direction];
4658
+ await page.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
4659
+ },
4009
4660
  async scrollIntoView(selector) {
4010
4661
  await page.locator(selector).scrollIntoViewIfNeeded();
4011
4662
  },
@@ -4086,8 +4737,8 @@ function createPlaywrightDriver(page) {
4086
4737
  }
4087
4738
 
4088
4739
  // src/runner/steps.ts
4089
- var import_node_fs9 = __toESM(require("fs"), 1);
4090
- var import_node_path9 = __toESM(require("path"), 1);
4740
+ var import_node_fs10 = __toESM(require("fs"), 1);
4741
+ var import_node_path11 = __toESM(require("path"), 1);
4091
4742
 
4092
4743
  // src/runner/healing.ts
4093
4744
  var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
@@ -4785,11 +5436,14 @@ function toVisibilitySelector(value) {
4785
5436
  if (looksLikeSelector(value)) return value;
4786
5437
  return textContainsSelector(value);
4787
5438
  }
5439
+ function countVisible(driver, selector) {
5440
+ return driver.visibleCount?.(selector) ?? driver.count(selector);
5441
+ }
4788
5442
  async function runInlineAssert(driver, policy, assertion) {
4789
5443
  if (assertion.visible !== void 0) {
4790
5444
  const selector = toVisibilitySelector(assertion.visible);
4791
5445
  policy.assertAllowedSelector(selector);
4792
- const count = await driver.count(selector);
5446
+ const count = await countVisible(driver, selector);
4793
5447
  if (count === 0) {
4794
5448
  throw new Error(`Expected visible: ${assertion.visible}`);
4795
5449
  }
@@ -4798,7 +5452,7 @@ async function runInlineAssert(driver, policy, assertion) {
4798
5452
  if (assertion.notVisible !== void 0) {
4799
5453
  const selector = toVisibilitySelector(assertion.notVisible);
4800
5454
  policy.assertAllowedSelector(selector);
4801
- const count = await driver.count(selector);
5455
+ const count = await countVisible(driver, selector);
4802
5456
  if (count > 0) {
4803
5457
  throw new Error(`Expected not visible: ${assertion.notVisible}`);
4804
5458
  }
@@ -4821,7 +5475,7 @@ async function runInlineAssert(driver, policy, assertion) {
4821
5475
  throw new Error("assert step is missing an assertion type");
4822
5476
  }
4823
5477
  function screenshotPath(screenshotsDir, fileName) {
4824
- return import_node_path9.default.join(screenshotsDir, fileName);
5478
+ return import_node_path11.default.join(screenshotsDir, fileName);
4825
5479
  }
4826
5480
  function stepPath(prefix, index) {
4827
5481
  return prefix ? `${prefix}.${index}` : `${index}`;
@@ -4838,7 +5492,7 @@ function validateDownloadFilename(suggestedFilename) {
4838
5492
  const safeFilename = suggestedFilename.trim();
4839
5493
  const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
4840
5494
  const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
4841
- if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path9.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
5495
+ if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path11.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
4842
5496
  throw new Error(`Invalid download filename: "${suggestedFilename}"`);
4843
5497
  }
4844
5498
  return safeFilename;
@@ -5018,7 +5672,7 @@ var STEP_HANDLERS = {
5018
5672
  if (!("setInputFiles" in h.step)) unknownStep();
5019
5673
  const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
5020
5674
  const rawFiles = h.step.setInputFiles.files;
5021
- const resolveFile = (f) => import_node_path9.default.isAbsolute(f) ? f : import_node_path9.default.join(h.context.configDir, f);
5675
+ const resolveFile = (f) => import_node_path11.default.isAbsolute(f) ? f : import_node_path11.default.join(h.context.configDir, f);
5022
5676
  const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
5023
5677
  await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
5024
5678
  h.policy.ensureLocationAllowed(h.driver);
@@ -5188,25 +5842,21 @@ var STEP_HANDLERS = {
5188
5842
  }
5189
5843
  },
5190
5844
  scroll: {
5191
- capabilities: ["evaluate"],
5845
+ // Dispatched through the driver's `scroll` verb (interact), so native mobile
5846
+ // targets synthesize a touch swipe while web keeps its `window.scrollBy`
5847
+ // behavior. The per-target step gate rejects `scroll` on macOS before here.
5848
+ capabilities: ["interact"],
5192
5849
  run: async (h) => {
5193
5850
  if (!("scroll" in h.step)) unknownStep();
5194
- const amount = h.step.scroll.amount ?? 500;
5195
- const scrollMap = {
5196
- up: [0, -amount],
5197
- down: [0, amount],
5198
- left: [-amount, 0],
5199
- right: [amount, 0]
5200
- };
5201
- const [x, y] = scrollMap[h.step.scroll.direction];
5202
- await h.driver.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
5851
+ const { direction, amount } = h.step.scroll;
5852
+ await h.driver.scroll(direction, amount);
5203
5853
  return {
5204
5854
  kind: "result",
5205
5855
  result: {
5206
5856
  type: "scroll",
5207
5857
  status: "pass",
5208
5858
  durationMs: Date.now() - h.stepStart,
5209
- value: `${h.step.scroll.direction} ${amount}px`
5859
+ value: amount === void 0 ? direction : `${direction} ${amount}px`
5210
5860
  }
5211
5861
  };
5212
5862
  }
@@ -5252,7 +5902,7 @@ var STEP_HANDLERS = {
5252
5902
  const condition = h.step.if;
5253
5903
  const selector = condition.visible ?? condition.notVisible;
5254
5904
  h.policy.assertAllowedSelector(selector);
5255
- const count = await h.driver.count(selector);
5905
+ const count = await countVisible(h.driver, selector);
5256
5906
  const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
5257
5907
  if (conditionMet) {
5258
5908
  const subResult = await h.executeNested({
@@ -5339,7 +5989,7 @@ var STEP_HANDLERS = {
5339
5989
  const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
5340
5990
  h.policy.assertAllowedSelector(whileSelector);
5341
5991
  for (let i = 0; i < maxIter; i++) {
5342
- const whileCount = await h.driver.count(whileSelector);
5992
+ const whileCount = await countVisible(h.driver, whileSelector);
5343
5993
  const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
5344
5994
  if (!shouldContinue) break;
5345
5995
  totalSubSteps += repeat.steps.length;
@@ -5380,15 +6030,15 @@ var STEP_HANDLERS = {
5380
6030
  if (!responseFile) {
5381
6031
  throw new Error("mock.response must include either body or file");
5382
6032
  }
5383
- const candidateFilePath = import_node_path9.default.isAbsolute(responseFile) ? responseFile : import_node_path9.default.join(h.context.configDir, responseFile);
5384
- const resolvedConfigDir = import_node_path9.default.resolve(h.context.configDir);
5385
- const resolvedFilePath = import_node_path9.default.resolve(candidateFilePath);
5386
- const relativePath = import_node_path9.default.relative(resolvedConfigDir, resolvedFilePath);
5387
- const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path9.default.sep}`) && !import_node_path9.default.isAbsolute(relativePath);
6033
+ const candidateFilePath = import_node_path11.default.isAbsolute(responseFile) ? responseFile : import_node_path11.default.join(h.context.configDir, responseFile);
6034
+ const resolvedConfigDir = import_node_path11.default.resolve(h.context.configDir);
6035
+ const resolvedFilePath = import_node_path11.default.resolve(candidateFilePath);
6036
+ const relativePath = import_node_path11.default.relative(resolvedConfigDir, resolvedFilePath);
6037
+ const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path11.default.sep}`) && !import_node_path11.default.isAbsolute(relativePath);
5388
6038
  if (!isWithinConfigDir) {
5389
6039
  throw new Error("mock.response.file must resolve within config directory");
5390
6040
  }
5391
- responseBody = await import_node_fs9.default.promises.readFile(resolvedFilePath, "utf-8");
6041
+ responseBody = await import_node_fs10.default.promises.readFile(resolvedFilePath, "utf-8");
5392
6042
  }
5393
6043
  const contentType = mock.response.contentType ?? "application/json";
5394
6044
  const status = mock.response.status;
@@ -5451,8 +6101,8 @@ var STEP_HANDLERS = {
5451
6101
  capabilities: ["evaluate"],
5452
6102
  run: async (h) => {
5453
6103
  if (!("runScript" in h.step)) unknownStep();
5454
- const filePath = import_node_path9.default.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : import_node_path9.default.join(h.context.configDir, h.step.runScript.file);
5455
- const fileContents = import_node_fs9.default.readFileSync(filePath, "utf-8");
6104
+ 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);
6105
+ const fileContents = import_node_fs10.default.readFileSync(filePath, "utf-8");
5456
6106
  await h.driver.evaluate(fileContents);
5457
6107
  return {
5458
6108
  kind: "result",
@@ -5473,19 +6123,19 @@ var STEP_HANDLERS = {
5473
6123
  const name = h.step.assertScreenshot.name;
5474
6124
  const threshold = h.step.assertScreenshot.threshold ?? 0.1;
5475
6125
  const baselineDir = ensureBaselineDir2(h.context.configDir);
5476
- const baselinePath = import_node_path9.default.join(baselineDir, `${name}.png`);
5477
- const currentScreenshotPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
5478
- import_node_fs9.default.mkdirSync(import_node_path9.default.dirname(currentScreenshotPath), { recursive: true });
6126
+ const baselinePath = import_node_path11.default.join(baselineDir, `${name}.png`);
6127
+ const currentScreenshotPath = import_node_path11.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
6128
+ import_node_fs10.default.mkdirSync(import_node_path11.default.dirname(currentScreenshotPath), { recursive: true });
5479
6129
  await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
5480
- h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-current.png`));
5481
- if (!import_node_fs9.default.existsSync(baselinePath)) {
5482
- import_node_fs9.default.copyFileSync(currentScreenshotPath, baselinePath);
6130
+ h.screenshots.push(import_node_path11.default.join("screenshots", `${name}-current.png`));
6131
+ if (!import_node_fs10.default.existsSync(baselinePath)) {
6132
+ import_node_fs10.default.copyFileSync(currentScreenshotPath, baselinePath);
5483
6133
  return {
5484
6134
  kind: "result",
5485
6135
  result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
5486
6136
  };
5487
6137
  }
5488
- const diffPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
6138
+ const diffPath = import_node_path11.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
5489
6139
  const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
5490
6140
  if (comparison.match) {
5491
6141
  return {
@@ -5498,7 +6148,7 @@ var STEP_HANDLERS = {
5498
6148
  }
5499
6149
  };
5500
6150
  }
5501
- h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-diff.png`));
6151
+ h.screenshots.push(import_node_path11.default.join("screenshots", `${name}-diff.png`));
5502
6152
  throw new Error(
5503
6153
  `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
5504
6154
  );
@@ -5524,8 +6174,8 @@ var STEP_HANDLERS = {
5524
6174
  }
5525
6175
  const fileName = `assertWithAI_step_${h.index + 1}.png`;
5526
6176
  const relative = await h.addScreenshot(fileName);
5527
- const screenshotFullPath = import_node_path9.default.join(h.context.runDir, relative);
5528
- const imageBase64 = import_node_fs9.default.readFileSync(screenshotFullPath).toString("base64");
6177
+ const screenshotFullPath = import_node_path11.default.join(h.context.runDir, relative);
6178
+ const imageBase64 = import_node_fs10.default.readFileSync(screenshotFullPath).toString("base64");
5529
6179
  const assertVision = h.context.assertVision ?? assertWithAiVision;
5530
6180
  const verdict = await assertVision(
5531
6181
  { imageBase64, mediaType: "image/png", assertion },
@@ -5582,7 +6232,7 @@ var STEP_HANDLERS = {
5582
6232
  `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
5583
6233
  );
5584
6234
  }
5585
- const savePath = import_node_path9.default.join(h.context.runDir, suggestedFilename);
6235
+ const savePath = import_node_path11.default.join(h.context.runDir, suggestedFilename);
5586
6236
  await download.saveAs(savePath);
5587
6237
  return {
5588
6238
  kind: "result",
@@ -5611,8 +6261,8 @@ async function executeSteps(context) {
5611
6261
  maxSteps: context.maxSteps,
5612
6262
  selfHealing: context.selfHealing
5613
6263
  });
5614
- const screenshotsDir = import_node_path9.default.join(context.runDir, "screenshots");
5615
- import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
6264
+ const screenshotsDir = import_node_path11.default.join(context.runDir, "screenshots");
6265
+ import_node_fs10.default.mkdirSync(screenshotsDir, { recursive: true });
5616
6266
  const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
5617
6267
  policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
5618
6268
  const results = [];
@@ -5622,7 +6272,7 @@ async function executeSteps(context) {
5622
6272
  const addScreenshot = async (fileName) => {
5623
6273
  const fullPath = screenshotPath(screenshotsDir, fileName);
5624
6274
  await captureScreenshot2(driver, fullPath);
5625
- const relative = import_node_path9.default.join("screenshots", fileName);
6275
+ const relative = import_node_path11.default.join("screenshots", fileName);
5626
6276
  screenshots.push(relative);
5627
6277
  return relative;
5628
6278
  };
@@ -5710,12 +6360,12 @@ async function executeSteps(context) {
5710
6360
  return { results, screenshots, failed: false };
5711
6361
  }
5712
6362
  async function captureFinalScreenshot(page, runDir) {
5713
- const screenshotsDir = import_node_path9.default.join(runDir, "screenshots");
5714
- import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
6363
+ const screenshotsDir = import_node_path11.default.join(runDir, "screenshots");
6364
+ import_node_fs10.default.mkdirSync(screenshotsDir, { recursive: true });
5715
6365
  const fileName = "final.png";
5716
6366
  const filePath = screenshotPath(screenshotsDir, fileName);
5717
6367
  await captureScreenshot2(page, filePath);
5718
- return import_node_path9.default.join("screenshots", fileName);
6368
+ return import_node_path11.default.join("screenshots", fileName);
5719
6369
  }
5720
6370
 
5721
6371
  // src/runner/assertions.ts
@@ -5837,6 +6487,59 @@ async function evaluateAssertions(options) {
5837
6487
  }
5838
6488
  return results;
5839
6489
  }
6490
+ async function evaluateNativeAssertions(options) {
6491
+ const assertions = mergeAssertions(options.config, options.huntAssertions);
6492
+ const authoredTypes = new Set(
6493
+ (options.huntAssertions ?? []).map((assertion) => Object.keys(assertion)[0])
6494
+ );
6495
+ const results = [];
6496
+ const warnings = [];
6497
+ for (const assertion of assertions) {
6498
+ const type = Object.keys(assertion)[0] ?? "assertion";
6499
+ try {
6500
+ if ("selectorExists" in assertion) {
6501
+ options.assertAllowedSelector?.(assertion.selectorExists);
6502
+ const count = await options.driver.count(assertion.selectorExists);
6503
+ results.push({
6504
+ type: "selectorExists",
6505
+ value: assertion.selectorExists,
6506
+ status: count > 0 ? "pass" : "fail",
6507
+ error: count > 0 ? void 0 : "Selector not found"
6508
+ });
6509
+ continue;
6510
+ }
6511
+ if ("selectorNotExists" in assertion) {
6512
+ options.assertAllowedSelector?.(assertion.selectorNotExists);
6513
+ const count = await options.driver.count(assertion.selectorNotExists);
6514
+ results.push({
6515
+ type: "selectorNotExists",
6516
+ value: assertion.selectorNotExists,
6517
+ status: count === 0 ? "pass" : "fail",
6518
+ error: count === 0 ? void 0 : "Selector exists"
6519
+ });
6520
+ continue;
6521
+ }
6522
+ const rawValue = assertion[type];
6523
+ results.push({
6524
+ type,
6525
+ value: rawValue,
6526
+ status: "skipped",
6527
+ error: `skipped (web-only): not supported on the ${options.targetLabel} target`
6528
+ });
6529
+ if (authoredTypes.has(type)) {
6530
+ warnings.push(`${type} is web-only; skipped on ${options.targetLabel} target`);
6531
+ }
6532
+ } catch (error) {
6533
+ const detail = error instanceof Error ? error.message : "no error details";
6534
+ results.push({
6535
+ type,
6536
+ status: "fail",
6537
+ error: `Native assertion "${type}" failed: ${detail}`
6538
+ });
6539
+ }
6540
+ }
6541
+ return { results, warnings };
6542
+ }
5840
6543
 
5841
6544
  // src/runner/tracing.ts
5842
6545
  var DEFAULT_TRACE_HEADER = "traceparent";
@@ -5875,18 +6578,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
5875
6578
  }
5876
6579
 
5877
6580
  // src/reporter/result.ts
5878
- var import_node_fs10 = __toESM(require("fs"), 1);
5879
- var import_node_path10 = __toESM(require("path"), 1);
6581
+ var import_node_fs11 = __toESM(require("fs"), 1);
6582
+ var import_node_path12 = __toESM(require("path"), 1);
5880
6583
  function writeResult(runDir, result) {
5881
6584
  const fileName = "result.json";
5882
- const fullPath = import_node_path10.default.join(runDir, fileName);
5883
- import_node_fs10.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
6585
+ const fullPath = import_node_path12.default.join(runDir, fileName);
6586
+ import_node_fs11.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
5884
6587
  return fileName;
5885
6588
  }
5886
6589
 
5887
6590
  // src/reporter/summary.ts
5888
- var import_node_fs11 = __toESM(require("fs"), 1);
5889
- var import_node_path11 = __toESM(require("path"), 1);
6591
+ var import_node_fs12 = __toESM(require("fs"), 1);
6592
+ var import_node_path13 = __toESM(require("path"), 1);
5890
6593
  function escapeMd(text) {
5891
6594
  return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
5892
6595
  }
@@ -5964,28 +6667,29 @@ function writeSummary(runDir, result) {
5964
6667
  }
5965
6668
  }
5966
6669
  const fileName = "summary.md";
5967
- const fullPath = import_node_path11.default.join(runDir, fileName);
5968
- import_node_fs11.default.writeFileSync(fullPath, `${lines.join("\n")}
6670
+ const fullPath = import_node_path13.default.join(runDir, fileName);
6671
+ import_node_fs12.default.writeFileSync(fullPath, `${lines.join("\n")}
5969
6672
  `);
5970
6673
  return fileName;
5971
6674
  }
5972
6675
 
5973
6676
  // src/reporter/junit.ts
5974
- var import_node_fs12 = __toESM(require("fs"), 1);
5975
- var import_node_path12 = __toESM(require("path"), 1);
6677
+ var import_node_fs13 = __toESM(require("fs"), 1);
6678
+ var import_node_path14 = __toESM(require("path"), 1);
5976
6679
  function escapeXml(text) {
5977
6680
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
5978
6681
  }
5979
6682
  function writeJunit(runDir, result) {
5980
6683
  const totalTests = result.steps.length + result.assertions.length;
5981
6684
  const failures = result.steps.filter((s) => s.status === "fail").length + result.assertions.filter((a) => a.status === "fail").length;
6685
+ const skipped = result.assertions.filter((a) => a.status === "skipped").length;
5982
6686
  const timeSeconds = (result.durationMs / 1e3).toFixed(3);
5983
6687
  const huntName = escapeXml(result.hunt);
5984
6688
  const lines = [];
5985
6689
  lines.push('<?xml version="1.0" encoding="UTF-8"?>');
5986
6690
  lines.push("<testsuites>");
5987
6691
  lines.push(
5988
- ` <testsuite name="${huntName}" tests="${totalTests}" failures="${failures}" errors="0" time="${timeSeconds}" timestamp="${escapeXml(result.startedAt)}">`
6692
+ ` <testsuite name="${huntName}" tests="${totalTests}" failures="${failures}" errors="0" skipped="${skipped}" time="${timeSeconds}" timestamp="${escapeXml(result.startedAt)}">`
5989
6693
  );
5990
6694
  for (let i = 0; i < result.steps.length; i++) {
5991
6695
  const step = result.steps[i];
@@ -6009,6 +6713,11 @@ function writeJunit(runDir, result) {
6009
6713
  lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0">`);
6010
6714
  lines.push(` <failure message="${escapedFailureText}" type="assertion">${escapedFailureText}</failure>`);
6011
6715
  lines.push(" </testcase>");
6716
+ } else if (assertion.status === "skipped") {
6717
+ const skipText = escapeXml(assertion.error ?? "skipped");
6718
+ lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0">`);
6719
+ lines.push(` <skipped message="${skipText}"/>`);
6720
+ lines.push(" </testcase>");
6012
6721
  } else {
6013
6722
  lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0"/>`);
6014
6723
  }
@@ -6016,8 +6725,8 @@ function writeJunit(runDir, result) {
6016
6725
  lines.push(" </testsuite>");
6017
6726
  lines.push("</testsuites>");
6018
6727
  const fileName = "junit.xml";
6019
- const fullPath = import_node_path12.default.join(runDir, fileName);
6020
- import_node_fs12.default.writeFileSync(fullPath, `${lines.join("\n")}
6728
+ const fullPath = import_node_path14.default.join(runDir, fileName);
6729
+ import_node_fs13.default.writeFileSync(fullPath, `${lines.join("\n")}
6021
6730
  `);
6022
6731
  return fileName;
6023
6732
  }
@@ -6051,15 +6760,15 @@ function timestamp(prefix) {
6051
6760
  }
6052
6761
 
6053
6762
  // src/runner/history.ts
6054
- var import_node_fs13 = __toESM(require("fs"), 1);
6055
- var import_node_path13 = __toESM(require("path"), 1);
6763
+ var import_node_fs14 = __toESM(require("fs"), 1);
6764
+ var import_node_path15 = __toESM(require("path"), 1);
6056
6765
  var HISTORY_FILE = "history.json";
6057
6766
  var LOCK_FILE_SUFFIX = ".lock";
6058
6767
  var LOCK_RETRY_MS = 10;
6059
6768
  var LOCK_TIMEOUT_MS = 5e3;
6060
6769
  var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
6061
6770
  function historyPath(configDir) {
6062
- return import_node_path13.default.join(configDir, HISTORY_FILE);
6771
+ return import_node_path15.default.join(configDir, HISTORY_FILE);
6063
6772
  }
6064
6773
  function isHistoryEntry(value) {
6065
6774
  if (!value || typeof value !== "object") {
@@ -6070,11 +6779,11 @@ function isHistoryEntry(value) {
6070
6779
  }
6071
6780
  function readHistory(configDir) {
6072
6781
  const filePath = historyPath(configDir);
6073
- if (!import_node_fs13.default.existsSync(filePath)) {
6782
+ if (!import_node_fs14.default.existsSync(filePath)) {
6074
6783
  return { entries: [] };
6075
6784
  }
6076
6785
  try {
6077
- const raw = import_node_fs13.default.readFileSync(filePath, "utf-8");
6786
+ const raw = import_node_fs14.default.readFileSync(filePath, "utf-8");
6078
6787
  const parsed = JSON.parse(raw);
6079
6788
  if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
6080
6789
  const validatedEntries = parsed.entries.filter(isHistoryEntry);
@@ -6113,12 +6822,12 @@ function sleepSync(ms) {
6113
6822
  function withHistoryLock(configDir, fn) {
6114
6823
  const filePath = historyPath(configDir);
6115
6824
  const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
6116
- import_node_fs13.default.mkdirSync(import_node_path13.default.dirname(filePath), { recursive: true });
6825
+ import_node_fs14.default.mkdirSync(import_node_path15.default.dirname(filePath), { recursive: true });
6117
6826
  const startedAt = Date.now();
6118
6827
  while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
6119
6828
  let fd;
6120
6829
  try {
6121
- fd = import_node_fs13.default.openSync(lockPath, "wx");
6830
+ fd = import_node_fs14.default.openSync(lockPath, "wx");
6122
6831
  } catch (error) {
6123
6832
  if (error.code === "EEXIST") {
6124
6833
  sleepSync(LOCK_RETRY_MS);
@@ -6130,10 +6839,10 @@ function withHistoryLock(configDir, fn) {
6130
6839
  return fn();
6131
6840
  } finally {
6132
6841
  try {
6133
- import_node_fs13.default.closeSync(fd);
6842
+ import_node_fs14.default.closeSync(fd);
6134
6843
  } catch {
6135
6844
  }
6136
- import_node_fs13.default.rmSync(lockPath, { force: true });
6845
+ import_node_fs14.default.rmSync(lockPath, { force: true });
6137
6846
  }
6138
6847
  }
6139
6848
  throw new Error(
@@ -6146,9 +6855,9 @@ function appendEntry(configDir, entry, maxRuns) {
6146
6855
  const current = readHistory(configDir);
6147
6856
  const next = pruneEntries([...current.entries, entry], maxRuns);
6148
6857
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
6149
- import_node_fs13.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
6858
+ import_node_fs14.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
6150
6859
  `);
6151
- import_node_fs13.default.renameSync(tempPath, filePath);
6860
+ import_node_fs14.default.renameSync(tempPath, filePath);
6152
6861
  });
6153
6862
  }
6154
6863
 
@@ -6161,11 +6870,11 @@ function parseViewportFlag(value) {
6161
6870
  return value;
6162
6871
  }
6163
6872
  function resolvePath(configDir, inputPath) {
6164
- if (import_node_path14.default.isAbsolute(inputPath)) {
6873
+ if (import_node_path16.default.isAbsolute(inputPath)) {
6165
6874
  return inputPath;
6166
6875
  }
6167
- const projectRoot = import_node_path14.default.dirname(configDir);
6168
- return import_node_path14.default.join(projectRoot, inputPath);
6876
+ const projectRoot = import_node_path16.default.dirname(configDir);
6877
+ return import_node_path16.default.join(projectRoot, inputPath);
6169
6878
  }
6170
6879
  function buildRunResult(options) {
6171
6880
  return {
@@ -6184,12 +6893,12 @@ function buildRunResult(options) {
6184
6893
  }
6185
6894
  function writeConsoleLog(runDir, entries) {
6186
6895
  const fileName = "console.log";
6187
- const filePath = import_node_path14.default.join(runDir, fileName);
6896
+ const filePath = import_node_path16.default.join(runDir, fileName);
6188
6897
  const lines = entries.map((entry) => {
6189
6898
  const location = entry.location ? ` (${entry.location})` : "";
6190
6899
  return `[${entry.type}] ${entry.text}${location}`;
6191
6900
  });
6192
- import_node_fs14.default.writeFileSync(filePath, `${lines.join("\n")}
6901
+ import_node_fs15.default.writeFileSync(filePath, `${lines.join("\n")}
6193
6902
  `);
6194
6903
  return fileName;
6195
6904
  }
@@ -6197,8 +6906,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
6197
6906
  const headless = options.headed ? false : config.browser.headless;
6198
6907
  const slowMo = options.slowMo ?? config.browser.slowMo;
6199
6908
  const maxSteps = config.guardrails.maxSteps;
6200
- const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
6201
- import_node_fs14.default.mkdirSync(runDir, { recursive: true });
6909
+ const runDir = import_node_path16.default.join(configDir, "runs", timestamp());
6910
+ import_node_fs15.default.mkdirSync(runDir, { recursive: true });
6202
6911
  const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
6203
6912
  const engine = options.browser ?? config.browser.engine;
6204
6913
  const channel = options.channel ?? config.browser.channel;
@@ -6381,8 +7090,8 @@ async function runHunt(options) {
6381
7090
  }
6382
7091
  async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
6383
7092
  const maxSteps = config.guardrails.maxSteps;
6384
- const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
6385
- import_node_fs14.default.mkdirSync(runDir, { recursive: true });
7093
+ const runDir = import_node_path16.default.join(configDir, "runs", timestamp());
7094
+ import_node_fs15.default.mkdirSync(runDir, { recursive: true });
6386
7095
  const session = await native.launchSession();
6387
7096
  let result;
6388
7097
  try {
@@ -6390,6 +7099,13 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
6390
7099
  const appIdentity = native.sessionAppIdentity(session);
6391
7100
  const targetLabel = `${native.targetType}:${appIdentity}`;
6392
7101
  const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
7102
+ const assertionPolicy = createRunPolicy(driver, {
7103
+ forbiddenSelectors: config.guardrails.forbiddenSelectors,
7104
+ allowedDomains: [],
7105
+ allowedApps: effectiveAllowedApps,
7106
+ maxSteps,
7107
+ selfHealing: config.guardrails.selfHealing
7108
+ });
6393
7109
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
6394
7110
  const startTime = Date.now();
6395
7111
  let stepResults = [];
@@ -6429,8 +7145,19 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
6429
7145
  } catch {
6430
7146
  finalScreenshot = void 0;
6431
7147
  }
7148
+ const { results: assertionResults, warnings: assertionWarnings } = await evaluateNativeAssertions({
7149
+ driver,
7150
+ config,
7151
+ huntAssertions: interpolatedHunt.assertions,
7152
+ assertAllowedSelector: assertionPolicy.assertAllowedSelector,
7153
+ targetLabel: nativeTargetLabel(native.targetType)
7154
+ });
7155
+ for (const warning of assertionWarnings) {
7156
+ console.warn(warning);
7157
+ }
6432
7158
  const durationMs = Date.now() - startTime;
6433
- const status = stepFailed ? "fail" : "pass";
7159
+ const assertionsFailed = assertionResults.some((assertion) => assertion.status === "fail");
7160
+ const status = stepFailed || assertionsFailed ? "fail" : "pass";
6434
7161
  const artifacts = {
6435
7162
  screenshots: finalScreenshot ? [...stepScreenshots, finalScreenshot] : stepScreenshots
6436
7163
  };
@@ -6441,7 +7168,7 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
6441
7168
  hunt: options.huntName,
6442
7169
  targetUrl: targetLabel,
6443
7170
  steps: stepResults,
6444
- assertions: [],
7171
+ assertions: assertionResults,
6445
7172
  artifacts
6446
7173
  });
6447
7174
  result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
@@ -6554,7 +7281,6 @@ async function runNativeHunt(options, config, configDir, target, native) {
6554
7281
  const hunt = loadHunt(options.huntName, configDir);
6555
7282
  const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
6556
7283
  assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
6557
- assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
6558
7284
  native.assertAppAllowed(config.guardrails.allowedApps, target);
6559
7285
  const maxSteps = config.guardrails.maxSteps;
6560
7286
  if (interpolatedHunt.steps.length > maxSteps) {
@@ -6595,7 +7321,7 @@ async function runNativeHunt(options, config, configDir, target, native) {
6595
7321
  }
6596
7322
  function recordHistory(configDir, outcome, maxRuns) {
6597
7323
  try {
6598
- const relativeRunDir = import_node_path14.default.relative(configDir, outcome.runDir);
7324
+ const relativeRunDir = import_node_path16.default.relative(configDir, outcome.runDir);
6599
7325
  appendEntry(
6600
7326
  configDir,
6601
7327
  {
@@ -6612,11 +7338,11 @@ function recordHistory(configDir, outcome, maxRuns) {
6612
7338
  }
6613
7339
 
6614
7340
  // src/runner/suite.ts
6615
- var import_node_path17 = __toESM(require("path"), 1);
7341
+ var import_node_path19 = __toESM(require("path"), 1);
6616
7342
 
6617
7343
  // src/reporter/ci-summary.ts
6618
- var import_node_fs15 = __toESM(require("fs"), 1);
6619
- var import_node_path15 = __toESM(require("path"), 1);
7344
+ var import_node_fs16 = __toESM(require("fs"), 1);
7345
+ var import_node_path17 = __toESM(require("path"), 1);
6620
7346
  var import_chalk = __toESM(require("chalk"), 1);
6621
7347
  function countCiResults(results) {
6622
7348
  return {
@@ -6646,9 +7372,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
6646
7372
  ...flaky.length > 0 ? { flaky } : {},
6647
7373
  ...clusters.length > 0 ? { clusters } : {}
6648
7374
  };
6649
- import_node_fs15.default.mkdirSync(ciRunDir, { recursive: true });
6650
- const filePath = import_node_path15.default.join(ciRunDir, "ci-result.json");
6651
- import_node_fs15.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
7375
+ import_node_fs16.default.mkdirSync(ciRunDir, { recursive: true });
7376
+ const filePath = import_node_path17.default.join(ciRunDir, "ci-result.json");
7377
+ import_node_fs16.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
6652
7378
  return filePath;
6653
7379
  }
6654
7380
 
@@ -6715,7 +7441,7 @@ function rankFlaky(configDir, options = {}) {
6715
7441
  }
6716
7442
 
6717
7443
  // src/backlog/fingerprint.ts
6718
- var import_node_crypto2 = require("crypto");
7444
+ var import_node_crypto3 = require("crypto");
6719
7445
  function normalizeError(error) {
6720
7446
  return error.toLowerCase().replace(/0x[0-9a-f]+/g, "").replace(/\b\d+(?:\.\d+)?\s*(?:ms|s|px)\b/g, "").replace(/\s+/g, " ").trim();
6721
7447
  }
@@ -6738,7 +7464,7 @@ function computeFingerprint(failure) {
6738
7464
  failure.selector ?? "",
6739
7465
  normalizeError(failure.error)
6740
7466
  ];
6741
- return (0, import_node_crypto2.createHash)("sha1").update(parts.join("|")).digest("hex").slice(0, 8);
7467
+ return (0, import_node_crypto3.createHash)("sha1").update(parts.join("|")).digest("hex").slice(0, 8);
6742
7468
  }
6743
7469
  function buildMarker(failure, hash) {
6744
7470
  const hunt = sanitizeMarkerValue(failure.hunt);
@@ -6782,8 +7508,8 @@ function clusterFailures(failures) {
6782
7508
  }
6783
7509
 
6784
7510
  // src/backlog/index.ts
6785
- var import_node_fs16 = __toESM(require("fs"), 1);
6786
- var import_node_path16 = __toESM(require("path"), 1);
7511
+ var import_node_fs17 = __toESM(require("fs"), 1);
7512
+ var import_node_path18 = __toESM(require("path"), 1);
6787
7513
 
6788
7514
  // src/backlog/parse.ts
6789
7515
  var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
@@ -6873,7 +7599,7 @@ ${after}`;
6873
7599
  // src/backlog/index.ts
6874
7600
  function readFileOrEmpty(filePath) {
6875
7601
  try {
6876
- return import_node_fs16.default.readFileSync(filePath, "utf-8");
7602
+ return import_node_fs17.default.readFileSync(filePath, "utf-8");
6877
7603
  } catch (error) {
6878
7604
  const err = error;
6879
7605
  if (err.code === "ENOENT") return "";
@@ -6889,7 +7615,7 @@ function buildFailure(hunt) {
6889
7615
  if (!hunt.runDir) return failure;
6890
7616
  let run;
6891
7617
  try {
6892
- const resultJson = readFileOrEmpty(import_node_path16.default.join(hunt.runDir, "result.json"));
7618
+ const resultJson = readFileOrEmpty(import_node_path18.default.join(hunt.runDir, "result.json"));
6893
7619
  if (!resultJson) return failure;
6894
7620
  run = JSON.parse(resultJson);
6895
7621
  } catch (error) {
@@ -6918,8 +7644,8 @@ function extractFailures(suiteResult) {
6918
7644
  }
6919
7645
  function updateBacklogFromSuite(suiteResult, options = {}) {
6920
7646
  const projectRoot = options.projectRoot ?? process.cwd();
6921
- const backlogPath = options.backlogPath ?? import_node_path16.default.join(projectRoot, "docs", "backlog.md");
6922
- const resolvedPath = options.resolvedPath ?? import_node_path16.default.join(projectRoot, "docs", "resolved.md");
7647
+ const backlogPath = options.backlogPath ?? import_node_path18.default.join(projectRoot, "docs", "backlog.md");
7648
+ const resolvedPath = options.resolvedPath ?? import_node_path18.default.join(projectRoot, "docs", "resolved.md");
6923
7649
  const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6924
7650
  const summary = { created: [], regressions: [], skipped: [], backlogPath };
6925
7651
  const failures = extractFailures(suiteResult);
@@ -6951,8 +7677,8 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
6951
7677
  }
6952
7678
  }
6953
7679
  if (ticketsToAdd.length > 0) {
6954
- import_node_fs16.default.mkdirSync(import_node_path16.default.dirname(backlogPath), { recursive: true });
6955
- import_node_fs16.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
7680
+ import_node_fs17.default.mkdirSync(import_node_path18.default.dirname(backlogPath), { recursive: true });
7681
+ import_node_fs17.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
6956
7682
  }
6957
7683
  return summary;
6958
7684
  }
@@ -7113,7 +7839,7 @@ async function runSuite(options = {}) {
7113
7839
  const clusters = clusterFailures(
7114
7840
  extractFailures({ result: { hunts: results }, resultPath: null })
7115
7841
  ).filter((cluster) => cluster.count > 1);
7116
- const ciRunDir = import_node_path17.default.join(configDir, "runs", timestamp("ci"));
7842
+ const ciRunDir = import_node_path19.default.join(configDir, "runs", timestamp("ci"));
7117
7843
  const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
7118
7844
  const { passed, failed, skipped } = countCiResults(results);
7119
7845
  return {
@@ -7798,11 +8524,17 @@ async function generateHunt(options) {
7798
8524
  DEFAULT_REQUEST_TIMEOUT_MS,
7799
8525
  DEFAULT_SIMULATOR_LOCK_ROOT,
7800
8526
  DEFAULT_WDA_REQUEST_TIMEOUT_MS,
8527
+ HELPER_BINARY,
7801
8528
  INTERACTIVE_ROLES,
7802
8529
  IOS_INTERACTIVE_TYPES,
7803
8530
  IOS_MATCH_DIALECT,
7804
8531
  IOS_PRESS_KEYS,
7805
8532
  IOS_WINDOW_TYPE,
8533
+ MACDRIVER_REPO,
8534
+ MACDRIVER_SIGNING_AUTHORITY_PREFIX,
8535
+ MACDRIVER_SIGNING_IDENTIFIER,
8536
+ MACDRIVER_VERSION,
8537
+ MACDRIVER_VERSION_PATTERN,
7806
8538
  MACOS_MATCH_DIALECT,
7807
8539
  NATIVE_ATTRIBUTE_MAP,
7808
8540
  SpawnMacHelperClient,
@@ -7832,6 +8564,8 @@ async function generateHunt(options) {
7832
8564
  closeIosSession,
7833
8565
  closeMacSession,
7834
8566
  clusterFailures,
8567
+ codesignVerifier,
8568
+ collectMacdriverStatus,
7835
8569
  computeFlakeScore,
7836
8570
  configSchema,
7837
8571
  createAndroidDriver,
@@ -7845,6 +8579,8 @@ async function generateHunt(options) {
7845
8579
  defaultAgentConnector,
7846
8580
  defaultIosAgentConnector,
7847
8581
  defaultWdaTestRunPreparer,
8582
+ dittoExtractor,
8583
+ downloadAndVerify,
7848
8584
  escapePredicateArg,
7849
8585
  escapeUiSelectorArg,
7850
8586
  extractElementId,
@@ -7855,6 +8591,7 @@ async function generateHunt(options) {
7855
8591
  healSelector,
7856
8592
  huntSchema,
7857
8593
  injectUsePortIntoXctestrun,
8594
+ installMacdriver,
7858
8595
  interpolateHunt,
7859
8596
  iosAppAllowedIdentities,
7860
8597
  iosNodeToNative,
@@ -7871,7 +8608,14 @@ async function generateHunt(options) {
7871
8608
  loadHunt,
7872
8609
  loadHuntMeta,
7873
8610
  loadHuntTags,
8611
+ macdriverAssetName,
8612
+ macdriverAssetUrl,
7874
8613
  macdriverBuildInstructions,
8614
+ macdriverChecksumName,
8615
+ macdriverInstallRoot,
8616
+ macdriverInstalledBinary,
8617
+ macdriverReleaseTag,
8618
+ macdriverVersionDir,
7875
8619
  matchAndroidSelector,
7876
8620
  matchIosSelector,
7877
8621
  matchNativeTree,
@@ -7881,6 +8625,8 @@ async function generateHunt(options) {
7881
8625
  parseAdbDevices,
7882
8626
  parseAndroidHierarchy,
7883
8627
  parseAndroidSelector,
8628
+ parseChecksumFile,
8629
+ parseCodesignDetails,
7884
8630
  parseForwardPort,
7885
8631
  parseIosHierarchy,
7886
8632
  parseIosSelector,
@@ -7907,19 +8653,26 @@ async function generateHunt(options) {
7907
8653
  resolveWdaTestRun,
7908
8654
  runHunt,
7909
8655
  runSuite,
8656
+ runVersionProbe,
7910
8657
  selectDeviceSerial,
7911
8658
  selectSimulatorUdid,
8659
+ sha256Hex,
7912
8660
  shortIosType,
7913
8661
  stepSchema,
8662
+ tccGuidance,
7914
8663
  unquoteSelectorValue,
7915
8664
  unwrapAndroidTextSelector,
7916
8665
  unwrapIosTextSelector,
7917
8666
  unwrapNativeTextSelector,
7918
8667
  updateBacklogFromSuite,
8668
+ validateMacdriverArchiveEntries,
8669
+ validateMacdriverVersion,
8670
+ verifyMacdriverSignature,
7919
8671
  waitForAgentReady,
7920
8672
  waitForWdaReady,
7921
8673
  wdaCacheDir,
7922
8674
  wdaTestRunArgs,
7923
- webOnlyReason
8675
+ webOnlyReason,
8676
+ zipinfoArchiveLister
7924
8677
  });
7925
8678
  //# sourceMappingURL=lib.cjs.map