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/index.cjs CHANGED
@@ -43,18 +43,33 @@ var init_types = __esm({
43
43
  function isValidHuntName(name) {
44
44
  return HUNT_NAME_PATTERN.test(name);
45
45
  }
46
+ function normalizeHuntName(input) {
47
+ let name = input.trim();
48
+ if (name.startsWith("./")) {
49
+ name = name.slice(2);
50
+ }
51
+ const isSupportedHuntPath = HUNT_FILE_EXTENSION_PATTERN.test(name);
52
+ if (isSupportedHuntPath && name.startsWith(".prowl/hunts/")) {
53
+ name = name.slice(".prowl/hunts/".length);
54
+ } else if (isSupportedHuntPath && name.startsWith("hunts/")) {
55
+ name = name.slice("hunts/".length);
56
+ }
57
+ name = name.replace(HUNT_FILE_EXTENSION_PATTERN, "");
58
+ return name;
59
+ }
46
60
  function assertValidHuntName(name) {
47
61
  if (!isValidHuntName(name)) {
48
62
  throw new Error(
49
- `Invalid hunt name: "${name}". Use only letters, numbers, hyphens, underscores, and forward slashes.`
63
+ `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).`
50
64
  );
51
65
  }
52
66
  }
53
- var HUNT_NAME_PATTERN;
67
+ var HUNT_NAME_PATTERN, HUNT_FILE_EXTENSION_PATTERN;
54
68
  var init_hunt_name = __esm({
55
69
  "src/config/hunt-name.ts"() {
56
70
  "use strict";
57
71
  HUNT_NAME_PATTERN = /^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/;
72
+ HUNT_FILE_EXTENSION_PATTERN = /\.yml$/i;
58
73
  }
59
74
  });
60
75
 
@@ -383,808 +398,839 @@ var init_schema = __esm({
383
398
  }
384
399
  });
385
400
 
386
- // src/config/loader.ts
387
- var loader_exports = {};
388
- __export(loader_exports, {
389
- CONFIG_DIR: () => CONFIG_DIR,
390
- LEGACY_CONFIG_DIR: () => LEGACY_CONFIG_DIR,
391
- ensureAllowedDomain: () => ensureAllowedDomain,
392
- findConfigPath: () => findConfigPath,
393
- listHunts: () => listHunts,
394
- loadConfig: () => loadConfig,
395
- loadHunt: () => loadHunt,
396
- loadHuntMeta: () => loadHuntMeta,
397
- loadHuntTags: () => loadHuntTags,
398
- resolveViewport: () => resolveViewport,
399
- warnLegacyConfigDir: () => warnLegacyConfigDir
400
- });
401
- function warnLegacyConfigDir() {
402
- if (legacyDirWarned) {
401
+ // src/config/interpolate.ts
402
+ function collectInterpolatedValues(input, vars, values) {
403
+ if (typeof input === "string") {
404
+ for (const match of input.matchAll(VAR_PATTERN)) {
405
+ const varValue = vars[match[1]];
406
+ if (varValue) values.add(varValue);
407
+ }
403
408
  return;
404
409
  }
405
- legacyDirWarned = true;
406
- console.warn(
407
- 'Warning: the ".prowlqa/" config directory is deprecated; rename it to ".prowl/". Support for ".prowlqa/" will be removed in a future release.'
408
- );
409
- }
410
- function findConfigPath(startDir) {
411
- let current = startDir;
412
- while (current) {
413
- for (const dir of [CONFIG_DIR, LEGACY_CONFIG_DIR]) {
414
- const candidate = import_node_path.default.join(current, dir, "config.yml");
415
- if (import_node_fs.default.existsSync(candidate)) {
416
- return candidate;
417
- }
410
+ if (Array.isArray(input)) {
411
+ for (const item of input) {
412
+ collectInterpolatedValues(item, vars, values);
418
413
  }
419
- const parent = import_node_path.default.dirname(current);
420
- if (parent === current) {
421
- break;
414
+ return;
415
+ }
416
+ if (input && typeof input === "object") {
417
+ for (const [key, value] of Object.entries(input)) {
418
+ collectInterpolatedValues(key, vars, values);
419
+ collectInterpolatedValues(value, vars, values);
422
420
  }
423
- current = parent;
424
421
  }
425
- return null;
426
422
  }
427
- function resolveViewport(value) {
428
- if (value === void 0) {
429
- return DEFAULT_CONFIG.browser.viewport;
430
- }
431
- if (typeof value === "string") {
432
- const preset = VIEWPORT_PRESETS[value];
433
- if (!preset) {
434
- throw new Error(`Unknown viewport preset: "${value}". Use mobile, tablet, or desktop.`);
423
+ function interpolateString(input, vars) {
424
+ const usedVars = [];
425
+ const value = input.replace(VAR_PATTERN, (_, name) => {
426
+ const varValue = vars[name];
427
+ if (varValue === void 0) {
428
+ throw new Error(`Missing variable: ${name}`);
435
429
  }
436
- return preset;
437
- }
438
- return value;
430
+ usedVars.push(name);
431
+ return varValue;
432
+ });
433
+ return { value, usedVars };
439
434
  }
440
- function resolveTarget(target) {
441
- const type = target?.type;
442
- if (type === "macos") {
443
- return { type: "macos", app: target.app };
435
+ function generateRandomVars(randomSource) {
436
+ const random = randomSource?.random ?? Math.random;
437
+ const randomBytes = randomSource?.randomBytes ?? import_node_crypto.default.randomBytes;
438
+ const randomUUID = randomSource?.randomUUID ?? import_node_crypto.default.randomUUID;
439
+ const hex = randomBytes(4).toString("hex");
440
+ const firstIndex = Math.floor(random() * RANDOM_FIRST_NAMES.length);
441
+ const lastIndex = Math.floor(random() * RANDOM_LAST_NAMES.length);
442
+ const num2 = Math.floor(random() * 9e3) + 1e3;
443
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
444
+ let text = "";
445
+ for (let i = 0; i < 8; i++) {
446
+ text += chars[Math.floor(random() * chars.length)];
444
447
  }
445
- if (type === "android") {
446
- const androidTarget = target;
448
+ return {
449
+ RANDOM_EMAIL: `prowl_${hex}@test.com`,
450
+ RANDOM_NAME: `${RANDOM_FIRST_NAMES[firstIndex]} ${RANDOM_LAST_NAMES[lastIndex]}`,
451
+ RANDOM_NUMBER: String(num2),
452
+ RANDOM_UUID: randomUUID(),
453
+ RANDOM_TEXT: text
454
+ };
455
+ }
456
+ function interpolateStep(step, vars, stepPath2, redacted) {
457
+ const isExplicitFill = (value) => typeof value.selector === "string" && typeof value.value === "string";
458
+ const interpolateSinglePair = (record) => {
459
+ const entries = Object.entries(record);
460
+ if (entries.length !== 1) {
461
+ throw new Error("Shorthand step expects exactly one key-value pair");
462
+ }
463
+ const [key, value] = entries[0];
447
464
  return {
448
- type: "android",
449
- app: androidTarget.app,
450
- ...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
451
- ...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
465
+ [interpolateString(key, vars).value]: interpolateString(value, vars).value
452
466
  };
467
+ };
468
+ if ("navigate" in step) {
469
+ const result = interpolateString(step.navigate, vars);
470
+ return { navigate: result.value };
453
471
  }
454
- if (type === "ios") {
455
- const iosTarget = target;
472
+ if ("click" in step) {
473
+ if (typeof step.click === "string") {
474
+ return { click: interpolateString(step.click, vars).value };
475
+ }
476
+ const result = interpolateString(step.click.selector, vars);
477
+ return { click: { selector: result.value } };
478
+ }
479
+ if ("fill" in step) {
480
+ if (isExplicitFill(step.fill)) {
481
+ const selectorResult = interpolateString(step.fill.selector, vars);
482
+ const valueResult2 = interpolateString(step.fill.value, vars);
483
+ if (valueResult2.usedVars.length > 0) {
484
+ redacted.add(stepPath2);
485
+ }
486
+ return { fill: { selector: selectorResult.value, value: valueResult2.value } };
487
+ }
488
+ const [rawLabel, rawValue] = Object.entries(step.fill)[0] ?? [];
489
+ if (rawLabel === void 0 || rawValue === void 0) {
490
+ throw new Error("Shorthand fill expects exactly one key-value pair");
491
+ }
492
+ const labelResult = interpolateString(rawLabel, vars);
493
+ const valueResult = interpolateString(rawValue, vars);
494
+ if (valueResult.usedVars.length > 0) {
495
+ redacted.add(stepPath2);
496
+ }
456
497
  return {
457
- type: "ios",
458
- app: iosTarget.app,
459
- ...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
460
- ...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
498
+ fill: {
499
+ [labelResult.value]: valueResult.value
500
+ }
461
501
  };
462
502
  }
463
- return {
464
- type: "web",
465
- url: target?.url ?? DEFAULT_WEB_URL
466
- };
467
- }
468
- function mergeConfig(partial) {
469
- return {
470
- target: resolveTarget(partial.target),
471
- browser: {
472
- headless: partial.browser?.headless ?? DEFAULT_CONFIG.browser.headless,
473
- slowMo: partial.browser?.slowMo ?? DEFAULT_CONFIG.browser.slowMo,
474
- timeout: partial.browser?.timeout ?? DEFAULT_CONFIG.browser.timeout,
475
- engine: partial.browser?.engine ?? DEFAULT_CONFIG.browser.engine,
476
- channel: partial.browser?.channel,
477
- viewport: resolveViewport(partial.browser?.viewport)
478
- },
479
- artifacts: {
480
- screenshots: partial.artifacts?.screenshots ?? DEFAULT_CONFIG.artifacts.screenshots,
481
- networkHar: partial.artifacts?.networkHar ?? DEFAULT_CONFIG.artifacts.networkHar,
482
- console: partial.artifacts?.console ?? DEFAULT_CONFIG.artifacts.console,
483
- junit: partial.artifacts?.junit ?? DEFAULT_CONFIG.artifacts.junit
484
- },
485
- assertions: {
486
- noConsoleErrors: partial.assertions?.noConsoleErrors ?? DEFAULT_CONFIG.assertions.noConsoleErrors,
487
- noNetworkErrors: partial.assertions?.noNetworkErrors ?? DEFAULT_CONFIG.assertions.noNetworkErrors,
488
- maxTotalTimeMs: partial.assertions?.maxTotalTimeMs ?? DEFAULT_CONFIG.assertions.maxTotalTimeMs,
489
- networkIgnorePatterns: partial.assertions?.networkIgnorePatterns ?? DEFAULT_CONFIG.assertions.networkIgnorePatterns
490
- },
491
- guardrails: {
492
- maxSteps: partial.guardrails?.maxSteps ?? DEFAULT_CONFIG.guardrails.maxSteps,
493
- allowedDomains: partial.guardrails?.allowedDomains ?? DEFAULT_CONFIG.guardrails.allowedDomains,
494
- allowedApps: partial.guardrails?.allowedApps ?? DEFAULT_CONFIG.guardrails.allowedApps,
495
- forbiddenSelectors: partial.guardrails?.forbiddenSelectors ?? DEFAULT_CONFIG.guardrails.forbiddenSelectors,
496
- selfHealing: partial.guardrails?.selfHealing ?? DEFAULT_CONFIG.guardrails.selfHealing
497
- },
498
- auth: {
499
- storageStatePath: partial.auth?.storageStatePath ?? (partial.auth !== void 0 ? DEFAULT_CONFIG.auth.storageStatePath : void 0)
500
- },
501
- history: {
502
- maxRuns: partial.history?.maxRuns ?? DEFAULT_CONFIG.history.maxRuns
503
- },
504
- bugLog: partial.bugLog,
505
- tracing: partial.tracing,
506
- reliability: partial.reliability
507
- };
508
- }
509
- function ensureAllowedDomain(allowed, urlValue) {
510
- try {
511
- const host = new URL(urlValue).hostname;
512
- if (!allowed.includes(host)) {
513
- return [...allowed, host];
503
+ if ("type" in step) {
504
+ const valueResult = interpolateString(step.type, vars);
505
+ if (valueResult.usedVars.length > 0) {
506
+ redacted.add(stepPath2);
514
507
  }
515
- } catch {
516
- return allowed;
508
+ return { type: valueResult.value };
517
509
  }
518
- return allowed;
519
- }
520
- function loadConfig(configPath) {
521
- const resolvedPath = configPath ? import_node_path.default.resolve(configPath) : findConfigPath(process.cwd());
522
- if (!resolvedPath) {
523
- throw new Error("Could not find .prowl/config.yml. Run `prowl init` first.");
510
+ if ("selectOption" in step) {
511
+ const selectorResult = interpolateString(step.selectOption.selector, vars);
512
+ const valueResult = interpolateString(step.selectOption.value, vars);
513
+ return { selectOption: { selector: selectorResult.value, value: valueResult.value } };
524
514
  }
525
- if (!import_node_fs.default.existsSync(resolvedPath)) {
526
- throw new Error(`Config file not found at ${resolvedPath}`);
515
+ if ("select" in step) {
516
+ return { select: interpolateSinglePair(step.select) };
527
517
  }
528
- const configDir = import_node_path.default.dirname(resolvedPath);
529
- if (import_node_path.default.basename(configDir) === LEGACY_CONFIG_DIR) {
530
- warnLegacyConfigDir();
518
+ if ("press" in step) {
519
+ const selectorResult = interpolateString(step.press.selector, vars);
520
+ const keyResult = interpolateString(step.press.key, vars);
521
+ return { press: { selector: selectorResult.value, key: keyResult.value } };
531
522
  }
532
- import_dotenv.default.config({ path: import_node_path.default.join(configDir, ".env"), override: false });
533
- const raw = import_node_fs.default.readFileSync(resolvedPath, "utf-8");
534
- const parsed = import_yaml.default.parse(raw) ?? {};
535
- const validated = configSchema.parse(parsed);
536
- const config = mergeConfig(validated);
537
- if (config.target.type === "web") {
538
- config.guardrails.allowedDomains = ensureAllowedDomain(
539
- config.guardrails.allowedDomains,
540
- config.target.url
541
- );
523
+ if ("onDialog" in step) {
524
+ return { onDialog: { action: step.onDialog.action } };
542
525
  }
543
- return { config, configPath: resolvedPath, configDir };
544
- }
545
- function loadHunt(huntName, configDir) {
546
- assertValidHuntName(huntName);
547
- const huntPath = import_node_path.default.join(configDir, "hunts", `${huntName}.yml`);
548
- if (!import_node_fs.default.existsSync(huntPath)) {
549
- throw new Error(`Hunt file not found: ${huntPath}`);
526
+ if ("setInputFiles" in step) {
527
+ const selectorResult = interpolateString(step.setInputFiles.selector, vars);
528
+ const rawFiles = step.setInputFiles.files;
529
+ const files = Array.isArray(rawFiles) ? rawFiles.map((f) => interpolateString(f, vars).value) : interpolateString(rawFiles, vars).value;
530
+ return { setInputFiles: { selector: selectorResult.value, files } };
550
531
  }
551
- const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
552
- const parsed = import_yaml.default.parse(raw) ?? {};
553
- const validated = huntSchema.parse(parsed);
554
- return validated;
555
- }
556
- function loadHuntTags(huntName, configDir) {
557
- assertValidHuntName(huntName);
558
- const huntPath = import_node_path.default.join(configDir, "hunts", `${huntName}.yml`);
559
- if (!import_node_fs.default.existsSync(huntPath)) {
560
- return [];
561
- }
562
- const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
563
- const parsed = import_yaml.default.parse(raw) ?? {};
564
- return Array.isArray(parsed.tags) ? parsed.tags : [];
565
- }
566
- function loadHuntMeta(huntName, configDir) {
567
- assertValidHuntName(huntName);
568
- const huntPath = import_node_path.default.join(configDir, "hunts", `${huntName}.yml`);
569
- if (!import_node_fs.default.existsSync(huntPath)) {
570
- return { tags: [] };
571
- }
572
- const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
573
- const parsed = import_yaml.default.parse(raw) ?? {};
574
- return {
575
- description: typeof parsed.description === "string" ? parsed.description : void 0,
576
- tags: Array.isArray(parsed.tags) ? parsed.tags : []
577
- };
578
- }
579
- function listHunts(configDir) {
580
- const huntsDir = import_node_path.default.join(configDir, "hunts");
581
- if (!import_node_fs.default.existsSync(huntsDir)) {
582
- return [];
583
- }
584
- const stats = import_node_fs.default.statSync(huntsDir);
585
- if (!stats.isDirectory()) {
586
- throw new Error(`Hunts path is not a directory: ${huntsDir}`);
587
- }
588
- const results = [];
589
- function scanDir(dir) {
590
- const entries = import_node_fs.default.readdirSync(dir, { withFileTypes: true });
591
- for (const entry of entries) {
592
- if (entry.isFile() && entry.name.endsWith(".yml")) {
593
- const fullPath = import_node_path.default.join(dir, entry.name);
594
- const relative = import_node_path.default.relative(huntsDir, fullPath);
595
- results.push(relative.replace(/\.yml$/, ""));
596
- } else if (entry.isDirectory()) {
597
- scanDir(import_node_path.default.join(dir, entry.name));
532
+ if ("runHunt" in step) {
533
+ if (typeof step.runHunt === "string") {
534
+ return { runHunt: interpolateString(step.runHunt, vars).value };
535
+ }
536
+ const nameResult = interpolateString(step.runHunt.name, vars);
537
+ const interpolatedVars = {};
538
+ for (const [key, value] of Object.entries(step.runHunt.vars ?? {})) {
539
+ interpolatedVars[key] = interpolateString(value, vars).value;
540
+ }
541
+ return {
542
+ runHunt: {
543
+ name: nameResult.value,
544
+ ...Object.keys(interpolatedVars).length > 0 ? { vars: interpolatedVars } : {}
598
545
  }
546
+ };
547
+ }
548
+ if ("assert" in step) {
549
+ if (step.assert.visible !== void 0) {
550
+ return { assert: { visible: interpolateString(step.assert.visible, vars).value } };
551
+ }
552
+ if (step.assert.notVisible !== void 0) {
553
+ return { assert: { notVisible: interpolateString(step.assert.notVisible, vars).value } };
554
+ }
555
+ if (step.assert.urlIncludes !== void 0) {
556
+ return { assert: { urlIncludes: interpolateString(step.assert.urlIncludes, vars).value } };
557
+ }
558
+ if (step.assert.urlEquals !== void 0) {
559
+ return { assert: { urlEquals: interpolateString(step.assert.urlEquals, vars).value } };
599
560
  }
561
+ return step;
600
562
  }
601
- scanDir(huntsDir);
602
- return results.sort((a, b) => a.localeCompare(b));
603
- }
604
- var import_node_fs, import_node_path, import_yaml, import_dotenv, DEFAULT_WEB_URL, DEFAULT_CONFIG, CONFIG_DIR, LEGACY_CONFIG_DIR, legacyDirWarned, VIEWPORT_PRESETS;
605
- var init_loader = __esm({
606
- "src/config/loader.ts"() {
607
- "use strict";
608
- import_node_fs = __toESM(require("fs"), 1);
609
- import_node_path = __toESM(require("path"), 1);
610
- import_yaml = __toESM(require("yaml"), 1);
611
- import_dotenv = __toESM(require("dotenv"), 1);
612
- init_schema();
613
- init_hunt_name();
614
- DEFAULT_WEB_URL = "http://localhost:3000";
615
- DEFAULT_CONFIG = {
616
- target: {
617
- type: "web",
618
- url: DEFAULT_WEB_URL
619
- },
620
- browser: {
621
- headless: true,
622
- slowMo: 0,
623
- timeout: 3e4,
624
- engine: "chromium",
625
- viewport: { width: 1280, height: 720 }
626
- },
627
- artifacts: {
628
- screenshots: "on-failure",
629
- networkHar: false,
630
- console: true,
631
- junit: false
632
- },
633
- assertions: {
634
- noConsoleErrors: true,
635
- noNetworkErrors: true,
636
- maxTotalTimeMs: 3e4,
637
- networkIgnorePatterns: []
638
- },
639
- guardrails: {
640
- maxSteps: 50,
641
- allowedDomains: ["localhost", "127.0.0.1", "0.0.0.0"],
642
- allowedApps: [],
643
- forbiddenSelectors: ["[data-danger]", ".delete-btn"],
644
- selfHealing: false
645
- },
646
- auth: {
647
- storageStatePath: ".prowl/auth-state.json"
648
- },
649
- history: {
650
- maxRuns: 100
563
+ if ("wait" in step) {
564
+ if (typeof step.wait === "string") {
565
+ return { wait: interpolateString(step.wait, vars).value };
566
+ }
567
+ return {
568
+ wait: {
569
+ for: interpolateString(step.wait.for, vars).value,
570
+ timeout: step.wait.timeout
651
571
  }
652
572
  };
653
- CONFIG_DIR = ".prowl";
654
- LEGACY_CONFIG_DIR = ".prowlqa";
655
- legacyDirWarned = false;
656
- VIEWPORT_PRESETS = {
657
- mobile: { width: 375, height: 812 },
658
- tablet: { width: 768, height: 1024 },
659
- desktop: { width: 1280, height: 720 }
660
- };
661
573
  }
662
- });
663
-
664
- // src/runner/visual.ts
665
- var visual_exports = {};
666
- __export(visual_exports, {
667
- compareScreenshots: () => compareScreenshots,
668
- ensureBaselineDir: () => ensureBaselineDir
669
- });
670
- async function compareScreenshots(baselinePath, currentPath, diffPath, threshold) {
671
- const baselineData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(baselinePath));
672
- const currentData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(currentPath));
673
- const { width, height } = baselineData;
674
- if (currentData.width !== width || currentData.height !== height) {
675
- const diff2 = new import_pngjs.PNG({ width, height });
676
- import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
574
+ if ("waitForSelector" in step) {
575
+ const selectorResult = interpolateString(step.waitForSelector.selector, vars);
677
576
  return {
678
- match: false,
679
- diffPercentage: 1,
680
- diffImagePath: diffPath
577
+ waitForSelector: {
578
+ selector: selectorResult.value,
579
+ timeout: step.waitForSelector.timeout
580
+ }
681
581
  };
682
582
  }
683
- const diff = new import_pngjs.PNG({ width, height });
684
- const diffPixels = (0, import_pixelmatch.default)(
685
- baselineData.data,
686
- currentData.data,
687
- diff.data,
688
- width,
689
- height,
690
- { threshold: 0.1 }
691
- );
692
- const totalPixels = width * height;
693
- const diffPercentage = diffPixels / totalPixels;
694
- import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
695
- return {
696
- match: diffPercentage <= threshold,
697
- diffPercentage,
698
- diffImagePath: diffPath
699
- };
700
- }
701
- function ensureBaselineDir(configDir) {
702
- const baselineDir = import_node_path8.default.join(configDir, "baselines");
703
- import_node_fs8.default.mkdirSync(baselineDir, { recursive: true });
704
- return baselineDir;
705
- }
706
- var import_node_fs8, import_node_path8, import_pngjs, import_pixelmatch;
707
- var init_visual = __esm({
708
- "src/runner/visual.ts"() {
709
- "use strict";
710
- import_node_fs8 = __toESM(require("fs"), 1);
711
- import_node_path8 = __toESM(require("path"), 1);
712
- import_pngjs = require("pngjs");
713
- import_pixelmatch = __toESM(require("pixelmatch"), 1);
714
- }
715
- });
716
-
717
- // src/cli/program.ts
718
- var import_commander13 = require("commander");
719
-
720
- // package.json
721
- var package_default = {
722
- name: "prowl-tools",
723
- version: "0.1.6",
724
- description: "CLI-first QA testing tool for deterministic Playwright flows.",
725
- type: "module",
726
- license: "Apache-2.0",
727
- author: "Prowl Tools",
728
- repository: {
729
- type: "git",
730
- url: "https://github.com/prowl-tools/prowl.git"
731
- },
732
- homepage: "https://prowl.tools",
733
- bugs: {
734
- url: "https://github.com/prowl-tools/prowl/issues"
735
- },
736
- keywords: [
737
- "testing",
738
- "qa",
739
- "playwright",
740
- "yaml",
741
- "e2e",
742
- "browser-testing",
743
- "automation",
744
- "web-testing",
745
- "cli"
746
- ],
747
- main: "dist/lib.cjs",
748
- module: "dist/lib.js",
749
- types: "dist/lib.d.ts",
750
- exports: {
751
- ".": {
752
- import: {
753
- types: "./dist/lib.d.ts",
754
- default: "./dist/lib.js"
755
- },
756
- require: {
757
- types: "./dist/lib.d.cts",
758
- default: "./dist/lib.cjs"
583
+ if ("waitForUrl" in step) {
584
+ const valueResult = interpolateString(step.waitForUrl.value, vars);
585
+ return {
586
+ waitForUrl: {
587
+ value: valueResult.value,
588
+ timeout: step.waitForUrl.timeout
759
589
  }
760
- }
761
- },
762
- bin: {
763
- prowl: "dist/index.js"
764
- },
765
- files: [
766
- "dist",
767
- "examples",
768
- "LICENSE",
769
- "README.md",
770
- "NOTICE"
771
- ],
772
- engines: {
773
- node: ">=20.0.0"
774
- },
775
- scripts: {
776
- build: "tsup",
777
- lint: "eslint .",
778
- test: "vitest run",
779
- "test:watch": "vitest"
780
- },
781
- dependencies: {
782
- "@modelcontextprotocol/sdk": "^1.29.0",
783
- chalk: "^5.3.0",
784
- commander: "^12.1.0",
785
- dotenv: "^16.6.1",
786
- ora: "^8.1.1",
787
- pixelmatch: "^7.1.0",
788
- playwright: "^1.50.1",
789
- pngjs: "^7.0.0",
790
- yaml: "^2.6.1",
791
- zod: "^3.23.8"
792
- },
793
- optionalDependencies: {
794
- "appium-uiautomator2-server": "10.6.2",
795
- "appium-webdriveragent": "16.4.0"
796
- },
797
- devDependencies: {
798
- "@types/node": "^22.13.1",
799
- "@types/pngjs": "^6.0.5",
800
- "@typescript-eslint/eslint-plugin": "^7.18.0",
801
- "@typescript-eslint/parser": "^7.18.0",
802
- eslint: "^8.57.1",
803
- tsup: "^8.3.5",
804
- typescript: "^5.7.3",
805
- vitest: "^2.1.8"
590
+ };
806
591
  }
807
- };
808
-
809
- // src/cli/commands/run.ts
810
- var import_commander = require("commander");
811
- var import_chalk3 = __toESM(require("chalk"), 1);
812
-
813
- // src/runner/index.ts
814
- var import_node_fs14 = __toESM(require("fs"), 1);
815
- var import_node_path14 = __toESM(require("path"), 1);
816
- init_loader();
817
-
818
- // src/config/interpolate.ts
819
- var import_node_crypto = __toESM(require("crypto"), 1);
820
- var VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
821
- function collectInterpolatedValues(input, vars, values) {
822
- if (typeof input === "string") {
823
- for (const match of input.matchAll(VAR_PATTERN)) {
824
- const varValue = vars[match[1]];
825
- if (varValue) values.add(varValue);
826
- }
827
- return;
592
+ if ("waitForNetworkIdle" in step) {
593
+ return { waitForNetworkIdle: { timeout: step.waitForNetworkIdle.timeout } };
828
594
  }
829
- if (Array.isArray(input)) {
830
- for (const item of input) {
831
- collectInterpolatedValues(item, vars, values);
832
- }
833
- return;
595
+ if ("hover" in step) {
596
+ const selectorResult = interpolateString(step.hover.selector, vars);
597
+ return { hover: { selector: selectorResult.value } };
834
598
  }
835
- if (input && typeof input === "object") {
836
- for (const [key, value] of Object.entries(input)) {
837
- collectInterpolatedValues(key, vars, values);
838
- collectInterpolatedValues(value, vars, values);
839
- }
599
+ if ("scroll" in step) {
600
+ return { scroll: { direction: step.scroll.direction, amount: step.scroll.amount } };
840
601
  }
841
- }
842
- function interpolateString(input, vars) {
843
- const usedVars = [];
844
- const value = input.replace(VAR_PATTERN, (_, name) => {
845
- const varValue = vars[name];
846
- if (varValue === void 0) {
847
- throw new Error(`Missing variable: ${name}`);
848
- }
849
- usedVars.push(name);
850
- return varValue;
851
- });
852
- return { value, usedVars };
853
- }
854
- var RANDOM_FIRST_NAMES = ["Alex", "Jordan", "Morgan", "Taylor", "Casey", "Riley", "Quinn", "Avery"];
855
- var RANDOM_LAST_NAMES = ["Smith", "Johnson", "Brown", "Davis", "Wilson", "Clark", "Hall", "Young"];
856
- function generateRandomVars(randomSource) {
857
- const random = randomSource?.random ?? Math.random;
858
- const randomBytes = randomSource?.randomBytes ?? import_node_crypto.default.randomBytes;
859
- const randomUUID = randomSource?.randomUUID ?? import_node_crypto.default.randomUUID;
860
- const hex = randomBytes(4).toString("hex");
861
- const firstIndex = Math.floor(random() * RANDOM_FIRST_NAMES.length);
862
- const lastIndex = Math.floor(random() * RANDOM_LAST_NAMES.length);
863
- const num2 = Math.floor(random() * 9e3) + 1e3;
864
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
865
- let text = "";
866
- for (let i = 0; i < 8; i++) {
867
- text += chars[Math.floor(random() * chars.length)];
602
+ if ("scrollTo" in step) {
603
+ const selectorResult = interpolateString(step.scrollTo.selector, vars);
604
+ return { scrollTo: { selector: selectorResult.value } };
868
605
  }
869
- return {
870
- RANDOM_EMAIL: `prowl_${hex}@test.com`,
871
- RANDOM_NAME: `${RANDOM_FIRST_NAMES[firstIndex]} ${RANDOM_LAST_NAMES[lastIndex]}`,
872
- RANDOM_NUMBER: String(num2),
873
- RANDOM_UUID: randomUUID(),
874
- RANDOM_TEXT: text
875
- };
876
- }
877
- function interpolateStep(step, vars, stepPath2, redacted) {
878
- const isExplicitFill = (value) => typeof value.selector === "string" && typeof value.value === "string";
879
- const interpolateSinglePair = (record) => {
880
- const entries = Object.entries(record);
881
- if (entries.length !== 1) {
882
- throw new Error("Shorthand step expects exactly one key-value pair");
883
- }
884
- const [key, value] = entries[0];
606
+ if ("screenshot" in step) {
607
+ return { screenshot: { name: step.screenshot.name } };
608
+ }
609
+ if ("if" in step) {
610
+ const condition = step.if;
611
+ const thenSteps = condition.then.map(
612
+ (s, i) => interpolateStep(s, vars, `${stepPath2}.if.then.${i}`, redacted)
613
+ );
614
+ const elseSteps = condition.else?.map(
615
+ (s, i) => interpolateStep(s, vars, `${stepPath2}.if.else.${i}`, redacted)
616
+ );
885
617
  return {
886
- [interpolateString(key, vars).value]: interpolateString(value, vars).value
618
+ if: {
619
+ ...condition.visible !== void 0 ? { visible: interpolateString(condition.visible, vars).value } : {},
620
+ ...condition.notVisible !== void 0 ? { notVisible: interpolateString(condition.notVisible, vars).value } : {},
621
+ then: thenSteps,
622
+ ...elseSteps !== void 0 ? { else: elseSteps } : {}
623
+ }
887
624
  };
888
- };
889
- if ("navigate" in step) {
890
- const result = interpolateString(step.navigate, vars);
891
- return { navigate: result.value };
892
625
  }
893
- if ("click" in step) {
894
- if (typeof step.click === "string") {
895
- return { click: interpolateString(step.click, vars).value };
896
- }
897
- const result = interpolateString(step.click.selector, vars);
898
- return { click: { selector: result.value } };
626
+ if ("repeat" in step) {
627
+ const repeat = step.repeat;
628
+ const subSteps = repeat.steps.map(
629
+ (s, i) => interpolateStep(s, vars, `${stepPath2}.repeat.steps.${i}`, redacted)
630
+ );
631
+ return {
632
+ repeat: {
633
+ ...repeat.times !== void 0 ? { times: repeat.times } : {},
634
+ ...repeat.while !== void 0 ? {
635
+ while: {
636
+ ...repeat.while.visible !== void 0 ? { visible: interpolateString(repeat.while.visible, vars).value } : {},
637
+ ...repeat.while.notVisible !== void 0 ? { notVisible: interpolateString(repeat.while.notVisible, vars).value } : {}
638
+ }
639
+ } : {},
640
+ ...repeat.maxIterations !== void 0 ? { maxIterations: repeat.maxIterations } : {},
641
+ steps: subSteps
642
+ }
643
+ };
899
644
  }
900
- if ("fill" in step) {
901
- if (isExplicitFill(step.fill)) {
902
- const selectorResult = interpolateString(step.fill.selector, vars);
903
- const valueResult2 = interpolateString(step.fill.value, vars);
904
- if (valueResult2.usedVars.length > 0) {
905
- redacted.add(stepPath2);
645
+ if ("mockRoute" in step) {
646
+ const mock = step.mockRoute;
647
+ return {
648
+ mockRoute: {
649
+ url: interpolateString(mock.url, vars).value,
650
+ response: {
651
+ status: mock.response.status,
652
+ ...mock.response.contentType !== void 0 ? { contentType: interpolateString(mock.response.contentType, vars).value } : {},
653
+ ...mock.response.body !== void 0 ? { body: interpolateString(mock.response.body, vars).value } : {},
654
+ ...mock.response.file !== void 0 ? { file: interpolateString(mock.response.file, vars).value } : {}
655
+ }
906
656
  }
907
- return { fill: { selector: selectorResult.value, value: valueResult2.value } };
908
- }
909
- const [rawLabel, rawValue] = Object.entries(step.fill)[0] ?? [];
910
- if (rawLabel === void 0 || rawValue === void 0) {
911
- throw new Error("Shorthand fill expects exactly one key-value pair");
657
+ };
658
+ }
659
+ if ("unmockRoute" in step) {
660
+ if (typeof step.unmockRoute === "string") {
661
+ return { unmockRoute: interpolateString(step.unmockRoute, vars).value };
912
662
  }
913
- const labelResult = interpolateString(rawLabel, vars);
914
- const valueResult = interpolateString(rawValue, vars);
915
- if (valueResult.usedVars.length > 0) {
916
- redacted.add(stepPath2);
663
+ return {
664
+ unmockRoute: { url: interpolateString(step.unmockRoute.url, vars).value }
665
+ };
666
+ }
667
+ if ("evalScript" in step) {
668
+ if (typeof step.evalScript === "string") {
669
+ return { evalScript: interpolateString(step.evalScript, vars).value };
917
670
  }
918
671
  return {
919
- fill: {
920
- [labelResult.value]: valueResult.value
672
+ evalScript: {
673
+ expression: interpolateString(step.evalScript.expression, vars).value,
674
+ ...step.evalScript.as !== void 0 ? { as: step.evalScript.as } : {}
921
675
  }
922
676
  };
923
677
  }
924
- if ("type" in step) {
925
- const valueResult = interpolateString(step.type, vars);
926
- if (valueResult.usedVars.length > 0) {
927
- redacted.add(stepPath2);
928
- }
929
- return { type: valueResult.value };
930
- }
931
- if ("selectOption" in step) {
932
- const selectorResult = interpolateString(step.selectOption.selector, vars);
933
- const valueResult = interpolateString(step.selectOption.value, vars);
934
- return { selectOption: { selector: selectorResult.value, value: valueResult.value } };
935
- }
936
- if ("select" in step) {
937
- return { select: interpolateSinglePair(step.select) };
678
+ if ("runScript" in step) {
679
+ return {
680
+ runScript: { file: interpolateString(step.runScript.file, vars).value }
681
+ };
938
682
  }
939
- if ("press" in step) {
940
- const selectorResult = interpolateString(step.press.selector, vars);
941
- const keyResult = interpolateString(step.press.key, vars);
942
- return { press: { selector: selectorResult.value, key: keyResult.value } };
683
+ if ("assertScreenshot" in step) {
684
+ return {
685
+ assertScreenshot: {
686
+ name: interpolateString(step.assertScreenshot.name, vars).value,
687
+ ...step.assertScreenshot.threshold !== void 0 ? { threshold: step.assertScreenshot.threshold } : {}
688
+ }
689
+ };
943
690
  }
944
- if ("onDialog" in step) {
945
- return { onDialog: { action: step.onDialog.action } };
691
+ if ("assertWithAI" in step) {
692
+ return {
693
+ assertWithAI: interpolateString(step.assertWithAI, vars).value
694
+ };
946
695
  }
947
- if ("setInputFiles" in step) {
948
- const selectorResult = interpolateString(step.setInputFiles.selector, vars);
949
- const rawFiles = step.setInputFiles.files;
950
- const files = Array.isArray(rawFiles) ? rawFiles.map((f) => interpolateString(f, vars).value) : interpolateString(rawFiles, vars).value;
951
- return { setInputFiles: { selector: selectorResult.value, files } };
696
+ if ("copyText" in step) {
697
+ return {
698
+ copyText: {
699
+ selector: interpolateString(step.copyText.selector, vars).value,
700
+ as: step.copyText.as
701
+ }
702
+ };
952
703
  }
953
- if ("runHunt" in step) {
954
- if (typeof step.runHunt === "string") {
955
- return { runHunt: interpolateString(step.runHunt, vars).value };
956
- }
957
- const nameResult = interpolateString(step.runHunt.name, vars);
958
- const interpolatedVars = {};
959
- for (const [key, value] of Object.entries(step.runHunt.vars ?? {})) {
960
- interpolatedVars[key] = interpolateString(value, vars).value;
704
+ if ("waitForDownload" in step) {
705
+ if (step.waitForDownload === null) {
706
+ return { waitForDownload: null };
961
707
  }
962
708
  return {
963
- runHunt: {
964
- name: nameResult.value,
965
- ...Object.keys(interpolatedVars).length > 0 ? { vars: interpolatedVars } : {}
709
+ waitForDownload: {
710
+ ...step.waitForDownload.filename !== void 0 ? { filename: interpolateString(step.waitForDownload.filename, vars).value } : {},
711
+ ...step.waitForDownload.timeout !== void 0 ? { timeout: step.waitForDownload.timeout } : {}
966
712
  }
967
713
  };
968
714
  }
969
- if ("assert" in step) {
970
- if (step.assert.visible !== void 0) {
971
- return { assert: { visible: interpolateString(step.assert.visible, vars).value } };
972
- }
973
- if (step.assert.notVisible !== void 0) {
974
- return { assert: { notVisible: interpolateString(step.assert.notVisible, vars).value } };
975
- }
976
- if (step.assert.urlIncludes !== void 0) {
977
- return { assert: { urlIncludes: interpolateString(step.assert.urlIncludes, vars).value } };
715
+ return step;
716
+ }
717
+ function interpolateAssertion(assertion, vars) {
718
+ if ("selectorExists" in assertion) {
719
+ return { selectorExists: interpolateString(assertion.selectorExists, vars).value };
720
+ }
721
+ if ("selectorNotExists" in assertion) {
722
+ return { selectorNotExists: interpolateString(assertion.selectorNotExists, vars).value };
723
+ }
724
+ if ("urlIncludes" in assertion) {
725
+ return { urlIncludes: interpolateString(assertion.urlIncludes, vars).value };
726
+ }
727
+ if ("urlEquals" in assertion) {
728
+ return { urlEquals: interpolateString(assertion.urlEquals, vars).value };
729
+ }
730
+ if ("noConsoleErrors" in assertion) {
731
+ return { noConsoleErrors: assertion.noConsoleErrors };
732
+ }
733
+ if ("noNetworkErrors" in assertion) {
734
+ return { noNetworkErrors: assertion.noNetworkErrors };
735
+ }
736
+ return assertion;
737
+ }
738
+ function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
739
+ const redactedFillSteps = /* @__PURE__ */ new Set();
740
+ const envVars = Object.fromEntries(
741
+ Object.entries(env).filter(([, value]) => value !== void 0)
742
+ );
743
+ const baseVars = { ...randomVars, ...envVars };
744
+ const resolvedHuntVars = {};
745
+ for (const [key, value] of Object.entries(hunt.vars ?? {})) {
746
+ resolvedHuntVars[key] = interpolateString(value, baseVars).value;
747
+ }
748
+ const vars = { ...baseVars, ...resolvedHuntVars };
749
+ const redactionValues = /* @__PURE__ */ new Set();
750
+ collectInterpolatedValues(hunt.steps, vars, redactionValues);
751
+ collectInterpolatedValues(hunt.assertions, vars, redactionValues);
752
+ const steps = hunt.steps.map(
753
+ (step, index) => interpolateStep(step, vars, `${index}`, redactedFillSteps)
754
+ );
755
+ const assertions = hunt.assertions?.map((assertion) => interpolateAssertion(assertion, vars));
756
+ return {
757
+ hunt: {
758
+ ...hunt,
759
+ steps,
760
+ assertions
761
+ },
762
+ redactedFillSteps,
763
+ randomVars,
764
+ redactionValues: [...redactionValues]
765
+ };
766
+ }
767
+ var import_node_crypto, VAR_PATTERN, RANDOM_FIRST_NAMES, RANDOM_LAST_NAMES;
768
+ var init_interpolate = __esm({
769
+ "src/config/interpolate.ts"() {
770
+ "use strict";
771
+ import_node_crypto = __toESM(require("crypto"), 1);
772
+ VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
773
+ RANDOM_FIRST_NAMES = ["Alex", "Jordan", "Morgan", "Taylor", "Casey", "Riley", "Quinn", "Avery"];
774
+ RANDOM_LAST_NAMES = ["Smith", "Johnson", "Brown", "Davis", "Wilson", "Clark", "Hall", "Young"];
775
+ }
776
+ });
777
+
778
+ // src/config/loader.ts
779
+ var loader_exports = {};
780
+ __export(loader_exports, {
781
+ CONFIG_DIR: () => CONFIG_DIR,
782
+ LEGACY_CONFIG_DIR: () => LEGACY_CONFIG_DIR,
783
+ ensureAllowedDomain: () => ensureAllowedDomain,
784
+ findConfigPath: () => findConfigPath,
785
+ listHunts: () => listHunts,
786
+ loadConfig: () => loadConfig,
787
+ loadHunt: () => loadHunt,
788
+ loadHuntMeta: () => loadHuntMeta,
789
+ loadHuntTags: () => loadHuntTags,
790
+ resolveViewport: () => resolveViewport,
791
+ warnLegacyConfigDir: () => warnLegacyConfigDir
792
+ });
793
+ function warnLegacyConfigDir() {
794
+ if (legacyDirWarned) {
795
+ return;
796
+ }
797
+ legacyDirWarned = true;
798
+ console.warn(
799
+ 'Warning: the ".prowlqa/" config directory is deprecated; rename it to ".prowl/". Support for ".prowlqa/" will be removed in a future release.'
800
+ );
801
+ }
802
+ function findConfigPath(startDir) {
803
+ let current = startDir;
804
+ while (current) {
805
+ for (const dir of [CONFIG_DIR, LEGACY_CONFIG_DIR]) {
806
+ const candidate = import_node_path.default.join(current, dir, "config.yml");
807
+ if (import_node_fs.default.existsSync(candidate)) {
808
+ return candidate;
809
+ }
978
810
  }
979
- if (step.assert.urlEquals !== void 0) {
980
- return { assert: { urlEquals: interpolateString(step.assert.urlEquals, vars).value } };
811
+ const parent = import_node_path.default.dirname(current);
812
+ if (parent === current) {
813
+ break;
981
814
  }
982
- return step;
815
+ current = parent;
983
816
  }
984
- if ("wait" in step) {
985
- if (typeof step.wait === "string") {
986
- return { wait: interpolateString(step.wait, vars).value };
817
+ return null;
818
+ }
819
+ function resolveViewport(value) {
820
+ if (value === void 0) {
821
+ return DEFAULT_CONFIG.browser.viewport;
822
+ }
823
+ if (typeof value === "string") {
824
+ const preset = VIEWPORT_PRESETS[value];
825
+ if (!preset) {
826
+ throw new Error(`Unknown viewport preset: "${value}". Use mobile, tablet, or desktop.`);
987
827
  }
828
+ return preset;
829
+ }
830
+ return value;
831
+ }
832
+ function resolveTarget(target) {
833
+ const type = target?.type;
834
+ if (type === "macos") {
835
+ return { type: "macos", app: target.app };
836
+ }
837
+ if (type === "android") {
838
+ const androidTarget = target;
988
839
  return {
989
- wait: {
990
- for: interpolateString(step.wait.for, vars).value,
991
- timeout: step.wait.timeout
992
- }
840
+ type: "android",
841
+ app: androidTarget.app,
842
+ ...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
843
+ ...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
993
844
  };
994
845
  }
995
- if ("waitForSelector" in step) {
996
- const selectorResult = interpolateString(step.waitForSelector.selector, vars);
846
+ if (type === "ios") {
847
+ const iosTarget = target;
997
848
  return {
998
- waitForSelector: {
999
- selector: selectorResult.value,
1000
- timeout: step.waitForSelector.timeout
1001
- }
849
+ type: "ios",
850
+ app: iosTarget.app,
851
+ ...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
852
+ ...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
1002
853
  };
1003
854
  }
1004
- if ("waitForUrl" in step) {
1005
- const valueResult = interpolateString(step.waitForUrl.value, vars);
1006
- return {
1007
- waitForUrl: {
1008
- value: valueResult.value,
1009
- timeout: step.waitForUrl.timeout
1010
- }
1011
- };
855
+ return {
856
+ type: "web",
857
+ url: target?.url ?? DEFAULT_WEB_URL
858
+ };
859
+ }
860
+ function mergeConfig(partial) {
861
+ return {
862
+ target: resolveTarget(partial.target),
863
+ browser: {
864
+ headless: partial.browser?.headless ?? DEFAULT_CONFIG.browser.headless,
865
+ slowMo: partial.browser?.slowMo ?? DEFAULT_CONFIG.browser.slowMo,
866
+ timeout: partial.browser?.timeout ?? DEFAULT_CONFIG.browser.timeout,
867
+ engine: partial.browser?.engine ?? DEFAULT_CONFIG.browser.engine,
868
+ channel: partial.browser?.channel,
869
+ viewport: resolveViewport(partial.browser?.viewport)
870
+ },
871
+ artifacts: {
872
+ screenshots: partial.artifacts?.screenshots ?? DEFAULT_CONFIG.artifacts.screenshots,
873
+ networkHar: partial.artifacts?.networkHar ?? DEFAULT_CONFIG.artifacts.networkHar,
874
+ console: partial.artifacts?.console ?? DEFAULT_CONFIG.artifacts.console,
875
+ junit: partial.artifacts?.junit ?? DEFAULT_CONFIG.artifacts.junit
876
+ },
877
+ assertions: {
878
+ noConsoleErrors: partial.assertions?.noConsoleErrors ?? DEFAULT_CONFIG.assertions.noConsoleErrors,
879
+ noNetworkErrors: partial.assertions?.noNetworkErrors ?? DEFAULT_CONFIG.assertions.noNetworkErrors,
880
+ maxTotalTimeMs: partial.assertions?.maxTotalTimeMs ?? DEFAULT_CONFIG.assertions.maxTotalTimeMs,
881
+ networkIgnorePatterns: partial.assertions?.networkIgnorePatterns ?? DEFAULT_CONFIG.assertions.networkIgnorePatterns
882
+ },
883
+ guardrails: {
884
+ maxSteps: partial.guardrails?.maxSteps ?? DEFAULT_CONFIG.guardrails.maxSteps,
885
+ allowedDomains: partial.guardrails?.allowedDomains ?? DEFAULT_CONFIG.guardrails.allowedDomains,
886
+ allowedApps: partial.guardrails?.allowedApps ?? DEFAULT_CONFIG.guardrails.allowedApps,
887
+ forbiddenSelectors: partial.guardrails?.forbiddenSelectors ?? DEFAULT_CONFIG.guardrails.forbiddenSelectors,
888
+ selfHealing: partial.guardrails?.selfHealing ?? DEFAULT_CONFIG.guardrails.selfHealing
889
+ },
890
+ auth: {
891
+ storageStatePath: partial.auth?.storageStatePath ?? (partial.auth !== void 0 ? DEFAULT_CONFIG.auth.storageStatePath : void 0)
892
+ },
893
+ history: {
894
+ maxRuns: partial.history?.maxRuns ?? DEFAULT_CONFIG.history.maxRuns
895
+ },
896
+ bugLog: partial.bugLog,
897
+ tracing: partial.tracing,
898
+ reliability: partial.reliability
899
+ };
900
+ }
901
+ function envStringVars(env) {
902
+ return Object.fromEntries(
903
+ Object.entries(env).filter(([, value]) => value !== void 0)
904
+ );
905
+ }
906
+ function interpolateConfigStrings(value, vars) {
907
+ if (typeof value === "string") {
908
+ return interpolateString(value, vars).value;
909
+ }
910
+ if (Array.isArray(value)) {
911
+ return value.map((item) => interpolateConfigStrings(item, vars));
1012
912
  }
1013
- if ("waitForNetworkIdle" in step) {
1014
- return { waitForNetworkIdle: { timeout: step.waitForNetworkIdle.timeout } };
913
+ if (value && typeof value === "object") {
914
+ return Object.fromEntries(
915
+ Object.entries(value).map(([key, item]) => [key, interpolateConfigStrings(item, vars)])
916
+ );
1015
917
  }
1016
- if ("hover" in step) {
1017
- const selectorResult = interpolateString(step.hover.selector, vars);
1018
- return { hover: { selector: selectorResult.value } };
918
+ return value;
919
+ }
920
+ function ensureAllowedDomain(allowed, urlValue) {
921
+ try {
922
+ const host = new URL(urlValue).hostname;
923
+ if (!allowed.includes(host)) {
924
+ return [...allowed, host];
925
+ }
926
+ } catch {
927
+ return allowed;
1019
928
  }
1020
- if ("scroll" in step) {
1021
- return { scroll: { direction: step.scroll.direction, amount: step.scroll.amount } };
929
+ return allowed;
930
+ }
931
+ function loadConfig(configPath) {
932
+ const resolvedPath = configPath ? import_node_path.default.resolve(configPath) : findConfigPath(process.cwd());
933
+ if (!resolvedPath) {
934
+ throw new Error("Could not find .prowl/config.yml. Run `prowl init` first.");
1022
935
  }
1023
- if ("scrollTo" in step) {
1024
- const selectorResult = interpolateString(step.scrollTo.selector, vars);
1025
- return { scrollTo: { selector: selectorResult.value } };
936
+ if (!import_node_fs.default.existsSync(resolvedPath)) {
937
+ throw new Error(`Config file not found at ${resolvedPath}`);
1026
938
  }
1027
- if ("screenshot" in step) {
1028
- return { screenshot: { name: step.screenshot.name } };
939
+ const configDir = import_node_path.default.dirname(resolvedPath);
940
+ if (import_node_path.default.basename(configDir) === LEGACY_CONFIG_DIR) {
941
+ warnLegacyConfigDir();
1029
942
  }
1030
- if ("if" in step) {
1031
- const condition = step.if;
1032
- const thenSteps = condition.then.map(
1033
- (s, i) => interpolateStep(s, vars, `${stepPath2}.if.then.${i}`, redacted)
1034
- );
1035
- const elseSteps = condition.else?.map(
1036
- (s, i) => interpolateStep(s, vars, `${stepPath2}.if.else.${i}`, redacted)
943
+ import_dotenv.default.config({ path: import_node_path.default.join(configDir, ".env"), override: false });
944
+ const raw = import_node_fs.default.readFileSync(resolvedPath, "utf-8");
945
+ const parsed = import_yaml.default.parse(raw) ?? {};
946
+ const interpolated = interpolateConfigStrings(parsed, envStringVars(process.env));
947
+ const validated = configSchema.parse(interpolated);
948
+ const config = mergeConfig(validated);
949
+ if (config.target.type === "web") {
950
+ config.guardrails.allowedDomains = ensureAllowedDomain(
951
+ config.guardrails.allowedDomains,
952
+ config.target.url
1037
953
  );
1038
- return {
1039
- if: {
1040
- ...condition.visible !== void 0 ? { visible: interpolateString(condition.visible, vars).value } : {},
1041
- ...condition.notVisible !== void 0 ? { notVisible: interpolateString(condition.notVisible, vars).value } : {},
1042
- then: thenSteps,
1043
- ...elseSteps !== void 0 ? { else: elseSteps } : {}
1044
- }
1045
- };
1046
954
  }
1047
- if ("repeat" in step) {
1048
- const repeat = step.repeat;
1049
- const subSteps = repeat.steps.map(
1050
- (s, i) => interpolateStep(s, vars, `${stepPath2}.repeat.steps.${i}`, redacted)
1051
- );
1052
- return {
1053
- repeat: {
1054
- ...repeat.times !== void 0 ? { times: repeat.times } : {},
1055
- ...repeat.while !== void 0 ? {
1056
- while: {
1057
- ...repeat.while.visible !== void 0 ? { visible: interpolateString(repeat.while.visible, vars).value } : {},
1058
- ...repeat.while.notVisible !== void 0 ? { notVisible: interpolateString(repeat.while.notVisible, vars).value } : {}
1059
- }
1060
- } : {},
1061
- ...repeat.maxIterations !== void 0 ? { maxIterations: repeat.maxIterations } : {},
1062
- steps: subSteps
1063
- }
1064
- };
955
+ return { config, configPath: resolvedPath, configDir };
956
+ }
957
+ function loadHunt(huntName, configDir) {
958
+ assertValidHuntName(huntName);
959
+ const huntPath = import_node_path.default.join(configDir, "hunts", `${huntName}.yml`);
960
+ if (!import_node_fs.default.existsSync(huntPath)) {
961
+ throw new Error(`Hunt file not found: ${huntPath}`);
1065
962
  }
1066
- if ("mockRoute" in step) {
1067
- const mock = step.mockRoute;
1068
- return {
1069
- mockRoute: {
1070
- url: interpolateString(mock.url, vars).value,
1071
- response: {
1072
- status: mock.response.status,
1073
- ...mock.response.contentType !== void 0 ? { contentType: interpolateString(mock.response.contentType, vars).value } : {},
1074
- ...mock.response.body !== void 0 ? { body: interpolateString(mock.response.body, vars).value } : {},
1075
- ...mock.response.file !== void 0 ? { file: interpolateString(mock.response.file, vars).value } : {}
1076
- }
1077
- }
1078
- };
963
+ const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
964
+ const parsed = import_yaml.default.parse(raw) ?? {};
965
+ const validated = huntSchema.parse(parsed);
966
+ return validated;
967
+ }
968
+ function loadHuntTags(huntName, configDir) {
969
+ assertValidHuntName(huntName);
970
+ const huntPath = import_node_path.default.join(configDir, "hunts", `${huntName}.yml`);
971
+ if (!import_node_fs.default.existsSync(huntPath)) {
972
+ return [];
1079
973
  }
1080
- if ("unmockRoute" in step) {
1081
- if (typeof step.unmockRoute === "string") {
1082
- return { unmockRoute: interpolateString(step.unmockRoute, vars).value };
1083
- }
1084
- return {
1085
- unmockRoute: { url: interpolateString(step.unmockRoute.url, vars).value }
1086
- };
974
+ const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
975
+ const parsed = import_yaml.default.parse(raw) ?? {};
976
+ return Array.isArray(parsed.tags) ? parsed.tags : [];
977
+ }
978
+ function loadHuntMeta(huntName, configDir) {
979
+ assertValidHuntName(huntName);
980
+ const huntPath = import_node_path.default.join(configDir, "hunts", `${huntName}.yml`);
981
+ if (!import_node_fs.default.existsSync(huntPath)) {
982
+ return { tags: [] };
1087
983
  }
1088
- if ("evalScript" in step) {
1089
- if (typeof step.evalScript === "string") {
1090
- return { evalScript: interpolateString(step.evalScript, vars).value };
1091
- }
1092
- return {
1093
- evalScript: {
1094
- expression: interpolateString(step.evalScript.expression, vars).value,
1095
- ...step.evalScript.as !== void 0 ? { as: step.evalScript.as } : {}
1096
- }
1097
- };
984
+ const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
985
+ const parsed = import_yaml.default.parse(raw) ?? {};
986
+ return {
987
+ description: typeof parsed.description === "string" ? parsed.description : void 0,
988
+ tags: Array.isArray(parsed.tags) ? parsed.tags : []
989
+ };
990
+ }
991
+ function listHunts(configDir) {
992
+ const huntsDir = import_node_path.default.join(configDir, "hunts");
993
+ if (!import_node_fs.default.existsSync(huntsDir)) {
994
+ return [];
1098
995
  }
1099
- if ("runScript" in step) {
1100
- return {
1101
- runScript: { file: interpolateString(step.runScript.file, vars).value }
1102
- };
996
+ const stats = import_node_fs.default.statSync(huntsDir);
997
+ if (!stats.isDirectory()) {
998
+ throw new Error(`Hunts path is not a directory: ${huntsDir}`);
1103
999
  }
1104
- if ("assertScreenshot" in step) {
1105
- return {
1106
- assertScreenshot: {
1107
- name: interpolateString(step.assertScreenshot.name, vars).value,
1108
- ...step.assertScreenshot.threshold !== void 0 ? { threshold: step.assertScreenshot.threshold } : {}
1000
+ const results = [];
1001
+ function scanDir(dir) {
1002
+ const entries = import_node_fs.default.readdirSync(dir, { withFileTypes: true });
1003
+ for (const entry of entries) {
1004
+ if (entry.isFile() && entry.name.endsWith(".yml")) {
1005
+ const fullPath = import_node_path.default.join(dir, entry.name);
1006
+ const relative = import_node_path.default.relative(huntsDir, fullPath);
1007
+ results.push(relative.replace(/\.yml$/, ""));
1008
+ } else if (entry.isDirectory()) {
1009
+ scanDir(import_node_path.default.join(dir, entry.name));
1109
1010
  }
1110
- };
1111
- }
1112
- if ("assertWithAI" in step) {
1113
- return {
1114
- assertWithAI: interpolateString(step.assertWithAI, vars).value
1115
- };
1011
+ }
1116
1012
  }
1117
- if ("copyText" in step) {
1118
- return {
1119
- copyText: {
1120
- selector: interpolateString(step.copyText.selector, vars).value,
1121
- as: step.copyText.as
1013
+ scanDir(huntsDir);
1014
+ return results.sort((a, b) => a.localeCompare(b));
1015
+ }
1016
+ var import_node_fs, import_node_path, import_yaml, import_dotenv, DEFAULT_WEB_URL, DEFAULT_CONFIG, CONFIG_DIR, LEGACY_CONFIG_DIR, legacyDirWarned, VIEWPORT_PRESETS;
1017
+ var init_loader = __esm({
1018
+ "src/config/loader.ts"() {
1019
+ "use strict";
1020
+ import_node_fs = __toESM(require("fs"), 1);
1021
+ import_node_path = __toESM(require("path"), 1);
1022
+ import_yaml = __toESM(require("yaml"), 1);
1023
+ import_dotenv = __toESM(require("dotenv"), 1);
1024
+ init_schema();
1025
+ init_hunt_name();
1026
+ init_interpolate();
1027
+ DEFAULT_WEB_URL = "http://localhost:3000";
1028
+ DEFAULT_CONFIG = {
1029
+ target: {
1030
+ type: "web",
1031
+ url: DEFAULT_WEB_URL
1032
+ },
1033
+ browser: {
1034
+ headless: true,
1035
+ slowMo: 0,
1036
+ timeout: 3e4,
1037
+ engine: "chromium",
1038
+ viewport: { width: 1280, height: 720 }
1039
+ },
1040
+ artifacts: {
1041
+ screenshots: "on-failure",
1042
+ networkHar: false,
1043
+ console: true,
1044
+ junit: false
1045
+ },
1046
+ assertions: {
1047
+ noConsoleErrors: true,
1048
+ noNetworkErrors: true,
1049
+ maxTotalTimeMs: 3e4,
1050
+ networkIgnorePatterns: []
1051
+ },
1052
+ guardrails: {
1053
+ maxSteps: 50,
1054
+ allowedDomains: ["localhost", "127.0.0.1", "0.0.0.0"],
1055
+ allowedApps: [],
1056
+ forbiddenSelectors: ["[data-danger]", ".delete-btn"],
1057
+ selfHealing: false
1058
+ },
1059
+ auth: {
1060
+ storageStatePath: ".prowl/auth-state.json"
1061
+ },
1062
+ history: {
1063
+ maxRuns: 100
1122
1064
  }
1123
1065
  };
1066
+ CONFIG_DIR = ".prowl";
1067
+ LEGACY_CONFIG_DIR = ".prowlqa";
1068
+ legacyDirWarned = false;
1069
+ VIEWPORT_PRESETS = {
1070
+ mobile: { width: 375, height: 812 },
1071
+ tablet: { width: 768, height: 1024 },
1072
+ desktop: { width: 1280, height: 720 }
1073
+ };
1124
1074
  }
1125
- if ("waitForDownload" in step) {
1126
- if (step.waitForDownload === null) {
1127
- return { waitForDownload: null };
1128
- }
1075
+ });
1076
+
1077
+ // src/runner/visual.ts
1078
+ var visual_exports = {};
1079
+ __export(visual_exports, {
1080
+ compareScreenshots: () => compareScreenshots,
1081
+ ensureBaselineDir: () => ensureBaselineDir
1082
+ });
1083
+ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold) {
1084
+ const baselineData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(baselinePath));
1085
+ const currentData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(currentPath));
1086
+ const { width, height } = baselineData;
1087
+ if (currentData.width !== width || currentData.height !== height) {
1088
+ const diff2 = new import_pngjs.PNG({ width, height });
1089
+ import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
1129
1090
  return {
1130
- waitForDownload: {
1131
- ...step.waitForDownload.filename !== void 0 ? { filename: interpolateString(step.waitForDownload.filename, vars).value } : {},
1132
- ...step.waitForDownload.timeout !== void 0 ? { timeout: step.waitForDownload.timeout } : {}
1133
- }
1091
+ match: false,
1092
+ diffPercentage: 1,
1093
+ diffImagePath: diffPath
1134
1094
  };
1135
1095
  }
1136
- return step;
1137
- }
1138
- function interpolateAssertion(assertion, vars) {
1139
- if ("selectorExists" in assertion) {
1140
- return { selectorExists: interpolateString(assertion.selectorExists, vars).value };
1141
- }
1142
- if ("selectorNotExists" in assertion) {
1143
- return { selectorNotExists: interpolateString(assertion.selectorNotExists, vars).value };
1144
- }
1145
- if ("urlIncludes" in assertion) {
1146
- return { urlIncludes: interpolateString(assertion.urlIncludes, vars).value };
1147
- }
1148
- if ("urlEquals" in assertion) {
1149
- return { urlEquals: interpolateString(assertion.urlEquals, vars).value };
1150
- }
1151
- if ("noConsoleErrors" in assertion) {
1152
- return { noConsoleErrors: assertion.noConsoleErrors };
1153
- }
1154
- if ("noNetworkErrors" in assertion) {
1155
- return { noNetworkErrors: assertion.noNetworkErrors };
1156
- }
1157
- return assertion;
1158
- }
1159
- function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
1160
- const redactedFillSteps = /* @__PURE__ */ new Set();
1161
- const envVars = Object.fromEntries(
1162
- Object.entries(env).filter(([, value]) => value !== void 0)
1163
- );
1164
- const baseVars = { ...randomVars, ...envVars };
1165
- const resolvedHuntVars = {};
1166
- for (const [key, value] of Object.entries(hunt.vars ?? {})) {
1167
- resolvedHuntVars[key] = interpolateString(value, baseVars).value;
1168
- }
1169
- const vars = { ...baseVars, ...resolvedHuntVars };
1170
- const redactionValues = /* @__PURE__ */ new Set();
1171
- collectInterpolatedValues(hunt.steps, vars, redactionValues);
1172
- collectInterpolatedValues(hunt.assertions, vars, redactionValues);
1173
- const steps = hunt.steps.map(
1174
- (step, index) => interpolateStep(step, vars, `${index}`, redactedFillSteps)
1096
+ const diff = new import_pngjs.PNG({ width, height });
1097
+ const diffPixels = (0, import_pixelmatch.default)(
1098
+ baselineData.data,
1099
+ currentData.data,
1100
+ diff.data,
1101
+ width,
1102
+ height,
1103
+ { threshold: 0.1 }
1175
1104
  );
1176
- const assertions = hunt.assertions?.map((assertion) => interpolateAssertion(assertion, vars));
1105
+ const totalPixels = width * height;
1106
+ const diffPercentage = diffPixels / totalPixels;
1107
+ import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
1177
1108
  return {
1178
- hunt: {
1179
- ...hunt,
1180
- steps,
1181
- assertions
1182
- },
1183
- redactedFillSteps,
1184
- randomVars,
1185
- redactionValues: [...redactionValues]
1109
+ match: diffPercentage <= threshold,
1110
+ diffPercentage,
1111
+ diffImagePath: diffPath
1186
1112
  };
1187
1113
  }
1114
+ function ensureBaselineDir(configDir) {
1115
+ const baselineDir = import_node_path9.default.join(configDir, "baselines");
1116
+ import_node_fs8.default.mkdirSync(baselineDir, { recursive: true });
1117
+ return baselineDir;
1118
+ }
1119
+ var import_node_fs8, import_node_path9, import_pngjs, import_pixelmatch;
1120
+ var init_visual = __esm({
1121
+ "src/runner/visual.ts"() {
1122
+ "use strict";
1123
+ import_node_fs8 = __toESM(require("fs"), 1);
1124
+ import_node_path9 = __toESM(require("path"), 1);
1125
+ import_pngjs = require("pngjs");
1126
+ import_pixelmatch = __toESM(require("pixelmatch"), 1);
1127
+ }
1128
+ });
1129
+
1130
+ // src/cli/program.ts
1131
+ var import_commander14 = require("commander");
1132
+
1133
+ // package.json
1134
+ var package_default = {
1135
+ name: "prowl-tools",
1136
+ version: "0.1.8",
1137
+ description: "E2E testing for native macOS apps and web apps from declarative YAML hunts.",
1138
+ type: "module",
1139
+ license: "Apache-2.0",
1140
+ author: "Prowl Tools",
1141
+ repository: {
1142
+ type: "git",
1143
+ url: "https://github.com/prowl-tools/prowl.git"
1144
+ },
1145
+ homepage: "https://prowl.tools",
1146
+ bugs: {
1147
+ url: "https://github.com/prowl-tools/prowl/issues"
1148
+ },
1149
+ keywords: [
1150
+ "testing",
1151
+ "qa",
1152
+ "e2e",
1153
+ "macos",
1154
+ "desktop-testing",
1155
+ "accessibility",
1156
+ "playwright",
1157
+ "yaml",
1158
+ "browser-testing",
1159
+ "automation",
1160
+ "web-testing",
1161
+ "cli"
1162
+ ],
1163
+ main: "dist/lib.cjs",
1164
+ module: "dist/lib.js",
1165
+ types: "dist/lib.d.ts",
1166
+ exports: {
1167
+ ".": {
1168
+ import: {
1169
+ types: "./dist/lib.d.ts",
1170
+ default: "./dist/lib.js"
1171
+ },
1172
+ require: {
1173
+ types: "./dist/lib.d.cts",
1174
+ default: "./dist/lib.cjs"
1175
+ }
1176
+ }
1177
+ },
1178
+ bin: {
1179
+ prowl: "dist/index.js"
1180
+ },
1181
+ files: [
1182
+ "dist",
1183
+ "examples",
1184
+ "LICENSE",
1185
+ "README.md",
1186
+ "NOTICE"
1187
+ ],
1188
+ engines: {
1189
+ node: ">=20.0.0"
1190
+ },
1191
+ scripts: {
1192
+ build: "tsup",
1193
+ lint: "eslint .",
1194
+ test: "vitest run",
1195
+ "test:watch": "vitest"
1196
+ },
1197
+ dependencies: {
1198
+ "@modelcontextprotocol/sdk": "^1.29.0",
1199
+ chalk: "^5.3.0",
1200
+ commander: "^12.1.0",
1201
+ dotenv: "^16.6.1",
1202
+ ora: "^8.1.1",
1203
+ pixelmatch: "^7.1.0",
1204
+ playwright: "^1.50.1",
1205
+ pngjs: "^7.0.0",
1206
+ yaml: "^2.6.1",
1207
+ zod: "^3.23.8"
1208
+ },
1209
+ optionalDependencies: {
1210
+ "appium-uiautomator2-server": "10.6.2",
1211
+ "appium-webdriveragent": "16.4.0"
1212
+ },
1213
+ devDependencies: {
1214
+ "@types/node": "^22.13.1",
1215
+ "@types/pngjs": "^6.0.5",
1216
+ "@typescript-eslint/eslint-plugin": "^7.18.0",
1217
+ "@typescript-eslint/parser": "^7.18.0",
1218
+ eslint: "^8.57.1",
1219
+ tsup: "^8.3.5",
1220
+ typescript: "^5.7.3",
1221
+ vitest: "^2.1.8"
1222
+ }
1223
+ };
1224
+
1225
+ // src/cli/commands/run.ts
1226
+ var import_commander = require("commander");
1227
+ var import_chalk3 = __toESM(require("chalk"), 1);
1228
+
1229
+ // src/runner/index.ts
1230
+ var import_node_fs14 = __toESM(require("fs"), 1);
1231
+ var import_node_path15 = __toESM(require("path"), 1);
1232
+ init_loader();
1233
+ init_interpolate();
1188
1234
 
1189
1235
  // src/config/target.ts
1190
1236
  var import_node_child_process = require("child_process");
@@ -1202,10 +1248,17 @@ var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
1202
1248
  "select",
1203
1249
  "selectOption",
1204
1250
  "setInputFiles",
1205
- "waitForDownload",
1206
- "scroll"
1207
- // directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
1251
+ "waitForDownload"
1208
1252
  ]);
1253
+ var MACOS_UNSUPPORTED_STEP_TYPES = /* @__PURE__ */ new Set(["scroll"]);
1254
+ function macosUnsupportedReason(step) {
1255
+ for (const type of MACOS_UNSUPPORTED_STEP_TYPES) {
1256
+ if (type in step) {
1257
+ return type;
1258
+ }
1259
+ }
1260
+ return null;
1261
+ }
1209
1262
  function webOnlyReason(step) {
1210
1263
  for (const type of WEB_ONLY_STEP_TYPES) {
1211
1264
  if (type in step) {
@@ -1241,6 +1294,14 @@ function assertStepsSupportedByTarget(steps, target) {
1241
1294
  `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.).`
1242
1295
  );
1243
1296
  }
1297
+ if (target === "macos") {
1298
+ const macReason = macosUnsupportedReason(step);
1299
+ if (macReason) {
1300
+ throw new Error(
1301
+ `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).`
1302
+ );
1303
+ }
1304
+ }
1244
1305
  if ("if" in step) {
1245
1306
  assertStepsSupportedByTarget(step.if.then, target);
1246
1307
  if (step.if.else) {
@@ -1524,6 +1585,16 @@ function createPlaywrightDriver(page) {
1524
1585
  async hover(selector) {
1525
1586
  await page.locator(selector).hover();
1526
1587
  },
1588
+ async scroll(direction, amount = 500) {
1589
+ const deltas = {
1590
+ up: [0, -amount],
1591
+ down: [0, amount],
1592
+ left: [-amount, 0],
1593
+ right: [amount, 0]
1594
+ };
1595
+ const [x, y] = deltas[direction];
1596
+ await page.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
1597
+ },
1527
1598
  async scrollIntoView(selector) {
1528
1599
  await page.locator(selector).scrollIntoViewIfNeeded();
1529
1600
  },
@@ -1606,7 +1677,8 @@ function createPlaywrightDriver(page) {
1606
1677
  // src/browser/mac-helper.ts
1607
1678
  var import_node_child_process2 = require("child_process");
1608
1679
  var import_node_fs4 = __toESM(require("fs"), 1);
1609
- var import_node_path4 = __toESM(require("path"), 1);
1680
+ var import_node_os2 = __toESM(require("os"), 1);
1681
+ var import_node_path5 = __toESM(require("path"), 1);
1610
1682
  var import_node_url = require("url");
1611
1683
 
1612
1684
  // src/browser/mac-driver.ts
@@ -1717,6 +1789,9 @@ function createMacDriver(client, options = {}) {
1717
1789
  async hover(selector) {
1718
1790
  await query("hover", selector);
1719
1791
  },
1792
+ scroll() {
1793
+ return rejectUnsupported("scroll");
1794
+ },
1720
1795
  async scrollIntoView(selector) {
1721
1796
  await query("scrollTo", selector);
1722
1797
  },
@@ -1785,24 +1860,67 @@ function createMacDriver(client, options = {}) {
1785
1860
  };
1786
1861
  }
1787
1862
 
1863
+ // src/browser/macdriver-release.ts
1864
+ var import_node_os = __toESM(require("os"), 1);
1865
+ var import_node_path4 = __toESM(require("path"), 1);
1866
+ var HELPER_BINARY = "prowl-macdriver";
1867
+ var MACDRIVER_VERSION = "0.1.0";
1868
+ var MACDRIVER_REPO = "prowl-tools/prowl";
1869
+ var MACDRIVER_SIGNING_IDENTIFIER = "tools.prowl.macdriver";
1870
+ var MACDRIVER_SIGNING_AUTHORITY_PREFIX = "Developer ID Application: Genkei Labs";
1871
+ var MACDRIVER_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?$/;
1872
+ function validateMacdriverVersion(version) {
1873
+ if (!MACDRIVER_VERSION_PATTERN.test(version)) {
1874
+ throw new Error(
1875
+ `Invalid prowl-macdriver version "${version}". Expected a release version like 0.1.0.`
1876
+ );
1877
+ }
1878
+ return version;
1879
+ }
1880
+ function macdriverReleaseTag(version = MACDRIVER_VERSION) {
1881
+ return `macdriver-v${validateMacdriverVersion(version)}`;
1882
+ }
1883
+ function macdriverAssetName(version = MACDRIVER_VERSION) {
1884
+ return `prowl-macdriver-v${validateMacdriverVersion(version)}-universal.zip`;
1885
+ }
1886
+ function macdriverChecksumName(version = MACDRIVER_VERSION) {
1887
+ return `${macdriverAssetName(version)}.sha256`;
1888
+ }
1889
+ function macdriverAssetUrl(assetName, version = MACDRIVER_VERSION) {
1890
+ return `https://github.com/${MACDRIVER_REPO}/releases/download/${macdriverReleaseTag(version)}/${assetName}`;
1891
+ }
1892
+ function macdriverInstallRoot(homedir = import_node_os.default.homedir()) {
1893
+ return import_node_path4.default.join(homedir, ".prowl", "macdriver");
1894
+ }
1895
+ function macdriverVersionDir(version = MACDRIVER_VERSION, homedir = import_node_os.default.homedir()) {
1896
+ const root = import_node_path4.default.resolve(macdriverInstallRoot(homedir));
1897
+ const versionDir = import_node_path4.default.resolve(root, validateMacdriverVersion(version));
1898
+ if (!versionDir.startsWith(root + import_node_path4.default.sep)) {
1899
+ throw new Error(`Resolved prowl-macdriver version directory escaped install root: ${versionDir}`);
1900
+ }
1901
+ return versionDir;
1902
+ }
1903
+ function macdriverInstalledBinary(version = MACDRIVER_VERSION, homedir = import_node_os.default.homedir()) {
1904
+ return import_node_path4.default.join(macdriverVersionDir(version, homedir), HELPER_BINARY);
1905
+ }
1906
+
1788
1907
  // src/browser/mac-helper.ts
1789
1908
  var import_meta = {};
1790
- var HELPER_BINARY = "prowl-macdriver";
1791
1909
  function macdriverBuildInstructions() {
1792
- 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.";
1910
+ 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.";
1793
1911
  }
1794
1912
  function getPackageRoot() {
1795
- let dir = import_node_path4.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
1796
- const root = import_node_path4.default.parse(dir).root;
1913
+ let dir = import_node_path5.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
1914
+ const root = import_node_path5.default.parse(dir).root;
1797
1915
  while (dir !== root) {
1798
- if (import_node_fs4.default.existsSync(import_node_path4.default.join(dir, "package.json"))) {
1916
+ if (import_node_fs4.default.existsSync(import_node_path5.default.join(dir, "package.json"))) {
1799
1917
  return dir;
1800
1918
  }
1801
- dir = import_node_path4.default.dirname(dir);
1919
+ dir = import_node_path5.default.dirname(dir);
1802
1920
  }
1803
1921
  return root;
1804
1922
  }
1805
- function resolveHelperBinary(env = process.env) {
1923
+ function resolveHelperBinary(env = process.env, options = {}) {
1806
1924
  const override = env.PROWL_MACDRIVER_BIN;
1807
1925
  if (override) {
1808
1926
  if (!import_node_fs4.default.existsSync(override)) {
@@ -1813,10 +1931,15 @@ ${macdriverBuildInstructions()}`
1813
1931
  }
1814
1932
  return override;
1815
1933
  }
1934
+ const homedir = options.homedir ?? import_node_os2.default.homedir();
1935
+ const userBinary = macdriverInstalledBinary(MACDRIVER_VERSION, homedir);
1936
+ if (import_node_fs4.default.existsSync(userBinary)) {
1937
+ return userBinary;
1938
+ }
1816
1939
  const root = getPackageRoot();
1817
1940
  const candidates = [
1818
- import_node_path4.default.join(root, "macdriver", ".build", "release", HELPER_BINARY),
1819
- import_node_path4.default.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
1941
+ import_node_path5.default.join(root, "macdriver", ".build", "release", HELPER_BINARY),
1942
+ import_node_path5.default.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
1820
1943
  ];
1821
1944
  for (const candidate of candidates) {
1822
1945
  if (import_node_fs4.default.existsSync(candidate)) {
@@ -1992,7 +2115,7 @@ async function closeMacSession(session) {
1992
2115
  var import_node_module = require("module");
1993
2116
  var import_node_child_process4 = require("child_process");
1994
2117
  var import_node_fs6 = __toESM(require("fs"), 1);
1995
- var import_node_path5 = __toESM(require("path"), 1);
2118
+ var import_node_path6 = __toESM(require("path"), 1);
1996
2119
 
1997
2120
  // src/browser/android-driver.ts
1998
2121
  var import_node_fs5 = __toESM(require("fs"), 1);
@@ -2202,6 +2325,101 @@ function rankNativeSelectors(fields) {
2202
2325
  return selectors;
2203
2326
  }
2204
2327
 
2328
+ // src/browser/touch-gestures.ts
2329
+ var DEFAULT_SWIPE_FRACTION = 0.75;
2330
+ var SCROLL_TO_PROBE_SWIPE_FRACTION = 0.6;
2331
+ var MAX_SWIPE_FRACTION = 0.9;
2332
+ var SWIPE_HOLD_MS = 100;
2333
+ var SWIPE_MOVE_DURATION_MS = 300;
2334
+ var SCROLL_TO_SWEEP_DEPTH = 10;
2335
+ var SCROLL_TO_PROBE_DIRECTIONS = [
2336
+ ...Array.from({ length: SCROLL_TO_SWEEP_DEPTH }, () => "down"),
2337
+ ...Array.from({ length: SCROLL_TO_SWEEP_DEPTH * 2 }, () => "up")
2338
+ ];
2339
+ var MAX_SCROLL_TO_SWIPES = SCROLL_TO_PROBE_DIRECTIONS.length;
2340
+ var OPPOSITE_SWIPE_DIRECTIONS = {
2341
+ up: "down",
2342
+ down: "up",
2343
+ left: "right",
2344
+ right: "left"
2345
+ };
2346
+ async function probeScrollIntoView({
2347
+ isVisible,
2348
+ swipe,
2349
+ directions = SCROLL_TO_PROBE_DIRECTIONS
2350
+ }) {
2351
+ if (await isVisible()) {
2352
+ return true;
2353
+ }
2354
+ for (const direction of directions) {
2355
+ await swipe(direction);
2356
+ if (await isVisible()) {
2357
+ return true;
2358
+ }
2359
+ }
2360
+ return false;
2361
+ }
2362
+ function toScreenSize(value, source) {
2363
+ const record = value ?? {};
2364
+ const width = Number(record.width);
2365
+ const height = Number(record.height);
2366
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
2367
+ throw new Error(`${source} did not return a usable screen size`);
2368
+ }
2369
+ return { width, height };
2370
+ }
2371
+ function isVertical(direction) {
2372
+ return direction === "up" || direction === "down";
2373
+ }
2374
+ function swipeDistanceFor(direction, size, amount) {
2375
+ if (amount !== void 0 && !Number.isFinite(amount)) {
2376
+ throw new Error("scroll amount must be a finite number");
2377
+ }
2378
+ const axis = isVertical(direction) ? size.height : size.width;
2379
+ const requested = amount === void 0 ? axis * DEFAULT_SWIPE_FRACTION : Math.abs(amount);
2380
+ const max = axis * MAX_SWIPE_FRACTION;
2381
+ return Math.max(1, Math.round(Math.min(requested, max)));
2382
+ }
2383
+ function scrollToProbeDistanceFor(direction, size) {
2384
+ const axis = isVertical(direction) ? size.height : size.width;
2385
+ return Math.max(1, Math.round(axis * SCROLL_TO_PROBE_SWIPE_FRACTION));
2386
+ }
2387
+ function swipeEndpoints(direction, size, distance) {
2388
+ const cx = Math.round(size.width / 2);
2389
+ const cy = Math.round(size.height / 2);
2390
+ const half = Math.round(distance / 2);
2391
+ switch (direction) {
2392
+ case "down":
2393
+ return { start: { x: cx, y: cy + half }, end: { x: cx, y: cy - half } };
2394
+ case "up":
2395
+ return { start: { x: cx, y: cy - half }, end: { x: cx, y: cy + half } };
2396
+ case "right":
2397
+ return { start: { x: cx + half, y: cy }, end: { x: cx - half, y: cy } };
2398
+ case "left":
2399
+ return { start: { x: cx - half, y: cy }, end: { x: cx + half, y: cy } };
2400
+ }
2401
+ }
2402
+ function buildSwipeActions(start, end) {
2403
+ return {
2404
+ type: "pointer",
2405
+ id: "finger1",
2406
+ parameters: { pointerType: "touch" },
2407
+ actions: [
2408
+ { type: "pointerMove", duration: 0, x: start.x, y: start.y, origin: "viewport" },
2409
+ { type: "pointerDown", button: 0 },
2410
+ { type: "pause", duration: SWIPE_HOLD_MS },
2411
+ { type: "pointerMove", duration: SWIPE_MOVE_DURATION_MS, x: end.x, y: end.y, origin: "viewport" },
2412
+ { type: "pointerUp", button: 0 }
2413
+ ]
2414
+ };
2415
+ }
2416
+ function buildDirectionalSwipe(direction, size, amount) {
2417
+ const normalizedDirection = amount !== void 0 && amount < 0 ? OPPOSITE_SWIPE_DIRECTIONS[direction] : direction;
2418
+ const distance = swipeDistanceFor(normalizedDirection, size, amount);
2419
+ const { start, end } = swipeEndpoints(normalizedDirection, size, distance);
2420
+ return { actions: buildSwipeActions(start, end), distance, start, end };
2421
+ }
2422
+
2205
2423
  // src/browser/android-driver.ts
2206
2424
  var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
2207
2425
  "query",
@@ -2317,6 +2535,11 @@ function createAndroidDriver(client, options = {}) {
2317
2535
  async function fillSelector(selector, value) {
2318
2536
  await client.setValue(await resolveOne(selector), value);
2319
2537
  }
2538
+ async function swipe(direction, amount, size) {
2539
+ const actualSize = size ?? await client.windowSize();
2540
+ const { actions } = buildDirectionalSwipe(direction, actualSize, amount);
2541
+ await client.performActions(actions);
2542
+ }
2320
2543
  return {
2321
2544
  capabilities: ANDROID_CAPABILITIES,
2322
2545
  // navigation -----------------------------------------------------------
@@ -2354,8 +2577,31 @@ function createAndroidDriver(client, options = {}) {
2354
2577
  hover() {
2355
2578
  return rejectUnsupported("hover");
2356
2579
  },
2357
- scrollIntoView() {
2358
- return rejectUnsupported("scrollTo");
2580
+ // Screen-centred swipe via the W3C actions endpoint (PROWL-080). Direction
2581
+ // semantics match the web step: scrolling "down" reveals lower content, so
2582
+ // the finger drags up. See ./touch-gestures.ts.
2583
+ async scroll(direction, amount) {
2584
+ await swipe(direction, amount);
2585
+ },
2586
+ // Resolve the element, short-circuiting if it is already present in the
2587
+ // hierarchy; otherwise use the shared bounded down/up mobile probe before
2588
+ // failing with a message naming the selector and attempts.
2589
+ async scrollIntoView(selector) {
2590
+ const query = parseAndroidSelector(selector);
2591
+ let probeSize;
2592
+ const found = await probeScrollIntoView({
2593
+ isVisible: async () => (await client.findElements(query)).length > 0,
2594
+ swipe: async (direction) => {
2595
+ probeSize ??= await client.windowSize();
2596
+ await swipe(direction, scrollToProbeDistanceFor(direction, probeSize), probeSize);
2597
+ }
2598
+ });
2599
+ if (found) {
2600
+ return;
2601
+ }
2602
+ throw new Error(
2603
+ `scrollTo: element "${selector}" not visible after ${MAX_SCROLL_TO_SWIPES} scroll attempts on the Android target`
2604
+ );
2359
2605
  },
2360
2606
  setInputFiles() {
2361
2607
  return rejectUnsupported("setInputFiles");
@@ -2678,12 +2924,12 @@ var Uia2Transport = class {
2678
2924
  * {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
2679
2925
  * per-request deadline elapses.
2680
2926
  */
2681
- async request(method, path25, body, timeoutMs) {
2927
+ async request(method, path27, body, timeoutMs) {
2682
2928
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
2683
2929
  const controller = new AbortController();
2684
2930
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
2685
2931
  timer.unref?.();
2686
- const url = `${this.baseUrl}${path25}`;
2932
+ const url = `${this.baseUrl}${path27}`;
2687
2933
  let response;
2688
2934
  try {
2689
2935
  response = await this.fetchImpl(url, {
@@ -2695,7 +2941,7 @@ var Uia2Transport = class {
2695
2941
  } catch (error) {
2696
2942
  if (controller.signal.aborted) {
2697
2943
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
2698
- throw new Error(`uiautomator2 request ${method} ${path25} timed out after ${shown}`);
2944
+ throw new Error(`uiautomator2 request ${method} ${path27} timed out after ${shown}`);
2699
2945
  }
2700
2946
  throw error instanceof Error ? error : new Error(String(error));
2701
2947
  } finally {
@@ -2706,7 +2952,7 @@ var Uia2Transport = class {
2706
2952
  if (!response.ok) {
2707
2953
  const wdError = extractWebdriverError(parsed);
2708
2954
  throw new Uia2HttpError(
2709
- `uiautomator2 ${method} ${path25} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2955
+ `uiautomator2 ${method} ${path27} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2710
2956
  response.status,
2711
2957
  wdError
2712
2958
  );
@@ -2789,10 +3035,10 @@ function sleep(ms) {
2789
3035
  }
2790
3036
  function createUia2AgentClient(transport, sessionId, options = {}) {
2791
3037
  const base = `/session/${sessionId}`;
2792
- async function locate(query, path25) {
3038
+ async function locate(query, path27) {
2793
3039
  return transport.request(
2794
3040
  "POST",
2795
- `${base}${path25}`,
3041
+ `${base}${path27}`,
2796
3042
  androidQueryToLocator(query, { appPackage: options.appPackage })
2797
3043
  );
2798
3044
  }
@@ -2827,6 +3073,13 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
2827
3073
  async pressKeyCode(keyCode) {
2828
3074
  await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
2829
3075
  },
3076
+ async windowSize() {
3077
+ const value = await transport.request("GET", `${base}/window/current/size`);
3078
+ return toScreenSize(value, "uiautomator2 /window/current/size");
3079
+ },
3080
+ async performActions(actions) {
3081
+ await transport.request("POST", `${base}/actions`, { actions: [actions] });
3082
+ },
2830
3083
  async screenshotPng() {
2831
3084
  const value = await transport.request("GET", `${base}/screenshot`);
2832
3085
  if (typeof value !== "string") {
@@ -2859,10 +3112,10 @@ function resolveAgentApks(requireFn = (0, import_node_module.createRequire)(impo
2859
3112
  "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"
2860
3113
  );
2861
3114
  }
2862
- const pkgDir = import_node_path5.default.dirname(pkgJsonPath);
3115
+ const pkgDir = import_node_path6.default.dirname(pkgJsonPath);
2863
3116
  const version = requireFn(pkgJsonPath).version;
2864
- const serverApk = import_node_path5.default.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
2865
- const testApk = import_node_path5.default.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
3117
+ const serverApk = import_node_path6.default.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
3118
+ const testApk = import_node_path6.default.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
2866
3119
  for (const apk of [serverApk, testApk]) {
2867
3120
  if (!import_node_fs6.default.existsSync(apk)) {
2868
3121
  throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
@@ -2910,7 +3163,7 @@ async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
2910
3163
  assertAndroidAppAllowed(allowedApps, app);
2911
3164
  return app;
2912
3165
  }
2913
- const apkPath = import_node_path5.default.resolve(app);
3166
+ const apkPath = import_node_path6.default.resolve(app);
2914
3167
  if (!import_node_fs6.default.existsSync(apkPath)) {
2915
3168
  throw new Error(`APK not found: ${apkPath}`);
2916
3169
  }
@@ -2971,6 +3224,7 @@ async function launchAndroidSession(options) {
2971
3224
  readyDeadlineMs,
2972
3225
  appPackage: pkg
2973
3226
  });
3227
+ await launchPackage(runner, serial, pkg);
2974
3228
  const driver = createAndroidDriver(client, { appLabel: pkg });
2975
3229
  return { client, driver, package: pkg, serial, teardown };
2976
3230
  } catch (error) {
@@ -2986,8 +3240,8 @@ async function closeAndroidSession(session) {
2986
3240
  var import_node_module2 = require("module");
2987
3241
  var import_node_child_process6 = require("child_process");
2988
3242
  var import_node_fs7 = __toESM(require("fs"), 1);
2989
- var import_node_os2 = __toESM(require("os"), 1);
2990
- var import_node_path7 = __toESM(require("path"), 1);
3243
+ var import_node_os4 = __toESM(require("os"), 1);
3244
+ var import_node_path8 = __toESM(require("path"), 1);
2991
3245
 
2992
3246
  // src/browser/ios-driver.ts
2993
3247
  var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
@@ -2998,6 +3252,7 @@ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
2998
3252
  ]);
2999
3253
  var WAIT_POLL_INTERVAL_MS2 = 250;
3000
3254
  var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
3255
+ var DISPLAYED_CHECK_CONCURRENCY = 4;
3001
3256
  var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
3002
3257
  function escapePredicateArg(value) {
3003
3258
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
@@ -3072,6 +3327,64 @@ function createIosDriver(client, options) {
3072
3327
  async function fillSelector(selector, value) {
3073
3328
  await client.setValue(await resolveOne(selector), value);
3074
3329
  }
3330
+ async function swipe(direction, amount, size) {
3331
+ const actualSize = size ?? await client.windowSize();
3332
+ const { actions } = buildDirectionalSwipe(direction, actualSize, amount);
3333
+ await client.performActions(actions);
3334
+ }
3335
+ async function visibleElementIds(query) {
3336
+ const ids = await client.findElements(query);
3337
+ const visible = Array.from({ length: ids.length }, () => null);
3338
+ let nextIndex = 0;
3339
+ const workerCount = Math.min(DISPLAYED_CHECK_CONCURRENCY, ids.length);
3340
+ await Promise.all(
3341
+ Array.from({ length: workerCount }, async () => {
3342
+ for (; ; ) {
3343
+ const index = nextIndex;
3344
+ nextIndex += 1;
3345
+ if (index >= ids.length) {
3346
+ return;
3347
+ }
3348
+ const id = ids[index];
3349
+ if (await client.isDisplayed(id)) {
3350
+ visible[index] = id;
3351
+ }
3352
+ }
3353
+ })
3354
+ );
3355
+ return visible.filter((id) => id !== null);
3356
+ }
3357
+ async function hasVisibleElement(query) {
3358
+ const ids = await client.findElements(query);
3359
+ if (ids.length === 0) {
3360
+ return false;
3361
+ }
3362
+ if (await client.isDisplayed(ids[0])) {
3363
+ return true;
3364
+ }
3365
+ let found = false;
3366
+ let nextIndex = 1;
3367
+ const workerCount = Math.min(DISPLAYED_CHECK_CONCURRENCY, ids.length - 1);
3368
+ await Promise.all(
3369
+ Array.from({ length: workerCount }, async () => {
3370
+ for (; ; ) {
3371
+ if (found) {
3372
+ return;
3373
+ }
3374
+ const index = nextIndex;
3375
+ nextIndex += 1;
3376
+ if (index >= ids.length) {
3377
+ return;
3378
+ }
3379
+ if (await client.isDisplayed(ids[index])) {
3380
+ found = true;
3381
+ return;
3382
+ }
3383
+ }
3384
+ })
3385
+ );
3386
+ return found;
3387
+ }
3075
3388
  async function pressKey(key) {
3076
3389
  const name = key.trim().toLowerCase();
3077
3390
  if (name === "enter" || name === "return") {
@@ -3103,6 +3416,9 @@ function createIosDriver(client, options) {
3103
3416
  async count(selector) {
3104
3417
  return (await client.findElements(parseIosSelector(selector))).length;
3105
3418
  },
3419
+ async visibleCount(selector) {
3420
+ return (await visibleElementIds(parseIosSelector(selector))).length;
3421
+ },
3106
3422
  async textContent(selector) {
3107
3423
  const id = await client.findElement(parseIosSelector(selector));
3108
3424
  if (id === null) {
@@ -3127,15 +3443,38 @@ function createIosDriver(client, options) {
3127
3443
  hover() {
3128
3444
  return rejectUnsupported("hover");
3129
3445
  },
3130
- scrollIntoView() {
3131
- return rejectUnsupported("scrollTo");
3446
+ // Screen-centred swipe via the W3C actions endpoint (PROWL-080). Direction
3447
+ // semantics match the web step: scrolling "down" reveals lower content, so
3448
+ // the finger drags up. See ./touch-gestures.ts.
3449
+ async scroll(direction, amount) {
3450
+ await swipe(direction, amount);
3451
+ },
3452
+ // Resolve the element, short-circuiting only if WDA reports a matching
3453
+ // element displayed in the viewport; hierarchy-only matches can be offscreen.
3454
+ // Otherwise use the shared bounded down/up mobile probe before failing.
3455
+ async scrollIntoView(selector) {
3456
+ const query = parseIosSelector(selector);
3457
+ let probeSize;
3458
+ const found = await probeScrollIntoView({
3459
+ isVisible: () => hasVisibleElement(query),
3460
+ swipe: async (direction) => {
3461
+ probeSize ??= await client.windowSize();
3462
+ await swipe(direction, scrollToProbeDistanceFor(direction, probeSize), probeSize);
3463
+ }
3464
+ });
3465
+ if (found) {
3466
+ return;
3467
+ }
3468
+ throw new Error(
3469
+ `scrollTo: element "${selector}" not visible after ${MAX_SCROLL_TO_SWIPES} scroll attempts on the iOS target`
3470
+ );
3132
3471
  },
3133
3472
  setInputFiles() {
3134
3473
  return rejectUnsupported("setInputFiles");
3135
3474
  },
3136
3475
  // semantic locators ----------------------------------------------------
3137
3476
  async countByRole(role, name) {
3138
- return (await client.findElements({ by: "role", role, name })).length;
3477
+ return (await visibleElementIds({ by: "role", role, name })).length;
3139
3478
  },
3140
3479
  async clickFirstByRole(role, name) {
3141
3480
  const id = await client.findElement({ by: "role", role, name });
@@ -3145,7 +3484,7 @@ function createIosDriver(client, options) {
3145
3484
  await client.click(id);
3146
3485
  },
3147
3486
  async countByLabel(label) {
3148
- return (await client.findElements({ by: "label", value: label })).length;
3487
+ return (await visibleElementIds({ by: "label", value: label })).length;
3149
3488
  },
3150
3489
  async fillFirstByLabel(label, value) {
3151
3490
  const id = await client.findElement({ by: "label", value: label });
@@ -3163,7 +3502,7 @@ function createIosDriver(client, options) {
3163
3502
  const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
3164
3503
  const deadline = Date.now() + timeoutMs;
3165
3504
  for (; ; ) {
3166
- if ((await client.findElements(query)).length > 0) {
3505
+ if (await hasVisibleElement(query)) {
3167
3506
  return;
3168
3507
  }
3169
3508
  if (Date.now() >= deadline) {
@@ -3211,8 +3550,8 @@ function createIosDriver(client, options) {
3211
3550
  var import_node_child_process5 = require("child_process");
3212
3551
  var import_promises = require("fs/promises");
3213
3552
  var import_node_net = __toESM(require("net"), 1);
3214
- var import_node_os = __toESM(require("os"), 1);
3215
- var import_node_path6 = __toESM(require("path"), 1);
3553
+ var import_node_os3 = __toESM(require("os"), 1);
3554
+ var import_node_path7 = __toESM(require("path"), 1);
3216
3555
  var spawnXcrunProcess = (args, options) => {
3217
3556
  const child = (0, import_node_child_process5.spawn)("xcrun", args, {
3218
3557
  stdio: "ignore",
@@ -3226,7 +3565,7 @@ var spawnXcrunProcess = (args, options) => {
3226
3565
  }
3227
3566
  };
3228
3567
  };
3229
- var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path6.default.join(import_node_os.default.tmpdir(), "prowl-ios-simulator-locks");
3568
+ var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path7.default.join(import_node_os3.default.tmpdir(), "prowl-ios-simulator-locks");
3230
3569
  var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
3231
3570
  var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
3232
3571
  (0, import_node_child_process5.execFile)(
@@ -3266,7 +3605,7 @@ function isProcessAlive(pid) {
3266
3605
  }
3267
3606
  async function removeStaleSimulatorLock(lockPath) {
3268
3607
  try {
3269
- const ownerText = await (0, import_promises.readFile)(import_node_path6.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
3608
+ const ownerText = await (0, import_promises.readFile)(import_node_path7.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
3270
3609
  const owner = JSON.parse(ownerText);
3271
3610
  if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
3272
3611
  await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
@@ -3284,7 +3623,7 @@ function simulatorReservedError(udid) {
3284
3623
  }
3285
3624
  async function reserveSimulatorUdid(udid, options = {}) {
3286
3625
  const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
3287
- const lockPath = import_node_path6.default.join(lockRoot, simulatorLockName(udid));
3626
+ const lockPath = import_node_path7.default.join(lockRoot, simulatorLockName(udid));
3288
3627
  await (0, import_promises.mkdir)(lockRoot, { recursive: true });
3289
3628
  for (let attempt = 0; attempt < 2; attempt += 1) {
3290
3629
  try {
@@ -3308,7 +3647,7 @@ async function reserveSimulatorUdid(udid, options = {}) {
3308
3647
  };
3309
3648
  try {
3310
3649
  await (0, import_promises.writeFile)(
3311
- import_node_path6.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
3650
+ import_node_path7.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
3312
3651
  `${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
3313
3652
  `,
3314
3653
  { flag: "wx" }
@@ -3500,15 +3839,15 @@ var WdaTransport = class {
3500
3839
  * {@link WdaHttpError} on a non-2xx response, or a timeout error when the
3501
3840
  * per-request deadline elapses.
3502
3841
  */
3503
- async requestFull(method, path25, body, timeoutMs) {
3842
+ async requestFull(method, path27, body, timeoutMs) {
3504
3843
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
3505
3844
  const controller = new AbortController();
3506
3845
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
3507
3846
  timer.unref?.();
3508
- const url = `${this.baseUrl}${path25}`;
3847
+ const url = `${this.baseUrl}${path27}`;
3509
3848
  const timeoutError = () => {
3510
3849
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
3511
- return new Error(`WebDriverAgent request ${method} ${path25} timed out after ${shown}`);
3850
+ return new Error(`WebDriverAgent request ${method} ${path27} timed out after ${shown}`);
3512
3851
  };
3513
3852
  let response;
3514
3853
  try {
@@ -3540,7 +3879,7 @@ var WdaTransport = class {
3540
3879
  if (!response.ok) {
3541
3880
  const wdError = extractWebdriverError2(parsed);
3542
3881
  throw new WdaHttpError(
3543
- `WebDriverAgent ${method} ${path25} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
3882
+ `WebDriverAgent ${method} ${path27} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
3544
3883
  response.status,
3545
3884
  wdError
3546
3885
  );
@@ -3548,8 +3887,8 @@ var WdaTransport = class {
3548
3887
  return parsed;
3549
3888
  }
3550
3889
  /** Like {@link requestFull} but returns just the `value` field. */
3551
- async request(method, path25, body, timeoutMs) {
3552
- const parsed = await this.requestFull(method, path25, body, timeoutMs);
3890
+ async request(method, path27, body, timeoutMs) {
3891
+ const parsed = await this.requestFull(method, path27, body, timeoutMs);
3553
3892
  return parsed?.value;
3554
3893
  }
3555
3894
  };
@@ -3625,8 +3964,8 @@ function sleep2(ms) {
3625
3964
  }
3626
3965
  function createWdaAgentClient(transport, sessionId) {
3627
3966
  const base = `/session/${sessionId}`;
3628
- async function locate(query, path25) {
3629
- return transport.request("POST", `${base}${path25}`, iosQueryToLocator(query));
3967
+ async function locate(query, path27) {
3968
+ return transport.request("POST", `${base}${path27}`, iosQueryToLocator(query));
3630
3969
  }
3631
3970
  return {
3632
3971
  async findElement(query) {
@@ -3656,12 +3995,29 @@ function createWdaAgentClient(transport, sessionId) {
3656
3995
  const value = await transport.request("GET", `${base}/element/${elementId}/text`);
3657
3996
  return typeof value === "string" ? value : value == null ? null : String(value);
3658
3997
  },
3998
+ async isDisplayed(elementId) {
3999
+ try {
4000
+ return await transport.request("GET", `${base}/element/${elementId}/displayed`) === true;
4001
+ } catch (error) {
4002
+ if (isNoSuchElement2(error)) {
4003
+ return false;
4004
+ }
4005
+ throw error;
4006
+ }
4007
+ },
3659
4008
  async sendKeys(keys) {
3660
4009
  await transport.request("POST", `${base}/wda/keys`, { value: keys });
3661
4010
  },
3662
4011
  async homescreen() {
3663
4012
  await transport.request("POST", "/wda/homescreen", {});
3664
4013
  },
4014
+ async windowSize() {
4015
+ const value = await transport.request("GET", `${base}/window/size`);
4016
+ return toScreenSize(value, "WebDriverAgent /window/size");
4017
+ },
4018
+ async performActions(actions) {
4019
+ await transport.request("POST", `${base}/actions`, { actions: [actions] });
4020
+ },
3665
4021
  async source() {
3666
4022
  const value = await transport.request("GET", "/source");
3667
4023
  if (typeof value !== "string") {
@@ -3702,19 +4058,19 @@ function resolveWdaProject(requireFn = (0, import_node_module2.createRequire)(im
3702
4058
  "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"
3703
4059
  );
3704
4060
  }
3705
- const pkgDir = import_node_path7.default.dirname(pkgJsonPath);
4061
+ const pkgDir = import_node_path8.default.dirname(pkgJsonPath);
3706
4062
  const version = requireFn(pkgJsonPath).version;
3707
- const projectPath = import_node_path7.default.join(pkgDir, "WebDriverAgent.xcodeproj");
4063
+ const projectPath = import_node_path8.default.join(pkgDir, "WebDriverAgent.xcodeproj");
3708
4064
  if (!import_node_fs7.default.existsSync(projectPath)) {
3709
4065
  throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
3710
4066
  }
3711
4067
  return { projectPath, version };
3712
4068
  }
3713
- function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os2.default.homedir()) {
3714
- return import_node_path7.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
4069
+ function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os4.default.homedir()) {
4070
+ return import_node_path8.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
3715
4071
  }
3716
4072
  function productsDir(derivedDataPath) {
3717
- return import_node_path7.default.join(derivedDataPath, "Build", "Products");
4073
+ return import_node_path8.default.join(derivedDataPath, "Build", "Products");
3718
4074
  }
3719
4075
  function findXctestrunIn(dir) {
3720
4076
  let entries;
@@ -3724,7 +4080,7 @@ function findXctestrunIn(dir) {
3724
4080
  return null;
3725
4081
  }
3726
4082
  const match = entries.filter((name) => name.endsWith(".xctestrun") && !name.startsWith(PREPARED_XCTESTRUN_PREFIX)).sort()[0];
3727
- return match ? import_node_path7.default.join(dir, match) : null;
4083
+ return match ? import_node_path8.default.join(dir, match) : null;
3728
4084
  }
3729
4085
  function resolveOverrideXctestrun(override) {
3730
4086
  if (!import_node_fs7.default.existsSync(override)) {
@@ -3736,7 +4092,7 @@ function resolveOverrideXctestrun(override) {
3736
4092
  const candidates = [];
3737
4093
  const stat = import_node_fs7.default.statSync(override);
3738
4094
  if (override.endsWith(".app")) {
3739
- candidates.push(import_node_path7.default.dirname(import_node_path7.default.dirname(override)));
4095
+ candidates.push(import_node_path8.default.dirname(import_node_path8.default.dirname(override)));
3740
4096
  } else if (stat.isDirectory()) {
3741
4097
  candidates.push(override, productsDir(override));
3742
4098
  }
@@ -3753,7 +4109,7 @@ function resolveOverrideXctestrun(override) {
3753
4109
  async function resolveWdaTestRun(options = {}) {
3754
4110
  const runner = options.runner ?? execFileXcrunRunner;
3755
4111
  const env = options.env ?? process.env;
3756
- const homeDir = options.homeDir ?? import_node_os2.default.homedir();
4112
+ const homeDir = options.homeDir ?? import_node_os4.default.homedir();
3757
4113
  const log = options.logger ?? ((message) => process.stderr.write(`${message}
3758
4114
  `));
3759
4115
  const override = env.PROWL_WDA_RUNNER;
@@ -3869,8 +4225,8 @@ var defaultWdaTestRunPreparer = async ({ xctestrunPath, port }) => {
3869
4225
  }
3870
4226
  const injected = injectUsePortIntoXctestrun(parsed, port);
3871
4227
  const stem = `${PREPARED_XCTESTRUN_PREFIX}${port}-${process.pid}`;
3872
- const outPath = import_node_path7.default.join(import_node_path7.default.dirname(xctestrunPath), `${stem}.xctestrun`);
3873
- const jsonPath = import_node_path7.default.join(import_node_os2.default.tmpdir(), `${stem}.json`);
4228
+ const outPath = import_node_path8.default.join(import_node_path8.default.dirname(xctestrunPath), `${stem}.xctestrun`);
4229
+ const jsonPath = import_node_path8.default.join(import_node_os4.default.tmpdir(), `${stem}.json`);
3874
4230
  import_node_fs7.default.writeFileSync(jsonPath, JSON.stringify(injected));
3875
4231
  try {
3876
4232
  await runPlutil(["-convert", "xml1", jsonPath, "-o", outPath]);
@@ -3883,7 +4239,7 @@ function cleanupPreparedTestRun(preparedPath) {
3883
4239
  if (!preparedPath) {
3884
4240
  return;
3885
4241
  }
3886
- if (!import_node_path7.default.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
4242
+ if (!import_node_path8.default.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
3887
4243
  return;
3888
4244
  }
3889
4245
  import_node_fs7.default.rmSync(preparedPath, { force: true });
@@ -3908,7 +4264,7 @@ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
3908
4264
  }
3909
4265
  return app;
3910
4266
  }
3911
- const appPath = import_node_path7.default.resolve(app);
4267
+ const appPath = import_node_path8.default.resolve(app);
3912
4268
  if (!import_node_fs7.default.existsSync(appPath)) {
3913
4269
  throw new Error(`.app bundle not found: ${appPath}`);
3914
4270
  }
@@ -4013,8 +4369,9 @@ async function closeIosSession(session) {
4013
4369
 
4014
4370
  // src/runner/steps.ts
4015
4371
  var import_node_fs9 = __toESM(require("fs"), 1);
4016
- var import_node_path9 = __toESM(require("path"), 1);
4372
+ var import_node_path10 = __toESM(require("path"), 1);
4017
4373
  init_loader();
4374
+ init_interpolate();
4018
4375
 
4019
4376
  // src/runner/healing.ts
4020
4377
  var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
@@ -4712,11 +5069,14 @@ function toVisibilitySelector(value) {
4712
5069
  if (looksLikeSelector(value)) return value;
4713
5070
  return textContainsSelector(value);
4714
5071
  }
5072
+ function countVisible(driver, selector) {
5073
+ return driver.visibleCount?.(selector) ?? driver.count(selector);
5074
+ }
4715
5075
  async function runInlineAssert(driver, policy, assertion) {
4716
5076
  if (assertion.visible !== void 0) {
4717
5077
  const selector = toVisibilitySelector(assertion.visible);
4718
5078
  policy.assertAllowedSelector(selector);
4719
- const count = await driver.count(selector);
5079
+ const count = await countVisible(driver, selector);
4720
5080
  if (count === 0) {
4721
5081
  throw new Error(`Expected visible: ${assertion.visible}`);
4722
5082
  }
@@ -4725,7 +5085,7 @@ async function runInlineAssert(driver, policy, assertion) {
4725
5085
  if (assertion.notVisible !== void 0) {
4726
5086
  const selector = toVisibilitySelector(assertion.notVisible);
4727
5087
  policy.assertAllowedSelector(selector);
4728
- const count = await driver.count(selector);
5088
+ const count = await countVisible(driver, selector);
4729
5089
  if (count > 0) {
4730
5090
  throw new Error(`Expected not visible: ${assertion.notVisible}`);
4731
5091
  }
@@ -4748,7 +5108,7 @@ async function runInlineAssert(driver, policy, assertion) {
4748
5108
  throw new Error("assert step is missing an assertion type");
4749
5109
  }
4750
5110
  function screenshotPath(screenshotsDir, fileName) {
4751
- return import_node_path9.default.join(screenshotsDir, fileName);
5111
+ return import_node_path10.default.join(screenshotsDir, fileName);
4752
5112
  }
4753
5113
  function stepPath(prefix, index) {
4754
5114
  return prefix ? `${prefix}.${index}` : `${index}`;
@@ -4765,7 +5125,7 @@ function validateDownloadFilename(suggestedFilename) {
4765
5125
  const safeFilename = suggestedFilename.trim();
4766
5126
  const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
4767
5127
  const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
4768
- if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path9.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
5128
+ if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path10.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
4769
5129
  throw new Error(`Invalid download filename: "${suggestedFilename}"`);
4770
5130
  }
4771
5131
  return safeFilename;
@@ -4945,7 +5305,7 @@ var STEP_HANDLERS = {
4945
5305
  if (!("setInputFiles" in h.step)) unknownStep();
4946
5306
  const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
4947
5307
  const rawFiles = h.step.setInputFiles.files;
4948
- const resolveFile = (f) => import_node_path9.default.isAbsolute(f) ? f : import_node_path9.default.join(h.context.configDir, f);
5308
+ const resolveFile = (f) => import_node_path10.default.isAbsolute(f) ? f : import_node_path10.default.join(h.context.configDir, f);
4949
5309
  const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
4950
5310
  await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
4951
5311
  h.policy.ensureLocationAllowed(h.driver);
@@ -5115,25 +5475,21 @@ var STEP_HANDLERS = {
5115
5475
  }
5116
5476
  },
5117
5477
  scroll: {
5118
- capabilities: ["evaluate"],
5478
+ // Dispatched through the driver's `scroll` verb (interact), so native mobile
5479
+ // targets synthesize a touch swipe while web keeps its `window.scrollBy`
5480
+ // behavior. The per-target step gate rejects `scroll` on macOS before here.
5481
+ capabilities: ["interact"],
5119
5482
  run: async (h) => {
5120
5483
  if (!("scroll" in h.step)) unknownStep();
5121
- const amount = h.step.scroll.amount ?? 500;
5122
- const scrollMap = {
5123
- up: [0, -amount],
5124
- down: [0, amount],
5125
- left: [-amount, 0],
5126
- right: [amount, 0]
5127
- };
5128
- const [x, y] = scrollMap[h.step.scroll.direction];
5129
- await h.driver.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
5484
+ const { direction, amount } = h.step.scroll;
5485
+ await h.driver.scroll(direction, amount);
5130
5486
  return {
5131
5487
  kind: "result",
5132
5488
  result: {
5133
5489
  type: "scroll",
5134
5490
  status: "pass",
5135
5491
  durationMs: Date.now() - h.stepStart,
5136
- value: `${h.step.scroll.direction} ${amount}px`
5492
+ value: amount === void 0 ? direction : `${direction} ${amount}px`
5137
5493
  }
5138
5494
  };
5139
5495
  }
@@ -5179,7 +5535,7 @@ var STEP_HANDLERS = {
5179
5535
  const condition = h.step.if;
5180
5536
  const selector = condition.visible ?? condition.notVisible;
5181
5537
  h.policy.assertAllowedSelector(selector);
5182
- const count = await h.driver.count(selector);
5538
+ const count = await countVisible(h.driver, selector);
5183
5539
  const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
5184
5540
  if (conditionMet) {
5185
5541
  const subResult = await h.executeNested({
@@ -5266,7 +5622,7 @@ var STEP_HANDLERS = {
5266
5622
  const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
5267
5623
  h.policy.assertAllowedSelector(whileSelector);
5268
5624
  for (let i = 0; i < maxIter; i++) {
5269
- const whileCount = await h.driver.count(whileSelector);
5625
+ const whileCount = await countVisible(h.driver, whileSelector);
5270
5626
  const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
5271
5627
  if (!shouldContinue) break;
5272
5628
  totalSubSteps += repeat.steps.length;
@@ -5307,11 +5663,11 @@ var STEP_HANDLERS = {
5307
5663
  if (!responseFile) {
5308
5664
  throw new Error("mock.response must include either body or file");
5309
5665
  }
5310
- const candidateFilePath = import_node_path9.default.isAbsolute(responseFile) ? responseFile : import_node_path9.default.join(h.context.configDir, responseFile);
5311
- const resolvedConfigDir = import_node_path9.default.resolve(h.context.configDir);
5312
- const resolvedFilePath = import_node_path9.default.resolve(candidateFilePath);
5313
- const relativePath = import_node_path9.default.relative(resolvedConfigDir, resolvedFilePath);
5314
- const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path9.default.sep}`) && !import_node_path9.default.isAbsolute(relativePath);
5666
+ const candidateFilePath = import_node_path10.default.isAbsolute(responseFile) ? responseFile : import_node_path10.default.join(h.context.configDir, responseFile);
5667
+ const resolvedConfigDir = import_node_path10.default.resolve(h.context.configDir);
5668
+ const resolvedFilePath = import_node_path10.default.resolve(candidateFilePath);
5669
+ const relativePath = import_node_path10.default.relative(resolvedConfigDir, resolvedFilePath);
5670
+ const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path10.default.sep}`) && !import_node_path10.default.isAbsolute(relativePath);
5315
5671
  if (!isWithinConfigDir) {
5316
5672
  throw new Error("mock.response.file must resolve within config directory");
5317
5673
  }
@@ -5378,7 +5734,7 @@ var STEP_HANDLERS = {
5378
5734
  capabilities: ["evaluate"],
5379
5735
  run: async (h) => {
5380
5736
  if (!("runScript" in h.step)) unknownStep();
5381
- 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);
5737
+ const filePath = import_node_path10.default.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : import_node_path10.default.join(h.context.configDir, h.step.runScript.file);
5382
5738
  const fileContents = import_node_fs9.default.readFileSync(filePath, "utf-8");
5383
5739
  await h.driver.evaluate(fileContents);
5384
5740
  return {
@@ -5400,11 +5756,11 @@ var STEP_HANDLERS = {
5400
5756
  const name = h.step.assertScreenshot.name;
5401
5757
  const threshold = h.step.assertScreenshot.threshold ?? 0.1;
5402
5758
  const baselineDir = ensureBaselineDir2(h.context.configDir);
5403
- const baselinePath = import_node_path9.default.join(baselineDir, `${name}.png`);
5404
- const currentScreenshotPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
5405
- import_node_fs9.default.mkdirSync(import_node_path9.default.dirname(currentScreenshotPath), { recursive: true });
5759
+ const baselinePath = import_node_path10.default.join(baselineDir, `${name}.png`);
5760
+ const currentScreenshotPath = import_node_path10.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
5761
+ import_node_fs9.default.mkdirSync(import_node_path10.default.dirname(currentScreenshotPath), { recursive: true });
5406
5762
  await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
5407
- h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-current.png`));
5763
+ h.screenshots.push(import_node_path10.default.join("screenshots", `${name}-current.png`));
5408
5764
  if (!import_node_fs9.default.existsSync(baselinePath)) {
5409
5765
  import_node_fs9.default.copyFileSync(currentScreenshotPath, baselinePath);
5410
5766
  return {
@@ -5412,7 +5768,7 @@ var STEP_HANDLERS = {
5412
5768
  result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
5413
5769
  };
5414
5770
  }
5415
- const diffPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
5771
+ const diffPath = import_node_path10.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
5416
5772
  const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
5417
5773
  if (comparison.match) {
5418
5774
  return {
@@ -5425,7 +5781,7 @@ var STEP_HANDLERS = {
5425
5781
  }
5426
5782
  };
5427
5783
  }
5428
- h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-diff.png`));
5784
+ h.screenshots.push(import_node_path10.default.join("screenshots", `${name}-diff.png`));
5429
5785
  throw new Error(
5430
5786
  `Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
5431
5787
  );
@@ -5451,7 +5807,7 @@ var STEP_HANDLERS = {
5451
5807
  }
5452
5808
  const fileName = `assertWithAI_step_${h.index + 1}.png`;
5453
5809
  const relative = await h.addScreenshot(fileName);
5454
- const screenshotFullPath = import_node_path9.default.join(h.context.runDir, relative);
5810
+ const screenshotFullPath = import_node_path10.default.join(h.context.runDir, relative);
5455
5811
  const imageBase64 = import_node_fs9.default.readFileSync(screenshotFullPath).toString("base64");
5456
5812
  const assertVision = h.context.assertVision ?? assertWithAiVision;
5457
5813
  const verdict = await assertVision(
@@ -5509,7 +5865,7 @@ var STEP_HANDLERS = {
5509
5865
  `Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
5510
5866
  );
5511
5867
  }
5512
- const savePath = import_node_path9.default.join(h.context.runDir, suggestedFilename);
5868
+ const savePath = import_node_path10.default.join(h.context.runDir, suggestedFilename);
5513
5869
  await download.saveAs(savePath);
5514
5870
  return {
5515
5871
  kind: "result",
@@ -5538,7 +5894,7 @@ async function executeSteps(context) {
5538
5894
  maxSteps: context.maxSteps,
5539
5895
  selfHealing: context.selfHealing
5540
5896
  });
5541
- const screenshotsDir = import_node_path9.default.join(context.runDir, "screenshots");
5897
+ const screenshotsDir = import_node_path10.default.join(context.runDir, "screenshots");
5542
5898
  import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
5543
5899
  const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
5544
5900
  policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
@@ -5549,7 +5905,7 @@ async function executeSteps(context) {
5549
5905
  const addScreenshot = async (fileName) => {
5550
5906
  const fullPath = screenshotPath(screenshotsDir, fileName);
5551
5907
  await captureScreenshot2(driver, fullPath);
5552
- const relative = import_node_path9.default.join("screenshots", fileName);
5908
+ const relative = import_node_path10.default.join("screenshots", fileName);
5553
5909
  screenshots.push(relative);
5554
5910
  return relative;
5555
5911
  };
@@ -5637,12 +5993,12 @@ async function executeSteps(context) {
5637
5993
  return { results, screenshots, failed: false };
5638
5994
  }
5639
5995
  async function captureFinalScreenshot(page, runDir) {
5640
- const screenshotsDir = import_node_path9.default.join(runDir, "screenshots");
5996
+ const screenshotsDir = import_node_path10.default.join(runDir, "screenshots");
5641
5997
  import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
5642
5998
  const fileName = "final.png";
5643
5999
  const filePath = screenshotPath(screenshotsDir, fileName);
5644
6000
  await captureScreenshot2(page, filePath);
5645
- return import_node_path9.default.join("screenshots", fileName);
6001
+ return import_node_path10.default.join("screenshots", fileName);
5646
6002
  }
5647
6003
 
5648
6004
  // src/runner/assertions.ts
@@ -5764,6 +6120,59 @@ async function evaluateAssertions(options) {
5764
6120
  }
5765
6121
  return results;
5766
6122
  }
6123
+ async function evaluateNativeAssertions(options) {
6124
+ const assertions = mergeAssertions(options.config, options.huntAssertions);
6125
+ const authoredTypes = new Set(
6126
+ (options.huntAssertions ?? []).map((assertion) => Object.keys(assertion)[0])
6127
+ );
6128
+ const results = [];
6129
+ const warnings = [];
6130
+ for (const assertion of assertions) {
6131
+ const type = Object.keys(assertion)[0] ?? "assertion";
6132
+ try {
6133
+ if ("selectorExists" in assertion) {
6134
+ options.assertAllowedSelector?.(assertion.selectorExists);
6135
+ const count = await options.driver.count(assertion.selectorExists);
6136
+ results.push({
6137
+ type: "selectorExists",
6138
+ value: assertion.selectorExists,
6139
+ status: count > 0 ? "pass" : "fail",
6140
+ error: count > 0 ? void 0 : "Selector not found"
6141
+ });
6142
+ continue;
6143
+ }
6144
+ if ("selectorNotExists" in assertion) {
6145
+ options.assertAllowedSelector?.(assertion.selectorNotExists);
6146
+ const count = await options.driver.count(assertion.selectorNotExists);
6147
+ results.push({
6148
+ type: "selectorNotExists",
6149
+ value: assertion.selectorNotExists,
6150
+ status: count === 0 ? "pass" : "fail",
6151
+ error: count === 0 ? void 0 : "Selector exists"
6152
+ });
6153
+ continue;
6154
+ }
6155
+ const rawValue = assertion[type];
6156
+ results.push({
6157
+ type,
6158
+ value: rawValue,
6159
+ status: "skipped",
6160
+ error: `skipped (web-only): not supported on the ${options.targetLabel} target`
6161
+ });
6162
+ if (authoredTypes.has(type)) {
6163
+ warnings.push(`${type} is web-only; skipped on ${options.targetLabel} target`);
6164
+ }
6165
+ } catch (error) {
6166
+ const detail = error instanceof Error ? error.message : "no error details";
6167
+ results.push({
6168
+ type,
6169
+ status: "fail",
6170
+ error: `Native assertion "${type}" failed: ${detail}`
6171
+ });
6172
+ }
6173
+ }
6174
+ return { results, warnings };
6175
+ }
5767
6176
 
5768
6177
  // src/runner/tracing.ts
5769
6178
  var DEFAULT_TRACE_HEADER = "traceparent";
@@ -5803,17 +6212,17 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
5803
6212
 
5804
6213
  // src/reporter/result.ts
5805
6214
  var import_node_fs10 = __toESM(require("fs"), 1);
5806
- var import_node_path10 = __toESM(require("path"), 1);
6215
+ var import_node_path11 = __toESM(require("path"), 1);
5807
6216
  function writeResult(runDir, result) {
5808
6217
  const fileName = "result.json";
5809
- const fullPath = import_node_path10.default.join(runDir, fileName);
6218
+ const fullPath = import_node_path11.default.join(runDir, fileName);
5810
6219
  import_node_fs10.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
5811
6220
  return fileName;
5812
6221
  }
5813
6222
 
5814
6223
  // src/reporter/summary.ts
5815
6224
  var import_node_fs11 = __toESM(require("fs"), 1);
5816
- var import_node_path11 = __toESM(require("path"), 1);
6225
+ var import_node_path12 = __toESM(require("path"), 1);
5817
6226
  function escapeMd(text) {
5818
6227
  return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
5819
6228
  }
@@ -5891,7 +6300,7 @@ function writeSummary(runDir, result) {
5891
6300
  }
5892
6301
  }
5893
6302
  const fileName = "summary.md";
5894
- const fullPath = import_node_path11.default.join(runDir, fileName);
6303
+ const fullPath = import_node_path12.default.join(runDir, fileName);
5895
6304
  import_node_fs11.default.writeFileSync(fullPath, `${lines.join("\n")}
5896
6305
  `);
5897
6306
  return fileName;
@@ -5899,20 +6308,21 @@ function writeSummary(runDir, result) {
5899
6308
 
5900
6309
  // src/reporter/junit.ts
5901
6310
  var import_node_fs12 = __toESM(require("fs"), 1);
5902
- var import_node_path12 = __toESM(require("path"), 1);
6311
+ var import_node_path13 = __toESM(require("path"), 1);
5903
6312
  function escapeXml(text) {
5904
6313
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
5905
6314
  }
5906
6315
  function writeJunit(runDir, result) {
5907
6316
  const totalTests = result.steps.length + result.assertions.length;
5908
6317
  const failures = result.steps.filter((s) => s.status === "fail").length + result.assertions.filter((a) => a.status === "fail").length;
6318
+ const skipped = result.assertions.filter((a) => a.status === "skipped").length;
5909
6319
  const timeSeconds = (result.durationMs / 1e3).toFixed(3);
5910
6320
  const huntName = escapeXml(result.hunt);
5911
6321
  const lines = [];
5912
6322
  lines.push('<?xml version="1.0" encoding="UTF-8"?>');
5913
6323
  lines.push("<testsuites>");
5914
6324
  lines.push(
5915
- ` <testsuite name="${huntName}" tests="${totalTests}" failures="${failures}" errors="0" time="${timeSeconds}" timestamp="${escapeXml(result.startedAt)}">`
6325
+ ` <testsuite name="${huntName}" tests="${totalTests}" failures="${failures}" errors="0" skipped="${skipped}" time="${timeSeconds}" timestamp="${escapeXml(result.startedAt)}">`
5916
6326
  );
5917
6327
  for (let i = 0; i < result.steps.length; i++) {
5918
6328
  const step = result.steps[i];
@@ -5936,6 +6346,11 @@ function writeJunit(runDir, result) {
5936
6346
  lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0">`);
5937
6347
  lines.push(` <failure message="${escapedFailureText}" type="assertion">${escapedFailureText}</failure>`);
5938
6348
  lines.push(" </testcase>");
6349
+ } else if (assertion.status === "skipped") {
6350
+ const skipText = escapeXml(assertion.error ?? "skipped");
6351
+ lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0">`);
6352
+ lines.push(` <skipped message="${skipText}"/>`);
6353
+ lines.push(" </testcase>");
5939
6354
  } else {
5940
6355
  lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0"/>`);
5941
6356
  }
@@ -5943,7 +6358,7 @@ function writeJunit(runDir, result) {
5943
6358
  lines.push(" </testsuite>");
5944
6359
  lines.push("</testsuites>");
5945
6360
  const fileName = "junit.xml";
5946
- const fullPath = import_node_path12.default.join(runDir, fileName);
6361
+ const fullPath = import_node_path13.default.join(runDir, fileName);
5947
6362
  import_node_fs12.default.writeFileSync(fullPath, `${lines.join("\n")}
5948
6363
  `);
5949
6364
  return fileName;
@@ -5979,14 +6394,14 @@ function timestamp(prefix) {
5979
6394
 
5980
6395
  // src/runner/history.ts
5981
6396
  var import_node_fs13 = __toESM(require("fs"), 1);
5982
- var import_node_path13 = __toESM(require("path"), 1);
6397
+ var import_node_path14 = __toESM(require("path"), 1);
5983
6398
  var HISTORY_FILE = "history.json";
5984
6399
  var LOCK_FILE_SUFFIX = ".lock";
5985
6400
  var LOCK_RETRY_MS = 10;
5986
6401
  var LOCK_TIMEOUT_MS = 5e3;
5987
6402
  var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
5988
6403
  function historyPath(configDir) {
5989
- return import_node_path13.default.join(configDir, HISTORY_FILE);
6404
+ return import_node_path14.default.join(configDir, HISTORY_FILE);
5990
6405
  }
5991
6406
  function isHistoryEntry(value) {
5992
6407
  if (!value || typeof value !== "object") {
@@ -6040,7 +6455,7 @@ function sleepSync(ms) {
6040
6455
  function withHistoryLock(configDir, fn) {
6041
6456
  const filePath = historyPath(configDir);
6042
6457
  const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
6043
- import_node_fs13.default.mkdirSync(import_node_path13.default.dirname(filePath), { recursive: true });
6458
+ import_node_fs13.default.mkdirSync(import_node_path14.default.dirname(filePath), { recursive: true });
6044
6459
  const startedAt = Date.now();
6045
6460
  while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
6046
6461
  let fd;
@@ -6088,11 +6503,11 @@ function parseViewportFlag(value) {
6088
6503
  return value;
6089
6504
  }
6090
6505
  function resolvePath(configDir, inputPath) {
6091
- if (import_node_path14.default.isAbsolute(inputPath)) {
6506
+ if (import_node_path15.default.isAbsolute(inputPath)) {
6092
6507
  return inputPath;
6093
6508
  }
6094
- const projectRoot = import_node_path14.default.dirname(configDir);
6095
- return import_node_path14.default.join(projectRoot, inputPath);
6509
+ const projectRoot = import_node_path15.default.dirname(configDir);
6510
+ return import_node_path15.default.join(projectRoot, inputPath);
6096
6511
  }
6097
6512
  function buildRunResult(options) {
6098
6513
  return {
@@ -6111,7 +6526,7 @@ function buildRunResult(options) {
6111
6526
  }
6112
6527
  function writeConsoleLog(runDir, entries) {
6113
6528
  const fileName = "console.log";
6114
- const filePath = import_node_path14.default.join(runDir, fileName);
6529
+ const filePath = import_node_path15.default.join(runDir, fileName);
6115
6530
  const lines = entries.map((entry) => {
6116
6531
  const location = entry.location ? ` (${entry.location})` : "";
6117
6532
  return `[${entry.type}] ${entry.text}${location}`;
@@ -6124,7 +6539,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
6124
6539
  const headless = options.headed ? false : config.browser.headless;
6125
6540
  const slowMo = options.slowMo ?? config.browser.slowMo;
6126
6541
  const maxSteps = config.guardrails.maxSteps;
6127
- const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
6542
+ const runDir = import_node_path15.default.join(configDir, "runs", timestamp());
6128
6543
  import_node_fs14.default.mkdirSync(runDir, { recursive: true });
6129
6544
  const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
6130
6545
  const engine = options.browser ?? config.browser.engine;
@@ -6308,7 +6723,7 @@ async function runHunt(options) {
6308
6723
  }
6309
6724
  async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
6310
6725
  const maxSteps = config.guardrails.maxSteps;
6311
- const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
6726
+ const runDir = import_node_path15.default.join(configDir, "runs", timestamp());
6312
6727
  import_node_fs14.default.mkdirSync(runDir, { recursive: true });
6313
6728
  const session = await native.launchSession();
6314
6729
  let result;
@@ -6317,6 +6732,13 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
6317
6732
  const appIdentity = native.sessionAppIdentity(session);
6318
6733
  const targetLabel = `${native.targetType}:${appIdentity}`;
6319
6734
  const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
6735
+ const assertionPolicy = createRunPolicy(driver, {
6736
+ forbiddenSelectors: config.guardrails.forbiddenSelectors,
6737
+ allowedDomains: [],
6738
+ allowedApps: effectiveAllowedApps,
6739
+ maxSteps,
6740
+ selfHealing: config.guardrails.selfHealing
6741
+ });
6320
6742
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
6321
6743
  const startTime = Date.now();
6322
6744
  let stepResults = [];
@@ -6356,8 +6778,19 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
6356
6778
  } catch {
6357
6779
  finalScreenshot = void 0;
6358
6780
  }
6781
+ const { results: assertionResults, warnings: assertionWarnings } = await evaluateNativeAssertions({
6782
+ driver,
6783
+ config,
6784
+ huntAssertions: interpolatedHunt.assertions,
6785
+ assertAllowedSelector: assertionPolicy.assertAllowedSelector,
6786
+ targetLabel: nativeTargetLabel(native.targetType)
6787
+ });
6788
+ for (const warning of assertionWarnings) {
6789
+ console.warn(warning);
6790
+ }
6359
6791
  const durationMs = Date.now() - startTime;
6360
- const status = stepFailed ? "fail" : "pass";
6792
+ const assertionsFailed = assertionResults.some((assertion) => assertion.status === "fail");
6793
+ const status = stepFailed || assertionsFailed ? "fail" : "pass";
6361
6794
  const artifacts = {
6362
6795
  screenshots: finalScreenshot ? [...stepScreenshots, finalScreenshot] : stepScreenshots
6363
6796
  };
@@ -6368,7 +6801,7 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
6368
6801
  hunt: options.huntName,
6369
6802
  targetUrl: targetLabel,
6370
6803
  steps: stepResults,
6371
- assertions: [],
6804
+ assertions: assertionResults,
6372
6805
  artifacts
6373
6806
  });
6374
6807
  result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
@@ -6481,7 +6914,6 @@ async function runNativeHunt(options, config, configDir, target, native) {
6481
6914
  const hunt = loadHunt(options.huntName, configDir);
6482
6915
  const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
6483
6916
  assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
6484
- assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
6485
6917
  native.assertAppAllowed(config.guardrails.allowedApps, target);
6486
6918
  const maxSteps = config.guardrails.maxSteps;
6487
6919
  if (interpolatedHunt.steps.length > maxSteps) {
@@ -6522,7 +6954,7 @@ async function runNativeHunt(options, config, configDir, target, native) {
6522
6954
  }
6523
6955
  function recordHistory(configDir, outcome, maxRuns) {
6524
6956
  try {
6525
- const relativeRunDir = import_node_path14.default.relative(configDir, outcome.runDir);
6957
+ const relativeRunDir = import_node_path15.default.relative(configDir, outcome.runDir);
6526
6958
  appendEntry(
6527
6959
  configDir,
6528
6960
  {
@@ -6540,6 +6972,7 @@ function recordHistory(configDir, outcome, maxRuns) {
6540
6972
 
6541
6973
  // src/cli/commands/run.ts
6542
6974
  init_loader();
6975
+ init_hunt_name();
6543
6976
 
6544
6977
  // src/cli/output.ts
6545
6978
  var import_chalk = __toESM(require("chalk"), 1);
@@ -6685,7 +7118,11 @@ ${bot}`);
6685
7118
 
6686
7119
  // src/cli/commands/run.ts
6687
7120
  function buildRunCommand() {
6688
- const command = new import_commander.Command("run").argument("<hunt-name>", "Hunt name or path (e.g. homepage or admin/users-crud)").option("--url <target>", "Override target URL").option("--headed", "Show browser window").option("--slow-mo <ms>", "Slow down Playwright actions", (value) => Number(value)).option("--trace", "Capture Playwright trace").option("--browser <engine>", "Browser engine: chromium, firefox, or webkit").option("--channel <name>", "Browser channel: chrome, msedge, chrome-beta, etc.").option("--viewport <size>", "Viewport size: WxH (e.g. 1920x1080) or preset (mobile, tablet, desktop)").option("--include-tags <tags>", "Only run hunts matching these tags (comma-separated)").option("--exclude-tags <tags>", "Skip hunts matching these tags (comma-separated)").option("--junit", "Generate JUnit XML report").option("--config <path>", "Custom config path").option("--json", "Output results as JSON").action(async (huntName, options) => {
7121
+ const command = new import_commander.Command("run").argument(
7122
+ "<hunt-name>",
7123
+ "Hunt name or path (e.g. homepage, admin/users-crud, or .prowl/hunts/homepage.yml)"
7124
+ ).option("--url <target>", "Override target URL").option("--headed", "Show browser window").option("--slow-mo <ms>", "Slow down Playwright actions", (value) => Number(value)).option("--trace", "Capture Playwright trace").option("--browser <engine>", "Browser engine: chromium, firefox, or webkit").option("--channel <name>", "Browser channel: chrome, msedge, chrome-beta, etc.").option("--viewport <size>", "Viewport size: WxH (e.g. 1920x1080) or preset (mobile, tablet, desktop)").option("--include-tags <tags>", "Only run hunts matching these tags (comma-separated)").option("--exclude-tags <tags>", "Skip hunts matching these tags (comma-separated)").option("--junit", "Generate JUnit XML report").option("--config <path>", "Custom config path").option("--json", "Output results as JSON").action(async (huntArg, options) => {
7125
+ const huntName = normalizeHuntName(huntArg);
6689
7126
  try {
6690
7127
  if (options.includeTags || options.excludeTags) {
6691
7128
  const { configDir } = loadConfig(options.config);
@@ -6752,7 +7189,7 @@ function buildRunCommand() {
6752
7189
 
6753
7190
  // src/cli/commands/init.ts
6754
7191
  var import_node_fs15 = __toESM(require("fs"), 1);
6755
- var import_node_path15 = __toESM(require("path"), 1);
7192
+ var import_node_path16 = __toESM(require("path"), 1);
6756
7193
  var import_node_url2 = require("url");
6757
7194
  var import_commander2 = require("commander");
6758
7195
  var import_chalk4 = __toESM(require("chalk"), 1);
@@ -6760,27 +7197,27 @@ init_loader();
6760
7197
  var import_meta4 = {};
6761
7198
  function getPackageRoot2() {
6762
7199
  const currentFile = (0, import_node_url2.fileURLToPath)(import_meta4.url);
6763
- let dir = import_node_path15.default.dirname(currentFile);
6764
- const root = import_node_path15.default.parse(dir).root;
7200
+ let dir = import_node_path16.default.dirname(currentFile);
7201
+ const root = import_node_path16.default.parse(dir).root;
6765
7202
  while (dir !== root) {
6766
- if (import_node_fs15.default.existsSync(import_node_path15.default.join(dir, "package.json"))) {
7203
+ if (import_node_fs15.default.existsSync(import_node_path16.default.join(dir, "package.json"))) {
6767
7204
  return dir;
6768
7205
  }
6769
- dir = import_node_path15.default.dirname(dir);
7206
+ dir = import_node_path16.default.dirname(dir);
6770
7207
  }
6771
- if (import_node_fs15.default.existsSync(import_node_path15.default.join(root, "package.json"))) {
7208
+ if (import_node_fs15.default.existsSync(import_node_path16.default.join(root, "package.json"))) {
6772
7209
  return root;
6773
7210
  }
6774
7211
  throw new Error("Cannot find package root. Reinstall prowl-tools.");
6775
7212
  }
6776
7213
  function copyFile(source, destination) {
6777
- import_node_fs15.default.mkdirSync(import_node_path15.default.dirname(destination), { recursive: true });
7214
+ import_node_fs15.default.mkdirSync(import_node_path16.default.dirname(destination), { recursive: true });
6778
7215
  import_node_fs15.default.copyFileSync(source, destination);
6779
7216
  }
6780
7217
  function buildInitCommand() {
6781
7218
  const command = new import_commander2.Command("init").option("--force", `Overwrite existing ${CONFIG_DIR} directory`).action((options) => {
6782
7219
  const root = process.cwd();
6783
- const prowlDir = import_node_path15.default.join(root, CONFIG_DIR);
7220
+ const prowlDir = import_node_path16.default.join(root, CONFIG_DIR);
6784
7221
  if (import_node_fs15.default.existsSync(prowlDir) && !options.force) {
6785
7222
  console.error(
6786
7223
  import_chalk4.default.red(
@@ -6791,20 +7228,20 @@ function buildInitCommand() {
6791
7228
  return;
6792
7229
  }
6793
7230
  const packageRoot = getPackageRoot2();
6794
- const examplesDir = import_node_path15.default.join(packageRoot, "examples");
6795
- const exampleConfig = import_node_path15.default.join(examplesDir, "config.yml");
6796
- const exampleHuntsDir = import_node_path15.default.join(examplesDir, "hunts");
7231
+ const examplesDir = import_node_path16.default.join(packageRoot, "examples");
7232
+ const exampleConfig = import_node_path16.default.join(examplesDir, "config.yml");
7233
+ const exampleHuntsDir = import_node_path16.default.join(examplesDir, "hunts");
6797
7234
  if (!import_node_fs15.default.existsSync(exampleConfig) || !import_node_fs15.default.existsSync(exampleHuntsDir)) {
6798
7235
  console.error(import_chalk4.default.red("Examples not found in package. Reinstall prowl-tools."));
6799
7236
  process.exitCode = 1;
6800
7237
  return;
6801
7238
  }
6802
- copyFile(exampleConfig, import_node_path15.default.join(prowlDir, "config.yml"));
7239
+ copyFile(exampleConfig, import_node_path16.default.join(prowlDir, "config.yml"));
6803
7240
  const huntFiles = import_node_fs15.default.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
6804
7241
  for (const huntFile of huntFiles) {
6805
7242
  copyFile(
6806
- import_node_path15.default.join(exampleHuntsDir, huntFile),
6807
- import_node_path15.default.join(prowlDir, "hunts", huntFile)
7243
+ import_node_path16.default.join(exampleHuntsDir, huntFile),
7244
+ import_node_path16.default.join(prowlDir, "hunts", huntFile)
6808
7245
  );
6809
7246
  }
6810
7247
  const gitignore = [
@@ -6818,27 +7255,27 @@ function buildInitCommand() {
6818
7255
  ".env",
6819
7256
  ""
6820
7257
  ].join("\n");
6821
- import_node_fs15.default.writeFileSync(import_node_path15.default.join(prowlDir, ".gitignore"), gitignore);
7258
+ import_node_fs15.default.writeFileSync(import_node_path16.default.join(prowlDir, ".gitignore"), gitignore);
6822
7259
  console.log(welcomeBanner());
6823
7260
  console.log(import_chalk4.default.green(` Initialized ${CONFIG_DIR} directory.`));
6824
7261
  console.log(import_chalk4.default.gray(" Run ") + import_chalk4.default.bold("prowl run hello") + import_chalk4.default.gray(" to get started."));
6825
- console.log(import_chalk4.default.gray(" Browse hunt templates at ") + import_chalk4.default.cyan("https://hub.prowl.tools") + "\n");
7262
+ console.log(import_chalk4.default.gray(" See ") + import_chalk4.default.cyan(`${CONFIG_DIR}/hunts/login-flow.yml`) + import_chalk4.default.gray(" for a fuller example.") + "\n");
6826
7263
  });
6827
7264
  return command;
6828
7265
  }
6829
7266
 
6830
7267
  // src/cli/commands/login.ts
6831
- var import_node_path16 = __toESM(require("path"), 1);
7268
+ var import_node_path17 = __toESM(require("path"), 1);
6832
7269
  var import_node_readline = __toESM(require("readline"), 1);
6833
7270
  var import_chalk5 = __toESM(require("chalk"), 1);
6834
7271
  var import_commander3 = require("commander");
6835
7272
  init_loader();
6836
7273
  function resolvePath2(configDir, inputPath) {
6837
- if (import_node_path16.default.isAbsolute(inputPath)) {
7274
+ if (import_node_path17.default.isAbsolute(inputPath)) {
6838
7275
  return inputPath;
6839
7276
  }
6840
- const projectRoot = import_node_path16.default.dirname(configDir);
6841
- return import_node_path16.default.join(projectRoot, inputPath);
7277
+ const projectRoot = import_node_path17.default.dirname(configDir);
7278
+ return import_node_path17.default.join(projectRoot, inputPath);
6842
7279
  }
6843
7280
  function waitForEnter(prompt) {
6844
7281
  return new Promise((resolve) => {
@@ -6929,14 +7366,15 @@ var import_node_fs16 = __toESM(require("fs"), 1);
6929
7366
  var import_chalk7 = __toESM(require("chalk"), 1);
6930
7367
  var import_commander5 = require("commander");
6931
7368
  init_loader();
7369
+ init_hunt_name();
6932
7370
 
6933
7371
  // src/cli/watch-utils.ts
6934
- var import_node_path17 = __toESM(require("path"), 1);
7372
+ var import_node_path18 = __toESM(require("path"), 1);
6935
7373
  function getWatchTargets(configDir, huntName) {
6936
7374
  return [
6937
- import_node_path17.default.join(configDir, "hunts", `${huntName}.yml`),
6938
- import_node_path17.default.join(configDir, "config.yml"),
6939
- import_node_path17.default.join(configDir, ".env")
7375
+ import_node_path18.default.join(configDir, "hunts", `${huntName}.yml`),
7376
+ import_node_path18.default.join(configDir, "config.yml"),
7377
+ import_node_path18.default.join(configDir, ".env")
6940
7378
  ];
6941
7379
  }
6942
7380
  function createDebouncer(delayMs, fn) {
@@ -6962,7 +7400,11 @@ function createDebouncer(delayMs, fn) {
6962
7400
 
6963
7401
  // src/cli/commands/watch.ts
6964
7402
  function buildWatchCommand() {
6965
- const command = new import_commander5.Command("watch").argument("<hunt-name>", "Hunt name or path (e.g. homepage or admin/users-crud)").option("--url <target>", "Override target URL").option("--headed", "Show browser window").option("--slow-mo <ms>", "Slow down Playwright actions", (value) => Number(value)).option("--trace", "Capture Playwright trace").option("--config <path>", "Custom config path").action(async (huntName, options) => {
7403
+ const command = new import_commander5.Command("watch").argument(
7404
+ "<hunt-name>",
7405
+ "Hunt name or path (e.g. homepage, admin/users-crud, or .prowl/hunts/homepage.yml)"
7406
+ ).option("--url <target>", "Override target URL").option("--headed", "Show browser window").option("--slow-mo <ms>", "Slow down Playwright actions", (value) => Number(value)).option("--trace", "Capture Playwright trace").option("--config <path>", "Custom config path").action(async (huntArg, options) => {
7407
+ const huntName = normalizeHuntName(huntArg);
6966
7408
  const { configDir } = loadConfig(options.config);
6967
7409
  const watchTargets = getWatchTargets(configDir, huntName);
6968
7410
  let running = false;
@@ -7038,12 +7480,12 @@ var import_commander6 = require("commander");
7038
7480
  var import_chalk9 = __toESM(require("chalk"), 1);
7039
7481
 
7040
7482
  // src/runner/suite.ts
7041
- var import_node_path20 = __toESM(require("path"), 1);
7483
+ var import_node_path21 = __toESM(require("path"), 1);
7042
7484
  init_loader();
7043
7485
 
7044
7486
  // src/reporter/ci-summary.ts
7045
7487
  var import_node_fs17 = __toESM(require("fs"), 1);
7046
- var import_node_path18 = __toESM(require("path"), 1);
7488
+ var import_node_path19 = __toESM(require("path"), 1);
7047
7489
  var import_chalk8 = __toESM(require("chalk"), 1);
7048
7490
  function countCiResults(results) {
7049
7491
  return {
@@ -7108,7 +7550,7 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
7108
7550
  ...clusters.length > 0 ? { clusters } : {}
7109
7551
  };
7110
7552
  import_node_fs17.default.mkdirSync(ciRunDir, { recursive: true });
7111
- const filePath = import_node_path18.default.join(ciRunDir, "ci-result.json");
7553
+ const filePath = import_node_path19.default.join(ciRunDir, "ci-result.json");
7112
7554
  import_node_fs17.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
7113
7555
  return filePath;
7114
7556
  }
@@ -7244,7 +7686,7 @@ function clusterFailures(failures) {
7244
7686
 
7245
7687
  // src/backlog/index.ts
7246
7688
  var import_node_fs18 = __toESM(require("fs"), 1);
7247
- var import_node_path19 = __toESM(require("path"), 1);
7689
+ var import_node_path20 = __toESM(require("path"), 1);
7248
7690
 
7249
7691
  // src/backlog/parse.ts
7250
7692
  var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
@@ -7350,7 +7792,7 @@ function buildFailure(hunt) {
7350
7792
  if (!hunt.runDir) return failure;
7351
7793
  let run;
7352
7794
  try {
7353
- const resultJson = readFileOrEmpty(import_node_path19.default.join(hunt.runDir, "result.json"));
7795
+ const resultJson = readFileOrEmpty(import_node_path20.default.join(hunt.runDir, "result.json"));
7354
7796
  if (!resultJson) return failure;
7355
7797
  run = JSON.parse(resultJson);
7356
7798
  } catch (error) {
@@ -7379,8 +7821,8 @@ function extractFailures(suiteResult) {
7379
7821
  }
7380
7822
  function updateBacklogFromSuite(suiteResult, options = {}) {
7381
7823
  const projectRoot = options.projectRoot ?? process.cwd();
7382
- const backlogPath = options.backlogPath ?? import_node_path19.default.join(projectRoot, "docs", "backlog.md");
7383
- const resolvedPath = options.resolvedPath ?? import_node_path19.default.join(projectRoot, "docs", "resolved.md");
7824
+ const backlogPath = options.backlogPath ?? import_node_path20.default.join(projectRoot, "docs", "backlog.md");
7825
+ const resolvedPath = options.resolvedPath ?? import_node_path20.default.join(projectRoot, "docs", "resolved.md");
7384
7826
  const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
7385
7827
  const summary = { created: [], regressions: [], skipped: [], backlogPath };
7386
7828
  const failures = extractFailures(suiteResult);
@@ -7412,7 +7854,7 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
7412
7854
  }
7413
7855
  }
7414
7856
  if (ticketsToAdd.length > 0) {
7415
- import_node_fs18.default.mkdirSync(import_node_path19.default.dirname(backlogPath), { recursive: true });
7857
+ import_node_fs18.default.mkdirSync(import_node_path20.default.dirname(backlogPath), { recursive: true });
7416
7858
  import_node_fs18.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
7417
7859
  }
7418
7860
  return summary;
@@ -7574,7 +8016,7 @@ async function runSuite(options = {}) {
7574
8016
  const clusters = clusterFailures(
7575
8017
  extractFailures({ result: { hunts: results }, resultPath: null })
7576
8018
  ).filter((cluster) => cluster.count > 1);
7577
- const ciRunDir = import_node_path20.default.join(configDir, "runs", timestamp("ci"));
8019
+ const ciRunDir = import_node_path21.default.join(configDir, "runs", timestamp("ci"));
7578
8020
  const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
7579
8021
  const { passed, failed, skipped } = countCiResults(results);
7580
8022
  return {
@@ -7693,7 +8135,7 @@ function buildCiCommand() {
7693
8135
 
7694
8136
  // src/cli/commands/update-baselines.ts
7695
8137
  var import_node_fs19 = __toESM(require("fs"), 1);
7696
- var import_node_path21 = __toESM(require("path"), 1);
8138
+ var import_node_path22 = __toESM(require("path"), 1);
7697
8139
  var import_commander7 = require("commander");
7698
8140
  var import_chalk10 = __toESM(require("chalk"), 1);
7699
8141
  init_loader();
@@ -7701,27 +8143,27 @@ function buildUpdateBaselinesCommand() {
7701
8143
  const command = new import_commander7.Command("update-baselines").description("Accept current screenshots as new visual regression baselines").option("--run <dir>", "Specific run directory to use").option("--name <name>", "Update only a specific baseline by name").option("--config <path>", "Custom config path").option("-y, --yes", "Skip confirmation prompt").action(async (options) => {
7702
8144
  try {
7703
8145
  const { configDir } = loadConfig(options.config);
7704
- const baselinesDir = import_node_path21.default.join(configDir, "baselines");
8146
+ const baselinesDir = import_node_path22.default.join(configDir, "baselines");
7705
8147
  import_node_fs19.default.mkdirSync(baselinesDir, { recursive: true });
7706
8148
  let runDir;
7707
8149
  if (options.run) {
7708
- runDir = import_node_path21.default.isAbsolute(options.run) ? options.run : import_node_path21.default.resolve(options.run);
8150
+ runDir = import_node_path22.default.isAbsolute(options.run) ? options.run : import_node_path22.default.resolve(options.run);
7709
8151
  } else {
7710
- const runsDir = import_node_path21.default.join(configDir, "runs");
8152
+ const runsDir = import_node_path22.default.join(configDir, "runs");
7711
8153
  if (!import_node_fs19.default.existsSync(runsDir)) {
7712
8154
  console.error(import_chalk10.default.red(" No runs directory found. Run a hunt first."));
7713
8155
  process.exitCode = 1;
7714
8156
  return;
7715
8157
  }
7716
- const entries = import_node_fs19.default.readdirSync(runsDir).filter((e) => import_node_fs19.default.statSync(import_node_path21.default.join(runsDir, e)).isDirectory()).sort().reverse();
8158
+ const entries = import_node_fs19.default.readdirSync(runsDir).filter((e) => import_node_fs19.default.statSync(import_node_path22.default.join(runsDir, e)).isDirectory()).sort().reverse();
7717
8159
  if (entries.length === 0) {
7718
8160
  console.error(import_chalk10.default.red(" No run directories found. Run a hunt first."));
7719
8161
  process.exitCode = 1;
7720
8162
  return;
7721
8163
  }
7722
- runDir = import_node_path21.default.join(runsDir, entries[0]);
8164
+ runDir = import_node_path22.default.join(runsDir, entries[0]);
7723
8165
  }
7724
- const screenshotsDir = import_node_path21.default.join(runDir, "screenshots");
8166
+ const screenshotsDir = import_node_path22.default.join(runDir, "screenshots");
7725
8167
  if (!import_node_fs19.default.existsSync(screenshotsDir)) {
7726
8168
  console.error(import_chalk10.default.red(` No screenshots found in ${runDir}`));
7727
8169
  process.exitCode = 1;
@@ -7741,8 +8183,8 @@ function buildUpdateBaselinesCommand() {
7741
8183
  let updated = 0;
7742
8184
  for (const file of filtered) {
7743
8185
  const baselineName = file.replace("-current.png", ".png");
7744
- const sourcePath = import_node_path21.default.join(screenshotsDir, file);
7745
- const destPath = import_node_path21.default.join(baselinesDir, baselineName);
8186
+ const sourcePath = import_node_path22.default.join(screenshotsDir, file);
8187
+ const destPath = import_node_path22.default.join(baselinesDir, baselineName);
7746
8188
  const exists = import_node_fs19.default.existsSync(destPath);
7747
8189
  import_node_fs19.default.copyFileSync(sourcePath, destPath);
7748
8190
  updated++;
@@ -8572,7 +9014,7 @@ function buildAnalyzeCommand() {
8572
9014
 
8573
9015
  // src/cli/commands/generate.ts
8574
9016
  var import_node_fs20 = __toESM(require("fs"), 1);
8575
- var import_node_path22 = __toESM(require("path"), 1);
9017
+ var import_node_path23 = __toESM(require("path"), 1);
8576
9018
  var import_commander9 = require("commander");
8577
9019
  var import_chalk12 = __toESM(require("chalk"), 1);
8578
9020
  var import_ora = __toESM(require("ora"), 1);
@@ -8767,12 +9209,12 @@ function buildGenerateCommand() {
8767
9209
  const result = loadConfig2(options.config);
8768
9210
  configDir = result.configDir;
8769
9211
  } catch {
8770
- configDir = import_node_path22.default.join(process.cwd(), ".prowl");
9212
+ configDir = import_node_path23.default.join(process.cwd(), ".prowl");
8771
9213
  }
8772
- const huntsDir = import_node_path22.default.join(configDir, "hunts");
9214
+ const huntsDir = import_node_path23.default.join(configDir, "hunts");
8773
9215
  import_node_fs20.default.mkdirSync(huntsDir, { recursive: true });
8774
9216
  const fileName = options.output.endsWith(".yml") ? options.output : `${options.output}.yml`;
8775
- const filePath = import_node_path22.default.join(huntsDir, fileName);
9217
+ const filePath = import_node_path23.default.join(huntsDir, fileName);
8776
9218
  import_node_fs20.default.writeFileSync(filePath, yamlStr + "\n", "utf-8");
8777
9219
  console.log(import_chalk12.default.green(` Saved to ${filePath}`));
8778
9220
  } else {
@@ -8797,14 +9239,19 @@ function buildGenerateCommand() {
8797
9239
  var import_commander10 = require("commander");
8798
9240
  var import_chalk13 = __toESM(require("chalk"), 1);
8799
9241
  init_loader();
9242
+ init_hunt_name();
8800
9243
  function buildHistoryCommand() {
8801
- const command = new import_commander10.Command("history").argument("<hunt-name>", "Hunt name or path (e.g. homepage or admin/users-crud)").description("Show run history for a hunt").option("--config <path>", "Custom config path").option("--limit <n>", "Show the last N runs (default: 20)", (value) => {
9244
+ const command = new import_commander10.Command("history").argument(
9245
+ "<hunt-name>",
9246
+ "Hunt name or path (e.g. homepage, admin/users-crud, or .prowl/hunts/homepage.yml)"
9247
+ ).description("Show run history for a hunt").option("--config <path>", "Custom config path").option("--limit <n>", "Show the last N runs (default: 20)", (value) => {
8802
9248
  const n = Number(value);
8803
9249
  if (!Number.isInteger(n) || n < 1) {
8804
9250
  throw new Error("--limit must be a positive integer");
8805
9251
  }
8806
9252
  return n;
8807
- }).option("--json", "Output as JSON").action((huntName, options) => {
9253
+ }).option("--json", "Output as JSON").action((huntArg, options) => {
9254
+ const huntName = normalizeHuntName(huntArg);
8808
9255
  try {
8809
9256
  const { configDir } = loadConfig(options.config);
8810
9257
  const entries = readHuntHistory(configDir, huntName);
@@ -8970,7 +9417,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
8970
9417
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
8971
9418
 
8972
9419
  // src/mcp/tools.ts
8973
- var import_node_path23 = __toESM(require("path"), 1);
9420
+ var import_node_path24 = __toESM(require("path"), 1);
8974
9421
  init_loader();
8975
9422
  function listHuntsTool(configPath) {
8976
9423
  const { configDir } = loadConfig(configPath);
@@ -8978,7 +9425,7 @@ function listHuntsTool(configPath) {
8978
9425
  }
8979
9426
  async function runSuiteTool(args = {}, options = {}) {
8980
9427
  const { configPath: resolvedConfigPath, configDir, config } = loadConfig(options.configPath);
8981
- const projectRoot = options.projectRoot ?? import_node_path23.default.dirname(configDir);
9428
+ const projectRoot = options.projectRoot ?? import_node_path24.default.dirname(configDir);
8982
9429
  const suite = await runSuite({
8983
9430
  configPath: resolvedConfigPath,
8984
9431
  includeTags: args.includeTags,
@@ -8987,8 +9434,8 @@ async function runSuiteTool(args = {}, options = {}) {
8987
9434
  });
8988
9435
  const bugLogCfg = config.bugLog ?? {};
8989
9436
  const logBugs = args.logBugs ?? bugLogCfg.enabled ?? true;
8990
- const backlogPath = bugLogCfg.backlogPath ? import_node_path23.default.resolve(projectRoot, bugLogCfg.backlogPath) : void 0;
8991
- const resolvedPath = bugLogCfg.resolvedPath ? import_node_path23.default.resolve(projectRoot, bugLogCfg.resolvedPath) : backlogPath ? import_node_path23.default.join(import_node_path23.default.dirname(backlogPath), "resolved.md") : void 0;
9437
+ const backlogPath = bugLogCfg.backlogPath ? import_node_path24.default.resolve(projectRoot, bugLogCfg.backlogPath) : void 0;
9438
+ const resolvedPath = bugLogCfg.resolvedPath ? import_node_path24.default.resolve(projectRoot, bugLogCfg.resolvedPath) : backlogPath ? import_node_path24.default.join(import_node_path24.default.dirname(backlogPath), "resolved.md") : void 0;
8992
9439
  const bugs = logBugs ? updateBacklogFromSuite(suite, { projectRoot, backlogPath, resolvedPath }) : { created: [], regressions: [], skipped: [], backlogPath: null };
8993
9440
  const { status, totalHunts, passed, failed, skipped } = suite.result;
8994
9441
  return {
@@ -9016,8 +9463,8 @@ async function runHuntTool(args, configPath) {
9016
9463
 
9017
9464
  // src/mcp/projects.ts
9018
9465
  var import_node_fs21 = __toESM(require("fs"), 1);
9019
- var import_node_os3 = __toESM(require("os"), 1);
9020
- var import_node_path24 = __toESM(require("path"), 1);
9466
+ var import_node_os5 = __toESM(require("os"), 1);
9467
+ var import_node_path25 = __toESM(require("path"), 1);
9021
9468
  var import_yaml3 = __toESM(require("yaml"), 1);
9022
9469
  var import_zod2 = require("zod");
9023
9470
  var projectEntrySchema = import_zod2.z.object({
@@ -9029,25 +9476,25 @@ var projectRegistrySchema = import_zod2.z.object({
9029
9476
  projects: import_zod2.z.record(import_zod2.z.string().min(1), projectEntrySchema)
9030
9477
  }).strict();
9031
9478
  function defaultRegistryPath() {
9032
- return import_node_path24.default.join(import_node_os3.default.homedir(), ".prowl", "projects.yml");
9479
+ return import_node_path25.default.join(import_node_os5.default.homedir(), ".prowl", "projects.yml");
9033
9480
  }
9034
9481
  function legacyRegistryPath() {
9035
- return import_node_path24.default.join(import_node_os3.default.homedir(), ".prowlqa", "projects.yml");
9482
+ return import_node_path25.default.join(import_node_os5.default.homedir(), ".prowlqa", "projects.yml");
9036
9483
  }
9037
9484
  function resolveProjectConfigPath(root) {
9038
- const preferred = import_node_path24.default.join(root, ".prowl", "config.yml");
9485
+ const preferred = import_node_path25.default.join(root, ".prowl", "config.yml");
9039
9486
  if (import_node_fs21.default.existsSync(preferred)) return preferred;
9040
- const legacy = import_node_path24.default.join(root, ".prowlqa", "config.yml");
9487
+ const legacy = import_node_path25.default.join(root, ".prowlqa", "config.yml");
9041
9488
  if (import_node_fs21.default.existsSync(legacy)) return legacy;
9042
9489
  return preferred;
9043
9490
  }
9044
9491
  function resolveRegistryRelativePath(registry, inputPath) {
9045
- return import_node_path24.default.isAbsolute(inputPath) ? inputPath : import_node_path24.default.resolve(import_node_path24.default.dirname(registry.registryPath), inputPath);
9492
+ return import_node_path25.default.isAbsolute(inputPath) ? inputPath : import_node_path25.default.resolve(import_node_path25.default.dirname(registry.registryPath), inputPath);
9046
9493
  }
9047
9494
  function resolveRegistryPath(explicitPath) {
9048
- if (explicitPath) return import_node_path24.default.resolve(explicitPath);
9495
+ if (explicitPath) return import_node_path25.default.resolve(explicitPath);
9049
9496
  const envPath = process.env.PROWL_PROJECTS ?? process.env.PROWLQA_PROJECTS;
9050
- if (envPath) return import_node_path24.default.resolve(envPath);
9497
+ if (envPath) return import_node_path25.default.resolve(envPath);
9051
9498
  const fallback = defaultRegistryPath();
9052
9499
  if (import_node_fs21.default.existsSync(fallback)) return fallback;
9053
9500
  const legacy = legacyRegistryPath();
@@ -9194,10 +9641,377 @@ function buildMcpCommand() {
9194
9641
  });
9195
9642
  }
9196
9643
 
9644
+ // src/cli/commands/macdriver.ts
9645
+ var import_chalk15 = __toESM(require("chalk"), 1);
9646
+ var import_commander13 = require("commander");
9647
+
9648
+ // src/browser/macdriver-install.ts
9649
+ var import_node_child_process7 = require("child_process");
9650
+ var import_node_crypto3 = require("crypto");
9651
+ var import_node_fs22 = __toESM(require("fs"), 1);
9652
+ var import_node_os6 = __toESM(require("os"), 1);
9653
+ var import_node_path26 = __toESM(require("path"), 1);
9654
+ var import_node_util = require("util");
9655
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process7.execFile);
9656
+ function parseChecksumFile(text) {
9657
+ const token = text.trim().split(/\s+/, 1)[0] ?? "";
9658
+ const digest = token.toLowerCase();
9659
+ if (!/^[0-9a-f]{64}$/.test(digest)) {
9660
+ throw new Error(`Malformed .sha256 checksum file (expected a 64-char hex digest, got: ${text.trim().slice(0, 80)})`);
9661
+ }
9662
+ return digest;
9663
+ }
9664
+ function sha256Hex(bytes) {
9665
+ return (0, import_node_crypto3.createHash)("sha256").update(bytes).digest("hex");
9666
+ }
9667
+ var commandRunner = async (file, args) => execFileAsync(file, args);
9668
+ function commandOutput(result) {
9669
+ return [result.stdout, result.stderr].filter((value) => value !== void 0).map((value) => value.toString()).join("\n").trim();
9670
+ }
9671
+ function commandErrorDetail(error) {
9672
+ const err = error;
9673
+ return [err.stderr, err.stdout, err.message].filter((value) => value !== void 0 && value !== "").map((value) => value.toString()).join("\n").trim();
9674
+ }
9675
+ async function runRequiredCommand(run, binaryPath, command, args, label) {
9676
+ try {
9677
+ return await run(command, args);
9678
+ } catch (error) {
9679
+ const detail = commandErrorDetail(error);
9680
+ throw new Error(`${label} failed for ${binaryPath}${detail ? `: ${detail}` : ""}`);
9681
+ }
9682
+ }
9683
+ var zipinfoArchiveLister = async (zipPath) => {
9684
+ try {
9685
+ const { stdout } = await execFileAsync("zipinfo", ["-1", zipPath]);
9686
+ return stdout.toString().split(/\r?\n/).filter((entry) => entry.length > 0);
9687
+ } catch (error) {
9688
+ const detail = commandErrorDetail(error);
9689
+ throw new Error(`Failed to inspect release archive ${zipPath} with zipinfo${detail ? `: ${detail}` : ""}`);
9690
+ }
9691
+ };
9692
+ function normalizeArchiveEntryName(entry) {
9693
+ if (entry.length === 0 || entry !== entry.trim() || entry.includes("\0") || entry.includes("\\")) {
9694
+ throw new Error(`Unsafe path in prowl-macdriver release archive: ${JSON.stringify(entry)}`);
9695
+ }
9696
+ if (entry.endsWith("/")) {
9697
+ throw new Error(`Unexpected directory in prowl-macdriver release archive: ${entry}`);
9698
+ }
9699
+ if (import_node_path26.default.posix.isAbsolute(entry)) {
9700
+ throw new Error(`Unsafe absolute path in prowl-macdriver release archive: ${entry}`);
9701
+ }
9702
+ const parts = entry.split("/");
9703
+ if (parts.some((part) => part === "" || part === "." || part === "..")) {
9704
+ throw new Error(`Unsafe path in prowl-macdriver release archive: ${entry}`);
9705
+ }
9706
+ return entry;
9707
+ }
9708
+ function validateMacdriverArchiveEntries(entries) {
9709
+ const normalized = entries.map(normalizeArchiveEntryName);
9710
+ if (normalized.length !== 1 || normalized[0] !== HELPER_BINARY) {
9711
+ const shown = normalized.length > 0 ? normalized.join(", ") : "(empty archive)";
9712
+ throw new Error(
9713
+ `Unexpected prowl-macdriver release archive contents: ${shown}. Expected exactly "${HELPER_BINARY}" at the archive root.`
9714
+ );
9715
+ }
9716
+ }
9717
+ var dittoExtractor = async (zipPath, destDir) => {
9718
+ await execFileAsync("ditto", ["-x", "-k", zipPath, destDir]);
9719
+ };
9720
+ function parseCodesignDetails(text) {
9721
+ const details = { identifier: null, authorities: [], teamIdentifier: null };
9722
+ for (const line of text.split(/\r?\n/)) {
9723
+ const trimmed = line.trim();
9724
+ if (trimmed.startsWith("Identifier=")) {
9725
+ details.identifier = trimmed.slice("Identifier=".length);
9726
+ } else if (trimmed.startsWith("Authority=")) {
9727
+ details.authorities.push(trimmed.slice("Authority=".length));
9728
+ } else if (trimmed.startsWith("TeamIdentifier=")) {
9729
+ details.teamIdentifier = trimmed.slice("TeamIdentifier=".length);
9730
+ }
9731
+ }
9732
+ return details;
9733
+ }
9734
+ function validateCodesignDetails(details, binaryPath) {
9735
+ if (details.identifier !== MACDRIVER_SIGNING_IDENTIFIER) {
9736
+ throw new Error(
9737
+ `codesign verification failed for ${binaryPath}: expected identifier "${MACDRIVER_SIGNING_IDENTIFIER}", got "${details.identifier ?? "missing"}"`
9738
+ );
9739
+ }
9740
+ const developerIdAuthority = details.authorities.find(
9741
+ (authority) => authority.startsWith(`${MACDRIVER_SIGNING_AUTHORITY_PREFIX} (`)
9742
+ );
9743
+ const authorityTeamId = developerIdAuthority?.match(/\(([A-Z0-9]{10})\)$/)?.[1] ?? null;
9744
+ if (!developerIdAuthority || !authorityTeamId) {
9745
+ const shown = details.authorities.length > 0 ? details.authorities.join(" / ") : "missing";
9746
+ throw new Error(
9747
+ `codesign verification failed for ${binaryPath}: expected ${MACDRIVER_SIGNING_AUTHORITY_PREFIX} signer, got ${shown}`
9748
+ );
9749
+ }
9750
+ if (!details.teamIdentifier) {
9751
+ throw new Error(`codesign verification failed for ${binaryPath}: missing TeamIdentifier`);
9752
+ }
9753
+ if (details.teamIdentifier !== authorityTeamId) {
9754
+ throw new Error(
9755
+ `codesign verification failed for ${binaryPath}: TeamIdentifier ${details.teamIdentifier} does not match Developer ID authority team ${authorityTeamId}`
9756
+ );
9757
+ }
9758
+ }
9759
+ async function verifyMacdriverSignature(binaryPath, run = commandRunner) {
9760
+ await runRequiredCommand(run, binaryPath, "codesign", ["--verify", "--strict", binaryPath], "codesign verification");
9761
+ const display = await runRequiredCommand(
9762
+ run,
9763
+ binaryPath,
9764
+ "codesign",
9765
+ ["--display", "--verbose=4", binaryPath],
9766
+ "codesign detail inspection"
9767
+ );
9768
+ validateCodesignDetails(parseCodesignDetails(commandOutput(display)), binaryPath);
9769
+ await runRequiredCommand(
9770
+ run,
9771
+ binaryPath,
9772
+ "spctl",
9773
+ ["--assess", "--type", "execute", "--verbose=4", binaryPath],
9774
+ "spctl assessment"
9775
+ );
9776
+ }
9777
+ var codesignVerifier = async (binaryPath) => {
9778
+ await verifyMacdriverSignature(binaryPath);
9779
+ };
9780
+ async function downloadAndVerify(options = {}) {
9781
+ const version = options.version ?? MACDRIVER_VERSION;
9782
+ const fetchImpl = options.fetchImpl ?? fetch;
9783
+ const assetName = macdriverAssetName(version);
9784
+ const zipUrl = macdriverAssetUrl(assetName, version);
9785
+ const sumUrl = macdriverAssetUrl(macdriverChecksumName(version), version);
9786
+ const [zipRes, sumRes] = await Promise.all([
9787
+ fetchImpl(zipUrl, { redirect: "follow" }),
9788
+ fetchImpl(sumUrl, { redirect: "follow" })
9789
+ ]);
9790
+ if (zipRes.status === 404 || sumRes.status === 404) {
9791
+ throw new Error(
9792
+ `No published prowl-macdriver release for ${macdriverReleaseTag(version)} yet.
9793
+ The signed binary is cut by the maintainer; until then build from source:
9794
+ cd macdriver && swift build -c release`
9795
+ );
9796
+ }
9797
+ if (!zipRes.ok) {
9798
+ throw new Error(`Failed to download ${assetName} (HTTP ${zipRes.status}) from ${zipUrl}`);
9799
+ }
9800
+ if (!sumRes.ok) {
9801
+ throw new Error(`Failed to download the checksum (HTTP ${sumRes.status}) from ${sumUrl}`);
9802
+ }
9803
+ const zipBytes = Buffer.from(await zipRes.arrayBuffer());
9804
+ const expected = parseChecksumFile(await sumRes.text());
9805
+ const actual = sha256Hex(zipBytes);
9806
+ if (actual !== expected) {
9807
+ throw new Error(
9808
+ `Checksum mismatch for ${assetName}.
9809
+ expected: ${expected}
9810
+ actual: ${actual}
9811
+ The download was rejected and discarded; re-run the install, and report it if it repeats.`
9812
+ );
9813
+ }
9814
+ return zipBytes;
9815
+ }
9816
+ async function installMacdriver(options = {}) {
9817
+ const version = validateMacdriverVersion(options.version ?? MACDRIVER_VERSION);
9818
+ const homedir = options.homedir ?? import_node_os6.default.homedir();
9819
+ const listArchiveEntries = options.listArchiveEntries ?? zipinfoArchiveLister;
9820
+ const extract = options.extract ?? dittoExtractor;
9821
+ const verifySignature = options.verifySignature ?? codesignVerifier;
9822
+ const installRoot = macdriverInstallRoot(homedir);
9823
+ const versionDir = macdriverVersionDir(version, homedir);
9824
+ const binaryPath = macdriverInstalledBinary(version, homedir);
9825
+ if (!options.force && import_node_fs22.default.existsSync(binaryPath)) {
9826
+ return { version, binaryPath, alreadyInstalled: true };
9827
+ }
9828
+ const zipBytes = await downloadAndVerify({ version, fetchImpl: options.fetchImpl });
9829
+ import_node_fs22.default.mkdirSync(installRoot, { recursive: true });
9830
+ const stagingDir = import_node_fs22.default.mkdtempSync(import_node_path26.default.join(installRoot, `.tmp-${version}-`));
9831
+ const extractDir = import_node_path26.default.join(stagingDir, "extract");
9832
+ const zipPath = import_node_path26.default.join(stagingDir, macdriverAssetName(version));
9833
+ try {
9834
+ import_node_fs22.default.mkdirSync(extractDir);
9835
+ import_node_fs22.default.writeFileSync(zipPath, zipBytes);
9836
+ validateMacdriverArchiveEntries(await listArchiveEntries(zipPath));
9837
+ await extract(zipPath, extractDir);
9838
+ const stagedBinaryPath = import_node_path26.default.join(extractDir, HELPER_BINARY);
9839
+ assertExtractedHelper(extractDir, stagedBinaryPath);
9840
+ import_node_fs22.default.chmodSync(stagedBinaryPath, 493);
9841
+ await verifySignature(stagedBinaryPath);
9842
+ replaceVersionDir(versionDir, extractDir, installRoot);
9843
+ } finally {
9844
+ import_node_fs22.default.rmSync(stagingDir, { recursive: true, force: true });
9845
+ }
9846
+ return { version, binaryPath, alreadyInstalled: false };
9847
+ }
9848
+ function assertExtractedHelper(extractDir, binaryPath) {
9849
+ const entries = import_node_fs22.default.readdirSync(extractDir);
9850
+ if (!entries.includes(HELPER_BINARY)) {
9851
+ throw new Error(`The release archive did not contain a "${HELPER_BINARY}" binary.`);
9852
+ }
9853
+ if (entries.length !== 1 || entries[0] !== HELPER_BINARY) {
9854
+ const shown = entries.length > 0 ? entries.join(", ") : "(empty directory)";
9855
+ throw new Error(
9856
+ `Unexpected extracted prowl-macdriver archive contents: ${shown}. Expected exactly "${HELPER_BINARY}".`
9857
+ );
9858
+ }
9859
+ let stat = null;
9860
+ try {
9861
+ stat = import_node_fs22.default.lstatSync(binaryPath);
9862
+ } catch {
9863
+ }
9864
+ if (!stat?.isFile()) {
9865
+ throw new Error(`The release archive "${HELPER_BINARY}" entry is not a regular file.`);
9866
+ }
9867
+ }
9868
+ function replaceVersionDir(versionDir, stagedVersionDir, installRoot) {
9869
+ const backupDir = import_node_path26.default.join(installRoot, `.previous-${import_node_path26.default.basename(versionDir)}-${process.pid}-${Date.now()}`);
9870
+ let backedUp = false;
9871
+ try {
9872
+ if (import_node_fs22.default.existsSync(versionDir)) {
9873
+ import_node_fs22.default.renameSync(versionDir, backupDir);
9874
+ backedUp = true;
9875
+ }
9876
+ import_node_fs22.default.renameSync(stagedVersionDir, versionDir);
9877
+ if (backedUp) {
9878
+ import_node_fs22.default.rmSync(backupDir, { recursive: true, force: true });
9879
+ }
9880
+ } catch (error) {
9881
+ if (backedUp && !import_node_fs22.default.existsSync(versionDir) && import_node_fs22.default.existsSync(backupDir)) {
9882
+ import_node_fs22.default.renameSync(backupDir, versionDir);
9883
+ }
9884
+ throw error;
9885
+ }
9886
+ }
9887
+ var runVersionProbe = async (binaryPath) => {
9888
+ try {
9889
+ const { stdout } = await execFileAsync(binaryPath, ["version"], { timeout: 5e3 });
9890
+ const match = stdout.match(/prowl-macdriver\s+(\S+)/);
9891
+ return match ? match[1] : stdout.trim() || null;
9892
+ } catch {
9893
+ return null;
9894
+ }
9895
+ };
9896
+ async function collectMacdriverStatus(options = {}) {
9897
+ const env = options.env ?? process.env;
9898
+ const homedir = options.homedir ?? import_node_os6.default.homedir();
9899
+ const probe = options.probe ?? runVersionProbe;
9900
+ let resolved = null;
9901
+ try {
9902
+ const resolvedPath = resolveHelperBinary(env, { homedir });
9903
+ resolved = { path: resolvedPath, source: classifyResolvedSource(resolvedPath, env, homedir) };
9904
+ } catch {
9905
+ resolved = null;
9906
+ }
9907
+ const installed = [];
9908
+ const root = macdriverInstallRoot(homedir);
9909
+ if (import_node_fs22.default.existsSync(root)) {
9910
+ for (const entry of import_node_fs22.default.readdirSync(root, { withFileTypes: true })) {
9911
+ if (!entry.isDirectory()) continue;
9912
+ let binaryPath;
9913
+ try {
9914
+ binaryPath = macdriverInstalledBinary(entry.name, homedir);
9915
+ } catch {
9916
+ continue;
9917
+ }
9918
+ if (import_node_fs22.default.existsSync(binaryPath)) {
9919
+ installed.push({ version: entry.name, binaryPath });
9920
+ }
9921
+ }
9922
+ installed.sort((a, b) => a.version.localeCompare(b.version));
9923
+ }
9924
+ const probedVersion = resolved ? await probe(resolved.path) : null;
9925
+ return { resolved, pinnedVersion: MACDRIVER_VERSION, installed, probedVersion };
9926
+ }
9927
+ function classifyResolvedSource(resolvedPath, env, homedir) {
9928
+ if (env.PROWL_MACDRIVER_BIN && resolvedPath === env.PROWL_MACDRIVER_BIN) {
9929
+ return "env";
9930
+ }
9931
+ if (resolvedPath.startsWith(macdriverInstallRoot(homedir) + import_node_path26.default.sep)) {
9932
+ return "user-install";
9933
+ }
9934
+ return "source-build";
9935
+ }
9936
+ function tccGuidance() {
9937
+ 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.";
9938
+ }
9939
+
9940
+ // src/cli/commands/macdriver.ts
9941
+ function buildInstallCommand() {
9942
+ return new import_commander13.Command("install").description("Download and install the prebuilt, signed prowl-macdriver helper").option("--force", "Reinstall even if the pinned version is already present").action(async (options) => {
9943
+ try {
9944
+ console.log(
9945
+ import_chalk15.default.cyan(
9946
+ `Installing prowl-macdriver ${MACDRIVER_VERSION} (${macdriverReleaseTag(MACDRIVER_VERSION)})\u2026`
9947
+ )
9948
+ );
9949
+ const result = await installMacdriver({ force: options.force });
9950
+ if (result.alreadyInstalled) {
9951
+ console.log(
9952
+ import_chalk15.default.green(`Already installed at ${result.binaryPath}`) + import_chalk15.default.dim(" (use --force to reinstall)")
9953
+ );
9954
+ } else {
9955
+ console.log(import_chalk15.default.green(`Installed prowl-macdriver ${result.version} \u2192 ${result.binaryPath}`));
9956
+ }
9957
+ console.log("");
9958
+ console.log(tccGuidance());
9959
+ } catch (error) {
9960
+ const message = error instanceof Error ? error.message : "Install failed";
9961
+ console.error(import_chalk15.default.red(`Error: ${message}`));
9962
+ process.exitCode = 1;
9963
+ }
9964
+ });
9965
+ }
9966
+ function buildStatusCommand() {
9967
+ return new import_commander13.Command("status").description("Report the resolved prowl-macdriver binary, installed versions, and permissions guidance").action(async () => {
9968
+ try {
9969
+ const status = await collectMacdriverStatus();
9970
+ console.log(import_chalk15.default.bold("prowl-macdriver status"));
9971
+ console.log(` pinned version: ${status.pinnedVersion}`);
9972
+ if (status.resolved) {
9973
+ const label = status.resolved.source === "env" ? "PROWL_MACDRIVER_BIN override" : status.resolved.source === "user-install" ? "user install (~/.prowl/macdriver)" : "repo source build (macdriver/.build)";
9974
+ console.log(` resolved binary: ${status.resolved.path}`);
9975
+ console.log(` resolved via: ${label}`);
9976
+ console.log(
9977
+ ` runs: ${status.probedVersion ? import_chalk15.default.green(`yes (reports ${status.probedVersion})`) : import_chalk15.default.yellow("no (binary did not respond to `version`)")}`
9978
+ );
9979
+ } else {
9980
+ console.log(` resolved binary: ${import_chalk15.default.yellow("none")}`);
9981
+ console.log(import_chalk15.default.dim(" search order: PROWL_MACDRIVER_BIN \u2192 ~/.prowl/macdriver \u2192 macdriver/.build"));
9982
+ console.log(import_chalk15.default.cyan(" Run `prowl macdriver install` to install the prebuilt helper."));
9983
+ }
9984
+ if (status.installed.length > 0) {
9985
+ console.log(" installed versions:");
9986
+ for (const entry of status.installed) {
9987
+ const marker = entry.version === status.pinnedVersion ? import_chalk15.default.green(" (pinned)") : "";
9988
+ console.log(` - ${entry.version}${marker} ${import_chalk15.default.dim(entry.binaryPath)}`);
9989
+ }
9990
+ } else {
9991
+ console.log(" installed versions: none");
9992
+ }
9993
+ console.log("");
9994
+ console.log(tccGuidance());
9995
+ } catch (error) {
9996
+ const message = error instanceof Error ? error.message : "Status failed";
9997
+ console.error(import_chalk15.default.red(`Error: ${message}`));
9998
+ process.exitCode = 1;
9999
+ }
10000
+ });
10001
+ }
10002
+ function buildMacdriverCommand() {
10003
+ const command = new import_commander13.Command("macdriver").description(
10004
+ "Manage the macOS helper binary (prowl-macdriver) for the macOS target"
10005
+ );
10006
+ command.addCommand(buildInstallCommand());
10007
+ command.addCommand(buildStatusCommand());
10008
+ return command;
10009
+ }
10010
+
9197
10011
  // src/cli/program.ts
9198
10012
  var CLI_VERSION = package_default.version;
9199
10013
  function buildProgram() {
9200
- const program2 = new import_commander13.Command();
10014
+ const program2 = new import_commander14.Command();
9201
10015
  program2.name("prowl").description("CLI-first QA testing tool for deterministic Playwright flows").version(CLI_VERSION);
9202
10016
  program2.addCommand(buildRunCommand());
9203
10017
  program2.addCommand(buildCiCommand());
@@ -9211,6 +10025,7 @@ function buildProgram() {
9211
10025
  program2.addCommand(buildHistoryCommand());
9212
10026
  program2.addCommand(buildFlakyCommand());
9213
10027
  program2.addCommand(buildMcpCommand());
10028
+ program2.addCommand(buildMacdriverCommand());
9214
10029
  return program2;
9215
10030
  }
9216
10031