prowl-tools 0.1.7 → 0.1.9

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
@@ -398,575 +398,136 @@ var init_schema = __esm({
398
398
  }
399
399
  });
400
400
 
401
- // src/config/loader.ts
402
- var loader_exports = {};
403
- __export(loader_exports, {
404
- CONFIG_DIR: () => CONFIG_DIR,
405
- LEGACY_CONFIG_DIR: () => LEGACY_CONFIG_DIR,
406
- ensureAllowedDomain: () => ensureAllowedDomain,
407
- findConfigPath: () => findConfigPath,
408
- listHunts: () => listHunts,
409
- loadConfig: () => loadConfig,
410
- loadHunt: () => loadHunt,
411
- loadHuntMeta: () => loadHuntMeta,
412
- loadHuntTags: () => loadHuntTags,
413
- resolveViewport: () => resolveViewport,
414
- warnLegacyConfigDir: () => warnLegacyConfigDir
415
- });
416
- function warnLegacyConfigDir() {
417
- 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
+ }
418
408
  return;
419
409
  }
420
- legacyDirWarned = true;
421
- console.warn(
422
- 'Warning: the ".prowlqa/" config directory is deprecated; rename it to ".prowl/". Support for ".prowlqa/" will be removed in a future release.'
423
- );
424
- }
425
- function findConfigPath(startDir) {
426
- let current = startDir;
427
- while (current) {
428
- for (const dir of [CONFIG_DIR, LEGACY_CONFIG_DIR]) {
429
- const candidate = import_node_path.default.join(current, dir, "config.yml");
430
- if (import_node_fs.default.existsSync(candidate)) {
431
- return candidate;
432
- }
410
+ if (Array.isArray(input)) {
411
+ for (const item of input) {
412
+ collectInterpolatedValues(item, vars, values);
433
413
  }
434
- const parent = import_node_path.default.dirname(current);
435
- if (parent === current) {
436
- 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);
437
420
  }
438
- current = parent;
439
421
  }
440
- return null;
441
422
  }
442
- function resolveViewport(value) {
443
- if (value === void 0) {
444
- return DEFAULT_CONFIG.browser.viewport;
445
- }
446
- if (typeof value === "string") {
447
- const preset = VIEWPORT_PRESETS[value];
448
- if (!preset) {
449
- 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}`);
450
429
  }
451
- return preset;
452
- }
453
- return value;
430
+ usedVars.push(name);
431
+ return varValue;
432
+ });
433
+ return { value, usedVars };
454
434
  }
455
- function resolveTarget(target) {
456
- const type = target?.type;
457
- if (type === "macos") {
458
- 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)];
459
447
  }
460
- if (type === "android") {
461
- 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];
462
464
  return {
463
- type: "android",
464
- app: androidTarget.app,
465
- ...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
466
- ...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
465
+ [interpolateString(key, vars).value]: interpolateString(value, vars).value
467
466
  };
467
+ };
468
+ if ("navigate" in step) {
469
+ const result = interpolateString(step.navigate, vars);
470
+ return { navigate: result.value };
468
471
  }
469
- if (type === "ios") {
470
- 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
+ }
471
497
  return {
472
- type: "ios",
473
- app: iosTarget.app,
474
- ...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
475
- ...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
498
+ fill: {
499
+ [labelResult.value]: valueResult.value
500
+ }
476
501
  };
477
502
  }
478
- return {
479
- type: "web",
480
- url: target?.url ?? DEFAULT_WEB_URL
481
- };
482
- }
483
- function mergeConfig(partial) {
484
- return {
485
- target: resolveTarget(partial.target),
486
- browser: {
487
- headless: partial.browser?.headless ?? DEFAULT_CONFIG.browser.headless,
488
- slowMo: partial.browser?.slowMo ?? DEFAULT_CONFIG.browser.slowMo,
489
- timeout: partial.browser?.timeout ?? DEFAULT_CONFIG.browser.timeout,
490
- engine: partial.browser?.engine ?? DEFAULT_CONFIG.browser.engine,
491
- channel: partial.browser?.channel,
492
- viewport: resolveViewport(partial.browser?.viewport)
493
- },
494
- artifacts: {
495
- screenshots: partial.artifacts?.screenshots ?? DEFAULT_CONFIG.artifacts.screenshots,
496
- networkHar: partial.artifacts?.networkHar ?? DEFAULT_CONFIG.artifacts.networkHar,
497
- console: partial.artifacts?.console ?? DEFAULT_CONFIG.artifacts.console,
498
- junit: partial.artifacts?.junit ?? DEFAULT_CONFIG.artifacts.junit
499
- },
500
- assertions: {
501
- noConsoleErrors: partial.assertions?.noConsoleErrors ?? DEFAULT_CONFIG.assertions.noConsoleErrors,
502
- noNetworkErrors: partial.assertions?.noNetworkErrors ?? DEFAULT_CONFIG.assertions.noNetworkErrors,
503
- maxTotalTimeMs: partial.assertions?.maxTotalTimeMs ?? DEFAULT_CONFIG.assertions.maxTotalTimeMs,
504
- networkIgnorePatterns: partial.assertions?.networkIgnorePatterns ?? DEFAULT_CONFIG.assertions.networkIgnorePatterns
505
- },
506
- guardrails: {
507
- maxSteps: partial.guardrails?.maxSteps ?? DEFAULT_CONFIG.guardrails.maxSteps,
508
- allowedDomains: partial.guardrails?.allowedDomains ?? DEFAULT_CONFIG.guardrails.allowedDomains,
509
- allowedApps: partial.guardrails?.allowedApps ?? DEFAULT_CONFIG.guardrails.allowedApps,
510
- forbiddenSelectors: partial.guardrails?.forbiddenSelectors ?? DEFAULT_CONFIG.guardrails.forbiddenSelectors,
511
- selfHealing: partial.guardrails?.selfHealing ?? DEFAULT_CONFIG.guardrails.selfHealing
512
- },
513
- auth: {
514
- storageStatePath: partial.auth?.storageStatePath ?? (partial.auth !== void 0 ? DEFAULT_CONFIG.auth.storageStatePath : void 0)
515
- },
516
- history: {
517
- maxRuns: partial.history?.maxRuns ?? DEFAULT_CONFIG.history.maxRuns
518
- },
519
- bugLog: partial.bugLog,
520
- tracing: partial.tracing,
521
- reliability: partial.reliability
522
- };
523
- }
524
- function ensureAllowedDomain(allowed, urlValue) {
525
- try {
526
- const host = new URL(urlValue).hostname;
527
- if (!allowed.includes(host)) {
528
- 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);
529
507
  }
530
- } catch {
531
- return allowed;
508
+ return { type: valueResult.value };
532
509
  }
533
- return allowed;
534
- }
535
- function loadConfig(configPath) {
536
- const resolvedPath = configPath ? import_node_path.default.resolve(configPath) : findConfigPath(process.cwd());
537
- if (!resolvedPath) {
538
- 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 } };
539
514
  }
540
- if (!import_node_fs.default.existsSync(resolvedPath)) {
541
- throw new Error(`Config file not found at ${resolvedPath}`);
515
+ if ("select" in step) {
516
+ return { select: interpolateSinglePair(step.select) };
542
517
  }
543
- const configDir = import_node_path.default.dirname(resolvedPath);
544
- if (import_node_path.default.basename(configDir) === LEGACY_CONFIG_DIR) {
545
- 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 } };
546
522
  }
547
- import_dotenv.default.config({ path: import_node_path.default.join(configDir, ".env"), override: false });
548
- const raw = import_node_fs.default.readFileSync(resolvedPath, "utf-8");
549
- const parsed = import_yaml.default.parse(raw) ?? {};
550
- const validated = configSchema.parse(parsed);
551
- const config = mergeConfig(validated);
552
- if (config.target.type === "web") {
553
- config.guardrails.allowedDomains = ensureAllowedDomain(
554
- config.guardrails.allowedDomains,
555
- config.target.url
556
- );
523
+ if ("onDialog" in step) {
524
+ return { onDialog: { action: step.onDialog.action } };
557
525
  }
558
- return { config, configPath: resolvedPath, configDir };
559
- }
560
- function loadHunt(huntName, configDir) {
561
- assertValidHuntName(huntName);
562
- const huntPath = import_node_path.default.join(configDir, "hunts", `${huntName}.yml`);
563
- if (!import_node_fs.default.existsSync(huntPath)) {
564
- throw new Error(`Hunt file not found: ${huntPath}`);
565
- }
566
- const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
567
- const parsed = import_yaml.default.parse(raw) ?? {};
568
- const validated = huntSchema.parse(parsed);
569
- return validated;
570
- }
571
- function loadHuntTags(huntName, configDir) {
572
- assertValidHuntName(huntName);
573
- const huntPath = import_node_path.default.join(configDir, "hunts", `${huntName}.yml`);
574
- if (!import_node_fs.default.existsSync(huntPath)) {
575
- return [];
576
- }
577
- const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
578
- const parsed = import_yaml.default.parse(raw) ?? {};
579
- return Array.isArray(parsed.tags) ? parsed.tags : [];
580
- }
581
- function loadHuntMeta(huntName, configDir) {
582
- assertValidHuntName(huntName);
583
- const huntPath = import_node_path.default.join(configDir, "hunts", `${huntName}.yml`);
584
- if (!import_node_fs.default.existsSync(huntPath)) {
585
- return { tags: [] };
586
- }
587
- const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
588
- const parsed = import_yaml.default.parse(raw) ?? {};
589
- return {
590
- description: typeof parsed.description === "string" ? parsed.description : void 0,
591
- tags: Array.isArray(parsed.tags) ? parsed.tags : []
592
- };
593
- }
594
- function listHunts(configDir) {
595
- const huntsDir = import_node_path.default.join(configDir, "hunts");
596
- if (!import_node_fs.default.existsSync(huntsDir)) {
597
- return [];
598
- }
599
- const stats = import_node_fs.default.statSync(huntsDir);
600
- if (!stats.isDirectory()) {
601
- throw new Error(`Hunts path is not a directory: ${huntsDir}`);
602
- }
603
- const results = [];
604
- function scanDir(dir) {
605
- const entries = import_node_fs.default.readdirSync(dir, { withFileTypes: true });
606
- for (const entry of entries) {
607
- if (entry.isFile() && entry.name.endsWith(".yml")) {
608
- const fullPath = import_node_path.default.join(dir, entry.name);
609
- const relative = import_node_path.default.relative(huntsDir, fullPath);
610
- results.push(relative.replace(/\.yml$/, ""));
611
- } else if (entry.isDirectory()) {
612
- scanDir(import_node_path.default.join(dir, entry.name));
613
- }
614
- }
615
- }
616
- scanDir(huntsDir);
617
- return results.sort((a, b) => a.localeCompare(b));
618
- }
619
- var import_node_fs, import_node_path, import_yaml, import_dotenv, DEFAULT_WEB_URL, DEFAULT_CONFIG, CONFIG_DIR, LEGACY_CONFIG_DIR, legacyDirWarned, VIEWPORT_PRESETS;
620
- var init_loader = __esm({
621
- "src/config/loader.ts"() {
622
- "use strict";
623
- import_node_fs = __toESM(require("fs"), 1);
624
- import_node_path = __toESM(require("path"), 1);
625
- import_yaml = __toESM(require("yaml"), 1);
626
- import_dotenv = __toESM(require("dotenv"), 1);
627
- init_schema();
628
- init_hunt_name();
629
- DEFAULT_WEB_URL = "http://localhost:3000";
630
- DEFAULT_CONFIG = {
631
- target: {
632
- type: "web",
633
- url: DEFAULT_WEB_URL
634
- },
635
- browser: {
636
- headless: true,
637
- slowMo: 0,
638
- timeout: 3e4,
639
- engine: "chromium",
640
- viewport: { width: 1280, height: 720 }
641
- },
642
- artifacts: {
643
- screenshots: "on-failure",
644
- networkHar: false,
645
- console: true,
646
- junit: false
647
- },
648
- assertions: {
649
- noConsoleErrors: true,
650
- noNetworkErrors: true,
651
- maxTotalTimeMs: 3e4,
652
- networkIgnorePatterns: []
653
- },
654
- guardrails: {
655
- maxSteps: 50,
656
- allowedDomains: ["localhost", "127.0.0.1", "0.0.0.0"],
657
- allowedApps: [],
658
- forbiddenSelectors: ["[data-danger]", ".delete-btn"],
659
- selfHealing: false
660
- },
661
- auth: {
662
- storageStatePath: ".prowl/auth-state.json"
663
- },
664
- history: {
665
- maxRuns: 100
666
- }
667
- };
668
- CONFIG_DIR = ".prowl";
669
- LEGACY_CONFIG_DIR = ".prowlqa";
670
- legacyDirWarned = false;
671
- VIEWPORT_PRESETS = {
672
- mobile: { width: 375, height: 812 },
673
- tablet: { width: 768, height: 1024 },
674
- desktop: { width: 1280, height: 720 }
675
- };
676
- }
677
- });
678
-
679
- // src/runner/visual.ts
680
- var visual_exports = {};
681
- __export(visual_exports, {
682
- compareScreenshots: () => compareScreenshots,
683
- ensureBaselineDir: () => ensureBaselineDir
684
- });
685
- async function compareScreenshots(baselinePath, currentPath, diffPath, threshold) {
686
- const baselineData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(baselinePath));
687
- const currentData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(currentPath));
688
- const { width, height } = baselineData;
689
- if (currentData.width !== width || currentData.height !== height) {
690
- const diff2 = new import_pngjs.PNG({ width, height });
691
- import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
692
- return {
693
- match: false,
694
- diffPercentage: 1,
695
- diffImagePath: diffPath
696
- };
697
- }
698
- const diff = new import_pngjs.PNG({ width, height });
699
- const diffPixels = (0, import_pixelmatch.default)(
700
- baselineData.data,
701
- currentData.data,
702
- diff.data,
703
- width,
704
- height,
705
- { threshold: 0.1 }
706
- );
707
- const totalPixels = width * height;
708
- const diffPercentage = diffPixels / totalPixels;
709
- import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
710
- return {
711
- match: diffPercentage <= threshold,
712
- diffPercentage,
713
- diffImagePath: diffPath
714
- };
715
- }
716
- function ensureBaselineDir(configDir) {
717
- const baselineDir = import_node_path9.default.join(configDir, "baselines");
718
- import_node_fs8.default.mkdirSync(baselineDir, { recursive: true });
719
- return baselineDir;
720
- }
721
- var import_node_fs8, import_node_path9, import_pngjs, import_pixelmatch;
722
- var init_visual = __esm({
723
- "src/runner/visual.ts"() {
724
- "use strict";
725
- import_node_fs8 = __toESM(require("fs"), 1);
726
- import_node_path9 = __toESM(require("path"), 1);
727
- import_pngjs = require("pngjs");
728
- import_pixelmatch = __toESM(require("pixelmatch"), 1);
729
- }
730
- });
731
-
732
- // src/cli/program.ts
733
- var import_commander14 = require("commander");
734
-
735
- // package.json
736
- var package_default = {
737
- name: "prowl-tools",
738
- version: "0.1.7",
739
- description: "E2E testing for native macOS apps and web apps from declarative YAML hunts.",
740
- type: "module",
741
- license: "Apache-2.0",
742
- author: "Prowl Tools",
743
- repository: {
744
- type: "git",
745
- url: "https://github.com/prowl-tools/prowl.git"
746
- },
747
- homepage: "https://prowl.tools",
748
- bugs: {
749
- url: "https://github.com/prowl-tools/prowl/issues"
750
- },
751
- keywords: [
752
- "testing",
753
- "qa",
754
- "e2e",
755
- "macos",
756
- "desktop-testing",
757
- "accessibility",
758
- "playwright",
759
- "yaml",
760
- "browser-testing",
761
- "automation",
762
- "web-testing",
763
- "cli"
764
- ],
765
- main: "dist/lib.cjs",
766
- module: "dist/lib.js",
767
- types: "dist/lib.d.ts",
768
- exports: {
769
- ".": {
770
- import: {
771
- types: "./dist/lib.d.ts",
772
- default: "./dist/lib.js"
773
- },
774
- require: {
775
- types: "./dist/lib.d.cts",
776
- default: "./dist/lib.cjs"
777
- }
778
- }
779
- },
780
- bin: {
781
- prowl: "dist/index.js"
782
- },
783
- files: [
784
- "dist",
785
- "examples",
786
- "LICENSE",
787
- "README.md",
788
- "NOTICE"
789
- ],
790
- engines: {
791
- node: ">=20.0.0"
792
- },
793
- scripts: {
794
- build: "tsup",
795
- lint: "eslint .",
796
- test: "vitest run",
797
- "test:watch": "vitest"
798
- },
799
- dependencies: {
800
- "@modelcontextprotocol/sdk": "^1.29.0",
801
- chalk: "^5.3.0",
802
- commander: "^12.1.0",
803
- dotenv: "^16.6.1",
804
- ora: "^8.1.1",
805
- pixelmatch: "^7.1.0",
806
- playwright: "^1.50.1",
807
- pngjs: "^7.0.0",
808
- yaml: "^2.6.1",
809
- zod: "^3.23.8"
810
- },
811
- optionalDependencies: {
812
- "appium-uiautomator2-server": "10.6.2",
813
- "appium-webdriveragent": "16.4.0"
814
- },
815
- devDependencies: {
816
- "@types/node": "^22.13.1",
817
- "@types/pngjs": "^6.0.5",
818
- "@typescript-eslint/eslint-plugin": "^7.18.0",
819
- "@typescript-eslint/parser": "^7.18.0",
820
- eslint: "^8.57.1",
821
- tsup: "^8.3.5",
822
- typescript: "^5.7.3",
823
- vitest: "^2.1.8"
824
- }
825
- };
826
-
827
- // src/cli/commands/run.ts
828
- var import_commander = require("commander");
829
- var import_chalk3 = __toESM(require("chalk"), 1);
830
-
831
- // src/runner/index.ts
832
- var import_node_fs14 = __toESM(require("fs"), 1);
833
- var import_node_path15 = __toESM(require("path"), 1);
834
- init_loader();
835
-
836
- // src/config/interpolate.ts
837
- var import_node_crypto = __toESM(require("crypto"), 1);
838
- var VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
839
- function collectInterpolatedValues(input, vars, values) {
840
- if (typeof input === "string") {
841
- for (const match of input.matchAll(VAR_PATTERN)) {
842
- const varValue = vars[match[1]];
843
- if (varValue) values.add(varValue);
844
- }
845
- return;
846
- }
847
- if (Array.isArray(input)) {
848
- for (const item of input) {
849
- collectInterpolatedValues(item, vars, values);
850
- }
851
- return;
852
- }
853
- if (input && typeof input === "object") {
854
- for (const [key, value] of Object.entries(input)) {
855
- collectInterpolatedValues(key, vars, values);
856
- collectInterpolatedValues(value, vars, values);
857
- }
858
- }
859
- }
860
- function interpolateString(input, vars) {
861
- const usedVars = [];
862
- const value = input.replace(VAR_PATTERN, (_, name) => {
863
- const varValue = vars[name];
864
- if (varValue === void 0) {
865
- throw new Error(`Missing variable: ${name}`);
866
- }
867
- usedVars.push(name);
868
- return varValue;
869
- });
870
- return { value, usedVars };
871
- }
872
- var RANDOM_FIRST_NAMES = ["Alex", "Jordan", "Morgan", "Taylor", "Casey", "Riley", "Quinn", "Avery"];
873
- var RANDOM_LAST_NAMES = ["Smith", "Johnson", "Brown", "Davis", "Wilson", "Clark", "Hall", "Young"];
874
- function generateRandomVars(randomSource) {
875
- const random = randomSource?.random ?? Math.random;
876
- const randomBytes = randomSource?.randomBytes ?? import_node_crypto.default.randomBytes;
877
- const randomUUID = randomSource?.randomUUID ?? import_node_crypto.default.randomUUID;
878
- const hex = randomBytes(4).toString("hex");
879
- const firstIndex = Math.floor(random() * RANDOM_FIRST_NAMES.length);
880
- const lastIndex = Math.floor(random() * RANDOM_LAST_NAMES.length);
881
- const num2 = Math.floor(random() * 9e3) + 1e3;
882
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
883
- let text = "";
884
- for (let i = 0; i < 8; i++) {
885
- text += chars[Math.floor(random() * chars.length)];
886
- }
887
- return {
888
- RANDOM_EMAIL: `prowl_${hex}@test.com`,
889
- RANDOM_NAME: `${RANDOM_FIRST_NAMES[firstIndex]} ${RANDOM_LAST_NAMES[lastIndex]}`,
890
- RANDOM_NUMBER: String(num2),
891
- RANDOM_UUID: randomUUID(),
892
- RANDOM_TEXT: text
893
- };
894
- }
895
- function interpolateStep(step, vars, stepPath2, redacted) {
896
- const isExplicitFill = (value) => typeof value.selector === "string" && typeof value.value === "string";
897
- const interpolateSinglePair = (record) => {
898
- const entries = Object.entries(record);
899
- if (entries.length !== 1) {
900
- throw new Error("Shorthand step expects exactly one key-value pair");
901
- }
902
- const [key, value] = entries[0];
903
- return {
904
- [interpolateString(key, vars).value]: interpolateString(value, vars).value
905
- };
906
- };
907
- if ("navigate" in step) {
908
- const result = interpolateString(step.navigate, vars);
909
- return { navigate: result.value };
910
- }
911
- if ("click" in step) {
912
- if (typeof step.click === "string") {
913
- return { click: interpolateString(step.click, vars).value };
914
- }
915
- const result = interpolateString(step.click.selector, vars);
916
- return { click: { selector: result.value } };
917
- }
918
- if ("fill" in step) {
919
- if (isExplicitFill(step.fill)) {
920
- const selectorResult = interpolateString(step.fill.selector, vars);
921
- const valueResult2 = interpolateString(step.fill.value, vars);
922
- if (valueResult2.usedVars.length > 0) {
923
- redacted.add(stepPath2);
924
- }
925
- return { fill: { selector: selectorResult.value, value: valueResult2.value } };
926
- }
927
- const [rawLabel, rawValue] = Object.entries(step.fill)[0] ?? [];
928
- if (rawLabel === void 0 || rawValue === void 0) {
929
- throw new Error("Shorthand fill expects exactly one key-value pair");
930
- }
931
- const labelResult = interpolateString(rawLabel, vars);
932
- const valueResult = interpolateString(rawValue, vars);
933
- if (valueResult.usedVars.length > 0) {
934
- redacted.add(stepPath2);
935
- }
936
- return {
937
- fill: {
938
- [labelResult.value]: valueResult.value
939
- }
940
- };
941
- }
942
- if ("type" in step) {
943
- const valueResult = interpolateString(step.type, vars);
944
- if (valueResult.usedVars.length > 0) {
945
- redacted.add(stepPath2);
946
- }
947
- return { type: valueResult.value };
948
- }
949
- if ("selectOption" in step) {
950
- const selectorResult = interpolateString(step.selectOption.selector, vars);
951
- const valueResult = interpolateString(step.selectOption.value, vars);
952
- return { selectOption: { selector: selectorResult.value, value: valueResult.value } };
953
- }
954
- if ("select" in step) {
955
- return { select: interpolateSinglePair(step.select) };
956
- }
957
- if ("press" in step) {
958
- const selectorResult = interpolateString(step.press.selector, vars);
959
- const keyResult = interpolateString(step.press.key, vars);
960
- return { press: { selector: selectorResult.value, key: keyResult.value } };
961
- }
962
- if ("onDialog" in step) {
963
- return { onDialog: { action: step.onDialog.action } };
964
- }
965
- if ("setInputFiles" in step) {
966
- const selectorResult = interpolateString(step.setInputFiles.selector, vars);
967
- const rawFiles = step.setInputFiles.files;
968
- const files = Array.isArray(rawFiles) ? rawFiles.map((f) => interpolateString(f, vars).value) : interpolateString(rawFiles, vars).value;
969
- return { setInputFiles: { selector: selectorResult.value, files } };
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 } };
970
531
  }
971
532
  if ("runHunt" in step) {
972
533
  if (typeof step.runHunt === "string") {
@@ -1114,95 +675,562 @@ function interpolateStep(step, vars, stepPath2, redacted) {
1114
675
  }
1115
676
  };
1116
677
  }
1117
- if ("runScript" in step) {
1118
- return {
1119
- runScript: { file: interpolateString(step.runScript.file, vars).value }
1120
- };
678
+ if ("runScript" in step) {
679
+ return {
680
+ runScript: { file: interpolateString(step.runScript.file, vars).value }
681
+ };
682
+ }
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
+ };
690
+ }
691
+ if ("assertWithAI" in step) {
692
+ return {
693
+ assertWithAI: interpolateString(step.assertWithAI, vars).value
694
+ };
695
+ }
696
+ if ("copyText" in step) {
697
+ return {
698
+ copyText: {
699
+ selector: interpolateString(step.copyText.selector, vars).value,
700
+ as: step.copyText.as
701
+ }
702
+ };
703
+ }
704
+ if ("waitForDownload" in step) {
705
+ if (step.waitForDownload === null) {
706
+ return { waitForDownload: null };
707
+ }
708
+ return {
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 } : {}
712
+ }
713
+ };
714
+ }
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
+ }
810
+ }
811
+ const parent = import_node_path.default.dirname(current);
812
+ if (parent === current) {
813
+ break;
814
+ }
815
+ current = parent;
816
+ }
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.`);
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 };
1121
836
  }
1122
- if ("assertScreenshot" in step) {
837
+ if (type === "android") {
838
+ const androidTarget = target;
1123
839
  return {
1124
- assertScreenshot: {
1125
- name: interpolateString(step.assertScreenshot.name, vars).value,
1126
- ...step.assertScreenshot.threshold !== void 0 ? { threshold: step.assertScreenshot.threshold } : {}
1127
- }
840
+ type: "android",
841
+ app: androidTarget.app,
842
+ ...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
843
+ ...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
1128
844
  };
1129
845
  }
1130
- if ("assertWithAI" in step) {
846
+ if (type === "ios") {
847
+ const iosTarget = target;
1131
848
  return {
1132
- assertWithAI: interpolateString(step.assertWithAI, vars).value
849
+ type: "ios",
850
+ app: iosTarget.app,
851
+ ...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
852
+ ...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
1133
853
  };
1134
854
  }
1135
- if ("copyText" in step) {
1136
- return {
1137
- copyText: {
1138
- selector: interpolateString(step.copyText.selector, vars).value,
1139
- as: step.copyText.as
1140
- }
1141
- };
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;
1142
909
  }
1143
- if ("waitForDownload" in step) {
1144
- if (step.waitForDownload === null) {
1145
- return { waitForDownload: null };
910
+ if (Array.isArray(value)) {
911
+ return value.map((item) => interpolateConfigStrings(item, vars));
912
+ }
913
+ if (value && typeof value === "object") {
914
+ return Object.fromEntries(
915
+ Object.entries(value).map(([key, item]) => [key, interpolateConfigStrings(item, vars)])
916
+ );
917
+ }
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];
1146
925
  }
1147
- return {
1148
- waitForDownload: {
1149
- ...step.waitForDownload.filename !== void 0 ? { filename: interpolateString(step.waitForDownload.filename, vars).value } : {},
1150
- ...step.waitForDownload.timeout !== void 0 ? { timeout: step.waitForDownload.timeout } : {}
1151
- }
1152
- };
926
+ } catch {
927
+ return allowed;
1153
928
  }
1154
- return step;
929
+ return allowed;
1155
930
  }
1156
- function interpolateAssertion(assertion, vars) {
1157
- if ("selectorExists" in assertion) {
1158
- return { selectorExists: interpolateString(assertion.selectorExists, vars).value };
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.");
1159
935
  }
1160
- if ("selectorNotExists" in assertion) {
1161
- return { selectorNotExists: interpolateString(assertion.selectorNotExists, vars).value };
936
+ if (!import_node_fs.default.existsSync(resolvedPath)) {
937
+ throw new Error(`Config file not found at ${resolvedPath}`);
1162
938
  }
1163
- if ("urlIncludes" in assertion) {
1164
- return { urlIncludes: interpolateString(assertion.urlIncludes, vars).value };
939
+ const configDir = import_node_path.default.dirname(resolvedPath);
940
+ if (import_node_path.default.basename(configDir) === LEGACY_CONFIG_DIR) {
941
+ warnLegacyConfigDir();
1165
942
  }
1166
- if ("urlEquals" in assertion) {
1167
- return { urlEquals: interpolateString(assertion.urlEquals, vars).value };
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
953
+ );
1168
954
  }
1169
- if ("noConsoleErrors" in assertion) {
1170
- return { noConsoleErrors: assertion.noConsoleErrors };
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}`);
1171
962
  }
1172
- if ("noNetworkErrors" in assertion) {
1173
- return { noNetworkErrors: assertion.noNetworkErrors };
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 [];
973
+ }
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: [] };
983
+ }
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 [];
995
+ }
996
+ const stats = import_node_fs.default.statSync(huntsDir);
997
+ if (!stats.isDirectory()) {
998
+ throw new Error(`Hunts path is not a directory: ${huntsDir}`);
999
+ }
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));
1010
+ }
1011
+ }
1012
+ }
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
1064
+ }
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
+ };
1174
1074
  }
1175
- return assertion;
1176
- }
1177
- function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
1178
- const redactedFillSteps = /* @__PURE__ */ new Set();
1179
- const envVars = Object.fromEntries(
1180
- Object.entries(env).filter(([, value]) => value !== void 0)
1181
- );
1182
- const baseVars = { ...randomVars, ...envVars };
1183
- const resolvedHuntVars = {};
1184
- for (const [key, value] of Object.entries(hunt.vars ?? {})) {
1185
- resolvedHuntVars[key] = interpolateString(value, baseVars).value;
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));
1090
+ return {
1091
+ match: false,
1092
+ diffPercentage: 1,
1093
+ diffImagePath: diffPath
1094
+ };
1186
1095
  }
1187
- const vars = { ...baseVars, ...resolvedHuntVars };
1188
- const redactionValues = /* @__PURE__ */ new Set();
1189
- collectInterpolatedValues(hunt.steps, vars, redactionValues);
1190
- collectInterpolatedValues(hunt.assertions, vars, redactionValues);
1191
- const steps = hunt.steps.map(
1192
- (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 }
1193
1104
  );
1194
- 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));
1195
1108
  return {
1196
- hunt: {
1197
- ...hunt,
1198
- steps,
1199
- assertions
1200
- },
1201
- redactedFillSteps,
1202
- randomVars,
1203
- redactionValues: [...redactionValues]
1109
+ match: diffPercentage <= threshold,
1110
+ diffPercentage,
1111
+ diffImagePath: diffPath
1204
1112
  };
1205
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_commander15 = require("commander");
1132
+
1133
+ // package.json
1134
+ var package_default = {
1135
+ name: "prowl-tools",
1136
+ version: "0.1.9",
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();
1206
1234
 
1207
1235
  // src/config/target.ts
1208
1236
  var import_node_child_process = require("child_process");
@@ -1220,10 +1248,17 @@ var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
1220
1248
  "select",
1221
1249
  "selectOption",
1222
1250
  "setInputFiles",
1223
- "waitForDownload",
1224
- "scroll"
1225
- // directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
1251
+ "waitForDownload"
1226
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
+ }
1227
1262
  function webOnlyReason(step) {
1228
1263
  for (const type of WEB_ONLY_STEP_TYPES) {
1229
1264
  if (type in step) {
@@ -1259,6 +1294,14 @@ function assertStepsSupportedByTarget(steps, target) {
1259
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.).`
1260
1295
  );
1261
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
+ }
1262
1305
  if ("if" in step) {
1263
1306
  assertStepsSupportedByTarget(step.if.then, target);
1264
1307
  if (step.if.else) {
@@ -1542,6 +1585,16 @@ function createPlaywrightDriver(page) {
1542
1585
  async hover(selector) {
1543
1586
  await page.locator(selector).hover();
1544
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
+ },
1545
1598
  async scrollIntoView(selector) {
1546
1599
  await page.locator(selector).scrollIntoViewIfNeeded();
1547
1600
  },
@@ -1736,6 +1789,9 @@ function createMacDriver(client, options = {}) {
1736
1789
  async hover(selector) {
1737
1790
  await query("hover", selector);
1738
1791
  },
1792
+ scroll() {
1793
+ return rejectUnsupported("scroll");
1794
+ },
1739
1795
  async scrollIntoView(selector) {
1740
1796
  await query("scrollTo", selector);
1741
1797
  },
@@ -1894,10 +1950,15 @@ ${macdriverBuildInstructions()}`
1894
1950
  ${macdriverBuildInstructions()}`);
1895
1951
  }
1896
1952
  var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
1953
+ function errorMessage(error) {
1954
+ return error instanceof Error ? error.message : String(error);
1955
+ }
1897
1956
  var SpawnMacHelperClient = class {
1898
1957
  child;
1899
1958
  pending = /* @__PURE__ */ new Map();
1900
1959
  requestTimeoutMs;
1960
+ onEvent;
1961
+ onEventError;
1901
1962
  stdoutBuffer = "";
1902
1963
  stderrBuffer = "";
1903
1964
  nextId = 1;
@@ -1905,6 +1966,9 @@ var SpawnMacHelperClient = class {
1905
1966
  terminalError;
1906
1967
  constructor(binaryPath, options = {}) {
1907
1968
  this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1969
+ this.onEvent = options.onEvent;
1970
+ this.onEventError = options.onEventError ?? ((message) => process.stderr.write(`${message}
1971
+ `));
1908
1972
  this.child = (0, import_node_child_process2.spawn)(binaryPath, ["serve"], { stdio: ["pipe", "pipe", "pipe"] });
1909
1973
  this.child.stdout?.setEncoding("utf-8");
1910
1974
  this.child.stderr?.setEncoding("utf-8");
@@ -1941,6 +2005,10 @@ var SpawnMacHelperClient = class {
1941
2005
  } catch {
1942
2006
  return;
1943
2007
  }
2008
+ if (typeof message.event === "string" && typeof message.id !== "number") {
2009
+ this.handleEvent(message);
2010
+ return;
2011
+ }
1944
2012
  const id = typeof message.id === "number" ? message.id : void 0;
1945
2013
  if (id === void 0) {
1946
2014
  return;
@@ -1957,6 +2025,18 @@ var SpawnMacHelperClient = class {
1957
2025
  pending.reject(new Error(typeof message.error === "string" ? message.error : "prowl-macdriver error"));
1958
2026
  }
1959
2027
  }
2028
+ handleEvent(event) {
2029
+ try {
2030
+ this.onEvent?.(event);
2031
+ } catch (error) {
2032
+ try {
2033
+ this.onEventError(
2034
+ `prowl-macdriver event sink failed for event "${event.event}": ${errorMessage(error)}`
2035
+ );
2036
+ } catch {
2037
+ }
2038
+ }
2039
+ }
1960
2040
  failAll(error) {
1961
2041
  for (const pending of this.pending.values()) {
1962
2042
  clearTimeout(pending.timer);
@@ -2269,6 +2349,101 @@ function rankNativeSelectors(fields) {
2269
2349
  return selectors;
2270
2350
  }
2271
2351
 
2352
+ // src/browser/touch-gestures.ts
2353
+ var DEFAULT_SWIPE_FRACTION = 0.75;
2354
+ var SCROLL_TO_PROBE_SWIPE_FRACTION = 0.6;
2355
+ var MAX_SWIPE_FRACTION = 0.9;
2356
+ var SWIPE_HOLD_MS = 100;
2357
+ var SWIPE_MOVE_DURATION_MS = 300;
2358
+ var SCROLL_TO_SWEEP_DEPTH = 10;
2359
+ var SCROLL_TO_PROBE_DIRECTIONS = [
2360
+ ...Array.from({ length: SCROLL_TO_SWEEP_DEPTH }, () => "down"),
2361
+ ...Array.from({ length: SCROLL_TO_SWEEP_DEPTH * 2 }, () => "up")
2362
+ ];
2363
+ var MAX_SCROLL_TO_SWIPES = SCROLL_TO_PROBE_DIRECTIONS.length;
2364
+ var OPPOSITE_SWIPE_DIRECTIONS = {
2365
+ up: "down",
2366
+ down: "up",
2367
+ left: "right",
2368
+ right: "left"
2369
+ };
2370
+ async function probeScrollIntoView({
2371
+ isVisible,
2372
+ swipe,
2373
+ directions = SCROLL_TO_PROBE_DIRECTIONS
2374
+ }) {
2375
+ if (await isVisible()) {
2376
+ return true;
2377
+ }
2378
+ for (const direction of directions) {
2379
+ await swipe(direction);
2380
+ if (await isVisible()) {
2381
+ return true;
2382
+ }
2383
+ }
2384
+ return false;
2385
+ }
2386
+ function toScreenSize(value, source) {
2387
+ const record = value ?? {};
2388
+ const width = Number(record.width);
2389
+ const height = Number(record.height);
2390
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
2391
+ throw new Error(`${source} did not return a usable screen size`);
2392
+ }
2393
+ return { width, height };
2394
+ }
2395
+ function isVertical(direction) {
2396
+ return direction === "up" || direction === "down";
2397
+ }
2398
+ function swipeDistanceFor(direction, size, amount) {
2399
+ if (amount !== void 0 && !Number.isFinite(amount)) {
2400
+ throw new Error("scroll amount must be a finite number");
2401
+ }
2402
+ const axis = isVertical(direction) ? size.height : size.width;
2403
+ const requested = amount === void 0 ? axis * DEFAULT_SWIPE_FRACTION : Math.abs(amount);
2404
+ const max = axis * MAX_SWIPE_FRACTION;
2405
+ return Math.max(1, Math.round(Math.min(requested, max)));
2406
+ }
2407
+ function scrollToProbeDistanceFor(direction, size) {
2408
+ const axis = isVertical(direction) ? size.height : size.width;
2409
+ return Math.max(1, Math.round(axis * SCROLL_TO_PROBE_SWIPE_FRACTION));
2410
+ }
2411
+ function swipeEndpoints(direction, size, distance) {
2412
+ const cx = Math.round(size.width / 2);
2413
+ const cy = Math.round(size.height / 2);
2414
+ const half = Math.round(distance / 2);
2415
+ switch (direction) {
2416
+ case "down":
2417
+ return { start: { x: cx, y: cy + half }, end: { x: cx, y: cy - half } };
2418
+ case "up":
2419
+ return { start: { x: cx, y: cy - half }, end: { x: cx, y: cy + half } };
2420
+ case "right":
2421
+ return { start: { x: cx + half, y: cy }, end: { x: cx - half, y: cy } };
2422
+ case "left":
2423
+ return { start: { x: cx - half, y: cy }, end: { x: cx + half, y: cy } };
2424
+ }
2425
+ }
2426
+ function buildSwipeActions(start, end) {
2427
+ return {
2428
+ type: "pointer",
2429
+ id: "finger1",
2430
+ parameters: { pointerType: "touch" },
2431
+ actions: [
2432
+ { type: "pointerMove", duration: 0, x: start.x, y: start.y, origin: "viewport" },
2433
+ { type: "pointerDown", button: 0 },
2434
+ { type: "pause", duration: SWIPE_HOLD_MS },
2435
+ { type: "pointerMove", duration: SWIPE_MOVE_DURATION_MS, x: end.x, y: end.y, origin: "viewport" },
2436
+ { type: "pointerUp", button: 0 }
2437
+ ]
2438
+ };
2439
+ }
2440
+ function buildDirectionalSwipe(direction, size, amount) {
2441
+ const normalizedDirection = amount !== void 0 && amount < 0 ? OPPOSITE_SWIPE_DIRECTIONS[direction] : direction;
2442
+ const distance = swipeDistanceFor(normalizedDirection, size, amount);
2443
+ const { start, end } = swipeEndpoints(normalizedDirection, size, distance);
2444
+ return { actions: buildSwipeActions(start, end), distance, start, end };
2445
+ }
2446
+
2272
2447
  // src/browser/android-driver.ts
2273
2448
  var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
2274
2449
  "query",
@@ -2384,6 +2559,11 @@ function createAndroidDriver(client, options = {}) {
2384
2559
  async function fillSelector(selector, value) {
2385
2560
  await client.setValue(await resolveOne(selector), value);
2386
2561
  }
2562
+ async function swipe(direction, amount, size) {
2563
+ const actualSize = size ?? await client.windowSize();
2564
+ const { actions } = buildDirectionalSwipe(direction, actualSize, amount);
2565
+ await client.performActions(actions);
2566
+ }
2387
2567
  return {
2388
2568
  capabilities: ANDROID_CAPABILITIES,
2389
2569
  // navigation -----------------------------------------------------------
@@ -2421,8 +2601,31 @@ function createAndroidDriver(client, options = {}) {
2421
2601
  hover() {
2422
2602
  return rejectUnsupported("hover");
2423
2603
  },
2424
- scrollIntoView() {
2425
- return rejectUnsupported("scrollTo");
2604
+ // Screen-centred swipe via the W3C actions endpoint (PROWL-080). Direction
2605
+ // semantics match the web step: scrolling "down" reveals lower content, so
2606
+ // the finger drags up. See ./touch-gestures.ts.
2607
+ async scroll(direction, amount) {
2608
+ await swipe(direction, amount);
2609
+ },
2610
+ // Resolve the element, short-circuiting if it is already present in the
2611
+ // hierarchy; otherwise use the shared bounded down/up mobile probe before
2612
+ // failing with a message naming the selector and attempts.
2613
+ async scrollIntoView(selector) {
2614
+ const query = parseAndroidSelector(selector);
2615
+ let probeSize;
2616
+ const found = await probeScrollIntoView({
2617
+ isVisible: async () => (await client.findElements(query)).length > 0,
2618
+ swipe: async (direction) => {
2619
+ probeSize ??= await client.windowSize();
2620
+ await swipe(direction, scrollToProbeDistanceFor(direction, probeSize), probeSize);
2621
+ }
2622
+ });
2623
+ if (found) {
2624
+ return;
2625
+ }
2626
+ throw new Error(
2627
+ `scrollTo: element "${selector}" not visible after ${MAX_SCROLL_TO_SWIPES} scroll attempts on the Android target`
2628
+ );
2426
2629
  },
2427
2630
  setInputFiles() {
2428
2631
  return rejectUnsupported("setInputFiles");
@@ -2745,12 +2948,12 @@ var Uia2Transport = class {
2745
2948
  * {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
2746
2949
  * per-request deadline elapses.
2747
2950
  */
2748
- async request(method, path27, body, timeoutMs) {
2951
+ async request(method, path28, body, timeoutMs) {
2749
2952
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
2750
2953
  const controller = new AbortController();
2751
2954
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
2752
2955
  timer.unref?.();
2753
- const url = `${this.baseUrl}${path27}`;
2956
+ const url = `${this.baseUrl}${path28}`;
2754
2957
  let response;
2755
2958
  try {
2756
2959
  response = await this.fetchImpl(url, {
@@ -2762,7 +2965,7 @@ var Uia2Transport = class {
2762
2965
  } catch (error) {
2763
2966
  if (controller.signal.aborted) {
2764
2967
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
2765
- throw new Error(`uiautomator2 request ${method} ${path27} timed out after ${shown}`);
2968
+ throw new Error(`uiautomator2 request ${method} ${path28} timed out after ${shown}`);
2766
2969
  }
2767
2970
  throw error instanceof Error ? error : new Error(String(error));
2768
2971
  } finally {
@@ -2773,7 +2976,7 @@ var Uia2Transport = class {
2773
2976
  if (!response.ok) {
2774
2977
  const wdError = extractWebdriverError(parsed);
2775
2978
  throw new Uia2HttpError(
2776
- `uiautomator2 ${method} ${path27} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2979
+ `uiautomator2 ${method} ${path28} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2777
2980
  response.status,
2778
2981
  wdError
2779
2982
  );
@@ -2856,10 +3059,10 @@ function sleep(ms) {
2856
3059
  }
2857
3060
  function createUia2AgentClient(transport, sessionId, options = {}) {
2858
3061
  const base = `/session/${sessionId}`;
2859
- async function locate(query, path27) {
3062
+ async function locate(query, path28) {
2860
3063
  return transport.request(
2861
3064
  "POST",
2862
- `${base}${path27}`,
3065
+ `${base}${path28}`,
2863
3066
  androidQueryToLocator(query, { appPackage: options.appPackage })
2864
3067
  );
2865
3068
  }
@@ -2894,6 +3097,13 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
2894
3097
  async pressKeyCode(keyCode) {
2895
3098
  await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
2896
3099
  },
3100
+ async windowSize() {
3101
+ const value = await transport.request("GET", `${base}/window/current/size`);
3102
+ return toScreenSize(value, "uiautomator2 /window/current/size");
3103
+ },
3104
+ async performActions(actions) {
3105
+ await transport.request("POST", `${base}/actions`, { actions: [actions] });
3106
+ },
2897
3107
  async screenshotPng() {
2898
3108
  const value = await transport.request("GET", `${base}/screenshot`);
2899
3109
  if (typeof value !== "string") {
@@ -3038,6 +3248,7 @@ async function launchAndroidSession(options) {
3038
3248
  readyDeadlineMs,
3039
3249
  appPackage: pkg
3040
3250
  });
3251
+ await launchPackage(runner, serial, pkg);
3041
3252
  const driver = createAndroidDriver(client, { appLabel: pkg });
3042
3253
  return { client, driver, package: pkg, serial, teardown };
3043
3254
  } catch (error) {
@@ -3065,6 +3276,7 @@ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
3065
3276
  ]);
3066
3277
  var WAIT_POLL_INTERVAL_MS2 = 250;
3067
3278
  var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
3279
+ var DISPLAYED_CHECK_CONCURRENCY = 4;
3068
3280
  var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
3069
3281
  function escapePredicateArg(value) {
3070
3282
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
@@ -3139,6 +3351,64 @@ function createIosDriver(client, options) {
3139
3351
  async function fillSelector(selector, value) {
3140
3352
  await client.setValue(await resolveOne(selector), value);
3141
3353
  }
3354
+ async function swipe(direction, amount, size) {
3355
+ const actualSize = size ?? await client.windowSize();
3356
+ const { actions } = buildDirectionalSwipe(direction, actualSize, amount);
3357
+ await client.performActions(actions);
3358
+ }
3359
+ async function visibleElementIds(query) {
3360
+ const ids = await client.findElements(query);
3361
+ const visible = Array.from({ length: ids.length }, () => null);
3362
+ let nextIndex = 0;
3363
+ const workerCount = Math.min(DISPLAYED_CHECK_CONCURRENCY, ids.length);
3364
+ await Promise.all(
3365
+ Array.from({ length: workerCount }, async () => {
3366
+ for (; ; ) {
3367
+ const index = nextIndex;
3368
+ nextIndex += 1;
3369
+ if (index >= ids.length) {
3370
+ return;
3371
+ }
3372
+ const id = ids[index];
3373
+ if (await client.isDisplayed(id)) {
3374
+ visible[index] = id;
3375
+ }
3376
+ }
3377
+ })
3378
+ );
3379
+ return visible.filter((id) => id !== null);
3380
+ }
3381
+ async function hasVisibleElement(query) {
3382
+ const ids = await client.findElements(query);
3383
+ if (ids.length === 0) {
3384
+ return false;
3385
+ }
3386
+ if (await client.isDisplayed(ids[0])) {
3387
+ return true;
3388
+ }
3389
+ let found = false;
3390
+ let nextIndex = 1;
3391
+ const workerCount = Math.min(DISPLAYED_CHECK_CONCURRENCY, ids.length - 1);
3392
+ await Promise.all(
3393
+ Array.from({ length: workerCount }, async () => {
3394
+ for (; ; ) {
3395
+ if (found) {
3396
+ return;
3397
+ }
3398
+ const index = nextIndex;
3399
+ nextIndex += 1;
3400
+ if (index >= ids.length) {
3401
+ return;
3402
+ }
3403
+ if (await client.isDisplayed(ids[index])) {
3404
+ found = true;
3405
+ return;
3406
+ }
3407
+ }
3408
+ })
3409
+ );
3410
+ return found;
3411
+ }
3142
3412
  async function pressKey(key) {
3143
3413
  const name = key.trim().toLowerCase();
3144
3414
  if (name === "enter" || name === "return") {
@@ -3170,6 +3440,9 @@ function createIosDriver(client, options) {
3170
3440
  async count(selector) {
3171
3441
  return (await client.findElements(parseIosSelector(selector))).length;
3172
3442
  },
3443
+ async visibleCount(selector) {
3444
+ return (await visibleElementIds(parseIosSelector(selector))).length;
3445
+ },
3173
3446
  async textContent(selector) {
3174
3447
  const id = await client.findElement(parseIosSelector(selector));
3175
3448
  if (id === null) {
@@ -3194,15 +3467,38 @@ function createIosDriver(client, options) {
3194
3467
  hover() {
3195
3468
  return rejectUnsupported("hover");
3196
3469
  },
3197
- scrollIntoView() {
3198
- return rejectUnsupported("scrollTo");
3470
+ // Screen-centred swipe via the W3C actions endpoint (PROWL-080). Direction
3471
+ // semantics match the web step: scrolling "down" reveals lower content, so
3472
+ // the finger drags up. See ./touch-gestures.ts.
3473
+ async scroll(direction, amount) {
3474
+ await swipe(direction, amount);
3475
+ },
3476
+ // Resolve the element, short-circuiting only if WDA reports a matching
3477
+ // element displayed in the viewport; hierarchy-only matches can be offscreen.
3478
+ // Otherwise use the shared bounded down/up mobile probe before failing.
3479
+ async scrollIntoView(selector) {
3480
+ const query = parseIosSelector(selector);
3481
+ let probeSize;
3482
+ const found = await probeScrollIntoView({
3483
+ isVisible: () => hasVisibleElement(query),
3484
+ swipe: async (direction) => {
3485
+ probeSize ??= await client.windowSize();
3486
+ await swipe(direction, scrollToProbeDistanceFor(direction, probeSize), probeSize);
3487
+ }
3488
+ });
3489
+ if (found) {
3490
+ return;
3491
+ }
3492
+ throw new Error(
3493
+ `scrollTo: element "${selector}" not visible after ${MAX_SCROLL_TO_SWIPES} scroll attempts on the iOS target`
3494
+ );
3199
3495
  },
3200
3496
  setInputFiles() {
3201
3497
  return rejectUnsupported("setInputFiles");
3202
3498
  },
3203
3499
  // semantic locators ----------------------------------------------------
3204
3500
  async countByRole(role, name) {
3205
- return (await client.findElements({ by: "role", role, name })).length;
3501
+ return (await visibleElementIds({ by: "role", role, name })).length;
3206
3502
  },
3207
3503
  async clickFirstByRole(role, name) {
3208
3504
  const id = await client.findElement({ by: "role", role, name });
@@ -3212,7 +3508,7 @@ function createIosDriver(client, options) {
3212
3508
  await client.click(id);
3213
3509
  },
3214
3510
  async countByLabel(label) {
3215
- return (await client.findElements({ by: "label", value: label })).length;
3511
+ return (await visibleElementIds({ by: "label", value: label })).length;
3216
3512
  },
3217
3513
  async fillFirstByLabel(label, value) {
3218
3514
  const id = await client.findElement({ by: "label", value: label });
@@ -3230,7 +3526,7 @@ function createIosDriver(client, options) {
3230
3526
  const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
3231
3527
  const deadline = Date.now() + timeoutMs;
3232
3528
  for (; ; ) {
3233
- if ((await client.findElements(query)).length > 0) {
3529
+ if (await hasVisibleElement(query)) {
3234
3530
  return;
3235
3531
  }
3236
3532
  if (Date.now() >= deadline) {
@@ -3567,15 +3863,15 @@ var WdaTransport = class {
3567
3863
  * {@link WdaHttpError} on a non-2xx response, or a timeout error when the
3568
3864
  * per-request deadline elapses.
3569
3865
  */
3570
- async requestFull(method, path27, body, timeoutMs) {
3866
+ async requestFull(method, path28, body, timeoutMs) {
3571
3867
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
3572
3868
  const controller = new AbortController();
3573
3869
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
3574
3870
  timer.unref?.();
3575
- const url = `${this.baseUrl}${path27}`;
3871
+ const url = `${this.baseUrl}${path28}`;
3576
3872
  const timeoutError = () => {
3577
3873
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
3578
- return new Error(`WebDriverAgent request ${method} ${path27} timed out after ${shown}`);
3874
+ return new Error(`WebDriverAgent request ${method} ${path28} timed out after ${shown}`);
3579
3875
  };
3580
3876
  let response;
3581
3877
  try {
@@ -3607,7 +3903,7 @@ var WdaTransport = class {
3607
3903
  if (!response.ok) {
3608
3904
  const wdError = extractWebdriverError2(parsed);
3609
3905
  throw new WdaHttpError(
3610
- `WebDriverAgent ${method} ${path27} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
3906
+ `WebDriverAgent ${method} ${path28} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
3611
3907
  response.status,
3612
3908
  wdError
3613
3909
  );
@@ -3615,8 +3911,8 @@ var WdaTransport = class {
3615
3911
  return parsed;
3616
3912
  }
3617
3913
  /** Like {@link requestFull} but returns just the `value` field. */
3618
- async request(method, path27, body, timeoutMs) {
3619
- const parsed = await this.requestFull(method, path27, body, timeoutMs);
3914
+ async request(method, path28, body, timeoutMs) {
3915
+ const parsed = await this.requestFull(method, path28, body, timeoutMs);
3620
3916
  return parsed?.value;
3621
3917
  }
3622
3918
  };
@@ -3692,8 +3988,8 @@ function sleep2(ms) {
3692
3988
  }
3693
3989
  function createWdaAgentClient(transport, sessionId) {
3694
3990
  const base = `/session/${sessionId}`;
3695
- async function locate(query, path27) {
3696
- return transport.request("POST", `${base}${path27}`, iosQueryToLocator(query));
3991
+ async function locate(query, path28) {
3992
+ return transport.request("POST", `${base}${path28}`, iosQueryToLocator(query));
3697
3993
  }
3698
3994
  return {
3699
3995
  async findElement(query) {
@@ -3723,12 +4019,29 @@ function createWdaAgentClient(transport, sessionId) {
3723
4019
  const value = await transport.request("GET", `${base}/element/${elementId}/text`);
3724
4020
  return typeof value === "string" ? value : value == null ? null : String(value);
3725
4021
  },
4022
+ async isDisplayed(elementId) {
4023
+ try {
4024
+ return await transport.request("GET", `${base}/element/${elementId}/displayed`) === true;
4025
+ } catch (error) {
4026
+ if (isNoSuchElement2(error)) {
4027
+ return false;
4028
+ }
4029
+ throw error;
4030
+ }
4031
+ },
3726
4032
  async sendKeys(keys) {
3727
4033
  await transport.request("POST", `${base}/wda/keys`, { value: keys });
3728
4034
  },
3729
4035
  async homescreen() {
3730
4036
  await transport.request("POST", "/wda/homescreen", {});
3731
4037
  },
4038
+ async windowSize() {
4039
+ const value = await transport.request("GET", `${base}/window/size`);
4040
+ return toScreenSize(value, "WebDriverAgent /window/size");
4041
+ },
4042
+ async performActions(actions) {
4043
+ await transport.request("POST", `${base}/actions`, { actions: [actions] });
4044
+ },
3732
4045
  async source() {
3733
4046
  const value = await transport.request("GET", "/source");
3734
4047
  if (typeof value !== "string") {
@@ -4082,6 +4395,7 @@ async function closeIosSession(session) {
4082
4395
  var import_node_fs9 = __toESM(require("fs"), 1);
4083
4396
  var import_node_path10 = __toESM(require("path"), 1);
4084
4397
  init_loader();
4398
+ init_interpolate();
4085
4399
 
4086
4400
  // src/runner/healing.ts
4087
4401
  var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
@@ -4779,11 +5093,14 @@ function toVisibilitySelector(value) {
4779
5093
  if (looksLikeSelector(value)) return value;
4780
5094
  return textContainsSelector(value);
4781
5095
  }
5096
+ function countVisible(driver, selector) {
5097
+ return driver.visibleCount?.(selector) ?? driver.count(selector);
5098
+ }
4782
5099
  async function runInlineAssert(driver, policy, assertion) {
4783
5100
  if (assertion.visible !== void 0) {
4784
5101
  const selector = toVisibilitySelector(assertion.visible);
4785
5102
  policy.assertAllowedSelector(selector);
4786
- const count = await driver.count(selector);
5103
+ const count = await countVisible(driver, selector);
4787
5104
  if (count === 0) {
4788
5105
  throw new Error(`Expected visible: ${assertion.visible}`);
4789
5106
  }
@@ -4792,7 +5109,7 @@ async function runInlineAssert(driver, policy, assertion) {
4792
5109
  if (assertion.notVisible !== void 0) {
4793
5110
  const selector = toVisibilitySelector(assertion.notVisible);
4794
5111
  policy.assertAllowedSelector(selector);
4795
- const count = await driver.count(selector);
5112
+ const count = await countVisible(driver, selector);
4796
5113
  if (count > 0) {
4797
5114
  throw new Error(`Expected not visible: ${assertion.notVisible}`);
4798
5115
  }
@@ -5182,25 +5499,21 @@ var STEP_HANDLERS = {
5182
5499
  }
5183
5500
  },
5184
5501
  scroll: {
5185
- capabilities: ["evaluate"],
5502
+ // Dispatched through the driver's `scroll` verb (interact), so native mobile
5503
+ // targets synthesize a touch swipe while web keeps its `window.scrollBy`
5504
+ // behavior. The per-target step gate rejects `scroll` on macOS before here.
5505
+ capabilities: ["interact"],
5186
5506
  run: async (h) => {
5187
5507
  if (!("scroll" in h.step)) unknownStep();
5188
- const amount = h.step.scroll.amount ?? 500;
5189
- const scrollMap = {
5190
- up: [0, -amount],
5191
- down: [0, amount],
5192
- left: [-amount, 0],
5193
- right: [amount, 0]
5194
- };
5195
- const [x, y] = scrollMap[h.step.scroll.direction];
5196
- await h.driver.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
5508
+ const { direction, amount } = h.step.scroll;
5509
+ await h.driver.scroll(direction, amount);
5197
5510
  return {
5198
5511
  kind: "result",
5199
5512
  result: {
5200
5513
  type: "scroll",
5201
5514
  status: "pass",
5202
5515
  durationMs: Date.now() - h.stepStart,
5203
- value: `${h.step.scroll.direction} ${amount}px`
5516
+ value: amount === void 0 ? direction : `${direction} ${amount}px`
5204
5517
  }
5205
5518
  };
5206
5519
  }
@@ -5246,7 +5559,7 @@ var STEP_HANDLERS = {
5246
5559
  const condition = h.step.if;
5247
5560
  const selector = condition.visible ?? condition.notVisible;
5248
5561
  h.policy.assertAllowedSelector(selector);
5249
- const count = await h.driver.count(selector);
5562
+ const count = await countVisible(h.driver, selector);
5250
5563
  const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
5251
5564
  if (conditionMet) {
5252
5565
  const subResult = await h.executeNested({
@@ -5333,7 +5646,7 @@ var STEP_HANDLERS = {
5333
5646
  const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
5334
5647
  h.policy.assertAllowedSelector(whileSelector);
5335
5648
  for (let i = 0; i < maxIter; i++) {
5336
- const whileCount = await h.driver.count(whileSelector);
5649
+ const whileCount = await countVisible(h.driver, whileSelector);
5337
5650
  const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
5338
5651
  if (!shouldContinue) break;
5339
5652
  totalSubSteps += repeat.steps.length;
@@ -6900,6 +7213,7 @@ function buildRunCommand() {
6900
7213
 
6901
7214
  // src/cli/commands/init.ts
6902
7215
  var import_node_fs15 = __toESM(require("fs"), 1);
7216
+ var import_node_os5 = __toESM(require("os"), 1);
6903
7217
  var import_node_path16 = __toESM(require("path"), 1);
6904
7218
  var import_node_url2 = require("url");
6905
7219
  var import_commander2 = require("commander");
@@ -6925,6 +7239,192 @@ function copyFile(source, destination) {
6925
7239
  import_node_fs15.default.mkdirSync(import_node_path16.default.dirname(destination), { recursive: true });
6926
7240
  import_node_fs15.default.copyFileSync(source, destination);
6927
7241
  }
7242
+ function isInsideDir(parent, child) {
7243
+ const relative = import_node_path16.default.relative(parent, child);
7244
+ return relative.length > 0 && !relative.startsWith("..") && !import_node_path16.default.isAbsolute(relative);
7245
+ }
7246
+ function lstatIfExists(target) {
7247
+ try {
7248
+ return import_node_fs15.default.lstatSync(target);
7249
+ } catch (error) {
7250
+ const code = error.code;
7251
+ if (code === "ENOENT" || code === "ENOTDIR") {
7252
+ return null;
7253
+ }
7254
+ throw error;
7255
+ }
7256
+ }
7257
+ function errorMessage2(error) {
7258
+ return error instanceof Error ? error.message : String(error);
7259
+ }
7260
+ function assertNoSymlinkDestination(prowlDir, destination) {
7261
+ const relative = import_node_path16.default.relative(prowlDir, destination);
7262
+ if (relative.startsWith("..") || import_node_path16.default.isAbsolute(relative)) {
7263
+ throw new Error(`Refusing to write outside ${CONFIG_DIR}: ${destination}`);
7264
+ }
7265
+ const parts = relative.split(import_node_path16.default.sep).filter((part) => part.length > 0);
7266
+ let current = prowlDir;
7267
+ const rootStat = lstatIfExists(current);
7268
+ if (rootStat?.isSymbolicLink()) {
7269
+ throw new Error(
7270
+ `${CONFIG_DIR} scaffold destination path contains a symlink: ${current}. Replace it with a real file or directory before running prowl init --force.`
7271
+ );
7272
+ }
7273
+ if (rootStat && !rootStat.isDirectory()) {
7274
+ throw new Error(`${CONFIG_DIR} scaffold path is not a directory: ${current}`);
7275
+ }
7276
+ for (let index = 0; index < parts.length; index += 1) {
7277
+ current = import_node_path16.default.join(current, parts[index] ?? "");
7278
+ const stat = lstatIfExists(current);
7279
+ if (stat?.isSymbolicLink()) {
7280
+ throw new Error(
7281
+ `${CONFIG_DIR} scaffold destination path contains a symlink: ${current}. Replace it with a real file or directory before running prowl init --force.`
7282
+ );
7283
+ }
7284
+ if (stat && index < parts.length - 1 && !stat.isDirectory()) {
7285
+ throw new Error(`${CONFIG_DIR} scaffold parent path is not a directory: ${current}`);
7286
+ }
7287
+ }
7288
+ }
7289
+ function gitignoreTemplate() {
7290
+ return [
7291
+ "# Run artifacts (screenshots, logs, reports)",
7292
+ "runs/",
7293
+ "",
7294
+ "# Auth state (tokens, cookies)",
7295
+ "auth-state.json",
7296
+ "",
7297
+ "# Environment variables (credentials)",
7298
+ ".env",
7299
+ ""
7300
+ ].join("\n");
7301
+ }
7302
+ function stageScaffoldTemplates(examplesDir) {
7303
+ const stageDir = import_node_fs15.default.mkdtempSync(import_node_path16.default.join(import_node_os5.default.tmpdir(), "prowl-init-"));
7304
+ const files = [];
7305
+ try {
7306
+ copyFile(import_node_path16.default.join(examplesDir, "config.yml"), import_node_path16.default.join(stageDir, "config.yml"));
7307
+ files.push({ source: import_node_path16.default.join(stageDir, "config.yml"), relativePath: "config.yml" });
7308
+ const exampleHuntsDir = import_node_path16.default.join(examplesDir, "hunts");
7309
+ const huntFiles = import_node_fs15.default.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
7310
+ for (const huntFile of huntFiles) {
7311
+ const relativePath = import_node_path16.default.join("hunts", huntFile);
7312
+ copyFile(import_node_path16.default.join(exampleHuntsDir, huntFile), import_node_path16.default.join(stageDir, relativePath));
7313
+ files.push({ source: import_node_path16.default.join(stageDir, relativePath), relativePath });
7314
+ }
7315
+ const gitignorePath = import_node_path16.default.join(stageDir, ".gitignore");
7316
+ import_node_fs15.default.writeFileSync(gitignorePath, gitignoreTemplate());
7317
+ files.push({ source: gitignorePath, relativePath: ".gitignore" });
7318
+ return { stageDir, files };
7319
+ } catch (error) {
7320
+ import_node_fs15.default.rmSync(stageDir, { recursive: true, force: true });
7321
+ throw error;
7322
+ }
7323
+ }
7324
+ function trackCreatedParentDirs(destination, prowlDir, createdDirs) {
7325
+ let dir = import_node_path16.default.dirname(destination);
7326
+ while (isInsideDir(prowlDir, dir) && !import_node_fs15.default.existsSync(dir)) {
7327
+ createdDirs.add(dir);
7328
+ dir = import_node_path16.default.dirname(dir);
7329
+ }
7330
+ }
7331
+ function prepareDestinationRollback(prowlDir, files) {
7332
+ const backupDir = import_node_fs15.default.mkdtempSync(import_node_path16.default.join(import_node_os5.default.tmpdir(), "prowl-init-rollback-"));
7333
+ const backups = [];
7334
+ const createdFiles = [];
7335
+ const createdDirs = /* @__PURE__ */ new Set();
7336
+ const prowlDirExisted = import_node_fs15.default.existsSync(prowlDir);
7337
+ try {
7338
+ for (const file of files) {
7339
+ const destination = import_node_path16.default.join(prowlDir, file.relativePath);
7340
+ assertNoSymlinkDestination(prowlDir, destination);
7341
+ const stat = lstatIfExists(destination);
7342
+ if (stat) {
7343
+ if (!stat.isFile()) {
7344
+ throw new Error(`${CONFIG_DIR} scaffold destination is not a regular file: ${destination}`);
7345
+ }
7346
+ const backup = import_node_path16.default.join(backupDir, file.relativePath);
7347
+ copyFile(destination, backup);
7348
+ backups.push({ destination, backup });
7349
+ } else {
7350
+ createdFiles.push(destination);
7351
+ }
7352
+ if (prowlDirExisted) {
7353
+ trackCreatedParentDirs(destination, prowlDir, createdDirs);
7354
+ }
7355
+ }
7356
+ return {
7357
+ prowlDirExisted,
7358
+ backupDir,
7359
+ backups,
7360
+ createdFiles,
7361
+ createdDirs: [...createdDirs].sort((a, b) => b.length - a.length)
7362
+ };
7363
+ } catch (error) {
7364
+ import_node_fs15.default.rmSync(backupDir, { recursive: true, force: true });
7365
+ throw error;
7366
+ }
7367
+ }
7368
+ function rollbackDestination(prowlDir, rollback) {
7369
+ if (!rollback.prowlDirExisted) {
7370
+ import_node_fs15.default.rmSync(prowlDir, { recursive: true, force: true });
7371
+ return;
7372
+ }
7373
+ for (const file of rollback.createdFiles) {
7374
+ import_node_fs15.default.rmSync(file, { force: true });
7375
+ }
7376
+ for (const backup of rollback.backups) {
7377
+ try {
7378
+ copyFile(backup.backup, backup.destination);
7379
+ } catch (error) {
7380
+ throw new Error(
7381
+ `Failed to restore ${backup.destination} from backup ${backup.backup}: ${errorMessage2(error)}`
7382
+ );
7383
+ }
7384
+ }
7385
+ for (const dir of rollback.createdDirs) {
7386
+ try {
7387
+ import_node_fs15.default.rmdirSync(dir);
7388
+ } catch {
7389
+ }
7390
+ }
7391
+ }
7392
+ function scaffoldProwlDir(root) {
7393
+ const prowlDir = import_node_path16.default.join(root, CONFIG_DIR);
7394
+ const packageRoot = getPackageRoot2();
7395
+ const examplesDir = import_node_path16.default.join(packageRoot, "examples");
7396
+ const exampleConfig = import_node_path16.default.join(examplesDir, "config.yml");
7397
+ const exampleHuntsDir = import_node_path16.default.join(examplesDir, "hunts");
7398
+ if (!import_node_fs15.default.existsSync(exampleConfig) || !import_node_fs15.default.existsSync(exampleHuntsDir)) {
7399
+ throw new Error("Examples not found in package. Reinstall prowl-tools.");
7400
+ }
7401
+ const staged = stageScaffoldTemplates(examplesDir);
7402
+ let rollback = null;
7403
+ let keepBackup = false;
7404
+ try {
7405
+ rollback = prepareDestinationRollback(prowlDir, staged.files);
7406
+ for (const file of staged.files) {
7407
+ copyFile(file.source, import_node_path16.default.join(prowlDir, file.relativePath));
7408
+ }
7409
+ } catch (error) {
7410
+ if (rollback) {
7411
+ try {
7412
+ rollbackDestination(prowlDir, rollback);
7413
+ } catch (rollbackError) {
7414
+ keepBackup = true;
7415
+ throw new Error(
7416
+ `Scaffold failed: ${errorMessage2(error)}. Rollback failed: ${errorMessage2(rollbackError)}. Backup preserved at ${rollback.backupDir}.`
7417
+ );
7418
+ }
7419
+ }
7420
+ throw error;
7421
+ } finally {
7422
+ if (rollback && !keepBackup) {
7423
+ import_node_fs15.default.rmSync(rollback.backupDir, { recursive: true, force: true });
7424
+ }
7425
+ import_node_fs15.default.rmSync(staged.stageDir, { recursive: true, force: true });
7426
+ }
7427
+ }
6928
7428
  function buildInitCommand() {
6929
7429
  const command = new import_commander2.Command("init").option("--force", `Overwrite existing ${CONFIG_DIR} directory`).action((options) => {
6930
7430
  const root = process.cwd();
@@ -6938,39 +7438,18 @@ function buildInitCommand() {
6938
7438
  process.exitCode = 1;
6939
7439
  return;
6940
7440
  }
6941
- const packageRoot = getPackageRoot2();
6942
- const examplesDir = import_node_path16.default.join(packageRoot, "examples");
6943
- const exampleConfig = import_node_path16.default.join(examplesDir, "config.yml");
6944
- const exampleHuntsDir = import_node_path16.default.join(examplesDir, "hunts");
6945
- if (!import_node_fs15.default.existsSync(exampleConfig) || !import_node_fs15.default.existsSync(exampleHuntsDir)) {
6946
- console.error(import_chalk4.default.red("Examples not found in package. Reinstall prowl-tools."));
7441
+ try {
7442
+ scaffoldProwlDir(root);
7443
+ } catch (error) {
7444
+ console.error(import_chalk4.default.red(error instanceof Error ? error.message : "init failed"));
6947
7445
  process.exitCode = 1;
6948
7446
  return;
6949
7447
  }
6950
- copyFile(exampleConfig, import_node_path16.default.join(prowlDir, "config.yml"));
6951
- const huntFiles = import_node_fs15.default.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
6952
- for (const huntFile of huntFiles) {
6953
- copyFile(
6954
- import_node_path16.default.join(exampleHuntsDir, huntFile),
6955
- import_node_path16.default.join(prowlDir, "hunts", huntFile)
6956
- );
6957
- }
6958
- const gitignore = [
6959
- "# Run artifacts (screenshots, logs, reports)",
6960
- "runs/",
6961
- "",
6962
- "# Auth state (tokens, cookies)",
6963
- "auth-state.json",
6964
- "",
6965
- "# Environment variables (credentials)",
6966
- ".env",
6967
- ""
6968
- ].join("\n");
6969
- import_node_fs15.default.writeFileSync(import_node_path16.default.join(prowlDir, ".gitignore"), gitignore);
6970
7448
  console.log(welcomeBanner());
6971
7449
  console.log(import_chalk4.default.green(` Initialized ${CONFIG_DIR} directory.`));
6972
7450
  console.log(import_chalk4.default.gray(" Run ") + import_chalk4.default.bold("prowl run hello") + import_chalk4.default.gray(" to get started."));
6973
- 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");
7451
+ console.log(import_chalk4.default.gray(" See ") + import_chalk4.default.cyan(`${CONFIG_DIR}/hunts/login-flow.yml`) + import_chalk4.default.gray(" (auth) and ") + import_chalk4.default.cyan(`${CONFIG_DIR}/hunts/form.yml`) + import_chalk4.default.gray(" (web forms) for fuller examples."));
7452
+ console.log(import_chalk4.default.gray(" Desktop-first? ") + import_chalk4.default.cyan(`${CONFIG_DIR}/hunts/macos-hello.yml`) + import_chalk4.default.gray(" is a macOS starter (experimental \u2014 see its comments to enable).") + "\n");
6974
7453
  });
6975
7454
  return command;
6976
7455
  }
@@ -9174,7 +9653,7 @@ async function runHuntTool(args, configPath) {
9174
9653
 
9175
9654
  // src/mcp/projects.ts
9176
9655
  var import_node_fs21 = __toESM(require("fs"), 1);
9177
- var import_node_os5 = __toESM(require("os"), 1);
9656
+ var import_node_os6 = __toESM(require("os"), 1);
9178
9657
  var import_node_path25 = __toESM(require("path"), 1);
9179
9658
  var import_yaml3 = __toESM(require("yaml"), 1);
9180
9659
  var import_zod2 = require("zod");
@@ -9187,10 +9666,10 @@ var projectRegistrySchema = import_zod2.z.object({
9187
9666
  projects: import_zod2.z.record(import_zod2.z.string().min(1), projectEntrySchema)
9188
9667
  }).strict();
9189
9668
  function defaultRegistryPath() {
9190
- return import_node_path25.default.join(import_node_os5.default.homedir(), ".prowl", "projects.yml");
9669
+ return import_node_path25.default.join(import_node_os6.default.homedir(), ".prowl", "projects.yml");
9191
9670
  }
9192
9671
  function legacyRegistryPath() {
9193
- return import_node_path25.default.join(import_node_os5.default.homedir(), ".prowlqa", "projects.yml");
9672
+ return import_node_path25.default.join(import_node_os6.default.homedir(), ".prowlqa", "projects.yml");
9194
9673
  }
9195
9674
  function resolveProjectConfigPath(root) {
9196
9675
  const preferred = import_node_path25.default.join(root, ".prowl", "config.yml");
@@ -9244,7 +9723,7 @@ function listRegisteredProjects(registry) {
9244
9723
  function textResult(data) {
9245
9724
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
9246
9725
  }
9247
- function errorMessage(error) {
9726
+ function errorMessage3(error) {
9248
9727
  return error instanceof Error ? error.message : String(error);
9249
9728
  }
9250
9729
  function safeStringify(value) {
@@ -9258,7 +9737,7 @@ async function toolResult(toolName, args, action) {
9258
9737
  try {
9259
9738
  return textResult(await action());
9260
9739
  } catch (error) {
9261
- throw new Error(`${toolName} failed for args=${safeStringify(args)}: ${errorMessage(error)}`, {
9740
+ throw new Error(`${toolName} failed for args=${safeStringify(args)}: ${errorMessage3(error)}`, {
9262
9741
  cause: error
9263
9742
  });
9264
9743
  }
@@ -9335,7 +9814,7 @@ async function startMcpServer(options = {}) {
9335
9814
  }
9336
9815
 
9337
9816
  // src/cli/commands/mcp.ts
9338
- function errorMessage2(error) {
9817
+ function errorMessage4(error) {
9339
9818
  return error instanceof Error ? error.message : String(error);
9340
9819
  }
9341
9820
  function buildMcpCommand() {
@@ -9346,7 +9825,7 @@ function buildMcpCommand() {
9346
9825
  try {
9347
9826
  await startMcpServer({ registryPath: options.projects });
9348
9827
  } catch (error) {
9349
- console.error(`Failed to start MCP server: ${errorMessage2(error)}`);
9828
+ console.error(`Failed to start MCP server: ${errorMessage4(error)}`);
9350
9829
  process.exit(1);
9351
9830
  }
9352
9831
  });
@@ -9360,7 +9839,7 @@ var import_commander13 = require("commander");
9360
9839
  var import_node_child_process7 = require("child_process");
9361
9840
  var import_node_crypto3 = require("crypto");
9362
9841
  var import_node_fs22 = __toESM(require("fs"), 1);
9363
- var import_node_os6 = __toESM(require("os"), 1);
9842
+ var import_node_os7 = __toESM(require("os"), 1);
9364
9843
  var import_node_path26 = __toESM(require("path"), 1);
9365
9844
  var import_node_util = require("util");
9366
9845
  var execFileAsync = (0, import_node_util.promisify)(import_node_child_process7.execFile);
@@ -9526,7 +10005,7 @@ The download was rejected and discarded; re-run the install, and report it if it
9526
10005
  }
9527
10006
  async function installMacdriver(options = {}) {
9528
10007
  const version = validateMacdriverVersion(options.version ?? MACDRIVER_VERSION);
9529
- const homedir = options.homedir ?? import_node_os6.default.homedir();
10008
+ const homedir = options.homedir ?? import_node_os7.default.homedir();
9530
10009
  const listArchiveEntries = options.listArchiveEntries ?? zipinfoArchiveLister;
9531
10010
  const extract = options.extract ?? dittoExtractor;
9532
10011
  const verifySignature = options.verifySignature ?? codesignVerifier;
@@ -9606,7 +10085,7 @@ var runVersionProbe = async (binaryPath) => {
9606
10085
  };
9607
10086
  async function collectMacdriverStatus(options = {}) {
9608
10087
  const env = options.env ?? process.env;
9609
- const homedir = options.homedir ?? import_node_os6.default.homedir();
10088
+ const homedir = options.homedir ?? import_node_os7.default.homedir();
9610
10089
  const probe = options.probe ?? runVersionProbe;
9611
10090
  let resolved = null;
9612
10091
  try {
@@ -9719,10 +10198,402 @@ function buildMacdriverCommand() {
9719
10198
  return command;
9720
10199
  }
9721
10200
 
10201
+ // src/cli/commands/doctor.ts
10202
+ var import_chalk16 = __toESM(require("chalk"), 1);
10203
+ var import_commander14 = require("commander");
10204
+
10205
+ // src/doctor/checks.ts
10206
+ var import_node_child_process8 = require("child_process");
10207
+ var import_node_module3 = require("module");
10208
+ var import_node_fs23 = __toESM(require("fs"), 1);
10209
+ var import_node_path27 = __toESM(require("path"), 1);
10210
+ var import_node_util2 = require("util");
10211
+ init_loader();
10212
+ var import_meta5 = {};
10213
+ var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process8.execFile);
10214
+ var MIN_NODE_MAJOR = 20;
10215
+ var CHECK_NODE = "Node.js version";
10216
+ var CHECK_PLAYWRIGHT = "Playwright installed";
10217
+ var CHECK_CHROMIUM = "Chromium browser";
10218
+ var CHECK_PROWL_DIR = ".prowl/ directory";
10219
+ var CHECK_CONFIG = "config.yml valid";
10220
+ var CHECK_MACOS = "macOS helper (prowl-macdriver)";
10221
+ var CHECK_IOS = "iOS tooling (xcrun simctl)";
10222
+ var CHECK_ANDROID = "Android tooling (adb)";
10223
+ var INSTALL_CHROMIUM_TIMEOUT_MS = 3e5;
10224
+ function nodeMajor(version) {
10225
+ const cleaned = version.startsWith("v") ? version.slice(1) : version;
10226
+ return Number.parseInt(cleaned.split(".")[0] ?? "", 10);
10227
+ }
10228
+ function checkNode(nodeVersion) {
10229
+ const major = nodeMajor(nodeVersion);
10230
+ if (Number.isFinite(major) && major >= MIN_NODE_MAJOR) {
10231
+ return {
10232
+ name: CHECK_NODE,
10233
+ status: "pass",
10234
+ message: `Node ${nodeVersion} (>= ${MIN_NODE_MAJOR} required)`,
10235
+ fixable: false
10236
+ };
10237
+ }
10238
+ return {
10239
+ name: CHECK_NODE,
10240
+ status: "fail",
10241
+ message: `Node ${nodeVersion} is too old \u2014 Prowl needs Node >= ${MIN_NODE_MAJOR}. Upgrade with nvm (\`nvm install 20\`), Homebrew (\`brew install node\`), or nodejs.org.`,
10242
+ fixable: false
10243
+ };
10244
+ }
10245
+ function checkPlaywright(probe) {
10246
+ if (probe.playwrightInstalled) {
10247
+ return { name: CHECK_PLAYWRIGHT, status: "pass", message: "Playwright is installed", fixable: false };
10248
+ }
10249
+ return {
10250
+ name: CHECK_PLAYWRIGHT,
10251
+ status: "fail",
10252
+ message: "Playwright is not installed. It ships as a dependency of prowl-tools \u2014 reinstall with `npm install` (local) or `npm install -g prowl-tools` (global).",
10253
+ fixable: false
10254
+ };
10255
+ }
10256
+ function checkChromium(probe) {
10257
+ if (!probe.playwrightInstalled) {
10258
+ return {
10259
+ name: CHECK_CHROMIUM,
10260
+ status: "skip",
10261
+ message: "Skipped \u2014 Playwright is not installed",
10262
+ fixable: false
10263
+ };
10264
+ }
10265
+ if (probe.chromiumInstalled) {
10266
+ return {
10267
+ name: CHECK_CHROMIUM,
10268
+ status: "pass",
10269
+ message: `Chromium is available${probe.chromiumPath ? ` (${probe.chromiumPath})` : ""}`,
10270
+ fixable: false
10271
+ };
10272
+ }
10273
+ return {
10274
+ name: CHECK_CHROMIUM,
10275
+ status: "fail",
10276
+ message: "Chromium is not installed. Run `npx playwright install chromium` (or `prowl doctor --fix`).",
10277
+ fixable: true
10278
+ };
10279
+ }
10280
+ function checkProwlDir(exists) {
10281
+ if (exists) {
10282
+ return { name: CHECK_PROWL_DIR, status: "pass", message: `${CONFIG_DIR}/ exists`, fixable: false };
10283
+ }
10284
+ return {
10285
+ name: CHECK_PROWL_DIR,
10286
+ status: "warn",
10287
+ message: `No ${CONFIG_DIR}/ directory here. Run \`prowl init\` (or \`prowl doctor --fix\`) to scaffold config and starter hunts.`,
10288
+ fixable: true
10289
+ };
10290
+ }
10291
+ function checkConfig(prowlDirExists, loadConfig2) {
10292
+ if (!prowlDirExists) {
10293
+ return {
10294
+ result: {
10295
+ name: CHECK_CONFIG,
10296
+ status: "skip",
10297
+ message: `Skipped \u2014 no ${CONFIG_DIR}/ directory`,
10298
+ fixable: false
10299
+ },
10300
+ config: null
10301
+ };
10302
+ }
10303
+ try {
10304
+ const { config } = loadConfig2();
10305
+ return {
10306
+ result: {
10307
+ name: CHECK_CONFIG,
10308
+ status: "pass",
10309
+ message: `Valid (target: ${config.target.type})`,
10310
+ fixable: false
10311
+ },
10312
+ config
10313
+ };
10314
+ } catch (error) {
10315
+ const detail = error instanceof Error ? error.message : String(error);
10316
+ return {
10317
+ result: {
10318
+ name: CHECK_CONFIG,
10319
+ status: "fail",
10320
+ message: `Invalid config: ${detail}`,
10321
+ fixable: false
10322
+ },
10323
+ config: null
10324
+ };
10325
+ }
10326
+ }
10327
+ function macdriverSourceLabel(source) {
10328
+ switch (source) {
10329
+ case "env":
10330
+ return "PROWL_MACDRIVER_BIN override";
10331
+ case "user-install":
10332
+ return "user install (~/.prowl/macdriver)";
10333
+ case "source-build":
10334
+ return "repo source build";
10335
+ }
10336
+ }
10337
+ async function checkMacTarget(collect2, env) {
10338
+ let status;
10339
+ try {
10340
+ status = await collect2({ env });
10341
+ } catch (error) {
10342
+ const detail = error instanceof Error ? error.message : String(error);
10343
+ return {
10344
+ name: CHECK_MACOS,
10345
+ status: "fail",
10346
+ message: `Could not inspect prowl-macdriver status (${detail}). Run \`prowl macdriver status\` for details.`,
10347
+ fixable: false
10348
+ };
10349
+ }
10350
+ if (status.resolved && status.probedVersion) {
10351
+ return {
10352
+ name: CHECK_MACOS,
10353
+ status: "pass",
10354
+ message: `Resolved via ${macdriverSourceLabel(status.resolved.source)}: ${status.resolved.path} (reports ${status.probedVersion}). Verify Accessibility/Screen Recording permissions with \`prowl macdriver status\`.`,
10355
+ fixable: false
10356
+ };
10357
+ }
10358
+ if (status.resolved) {
10359
+ return {
10360
+ name: CHECK_MACOS,
10361
+ status: "fail",
10362
+ message: `prowl-macdriver resolved via ${macdriverSourceLabel(status.resolved.source)} (${status.resolved.path}), but the helper version probe failed. Run \`prowl macdriver status\` for details.`,
10363
+ fixable: false
10364
+ };
10365
+ }
10366
+ return {
10367
+ name: CHECK_MACOS,
10368
+ status: "fail",
10369
+ message: "prowl-macdriver helper not found. Install it with `prowl macdriver install`, then check permissions with `prowl macdriver status`.",
10370
+ fixable: false
10371
+ };
10372
+ }
10373
+ async function checkIosTarget(platform, run) {
10374
+ if (platform !== "darwin") {
10375
+ return {
10376
+ name: CHECK_IOS,
10377
+ status: "fail",
10378
+ message: `The iOS target requires macOS; xcrun simctl is unavailable on ${platform}.`,
10379
+ fixable: false
10380
+ };
10381
+ }
10382
+ try {
10383
+ await run("xcrun", ["simctl", "help"]);
10384
+ return { name: CHECK_IOS, status: "pass", message: "xcrun simctl is reachable", fixable: false };
10385
+ } catch (error) {
10386
+ const detail = error instanceof Error ? error.message : String(error);
10387
+ return {
10388
+ name: CHECK_IOS,
10389
+ status: "fail",
10390
+ message: `xcrun simctl is not reachable (${detail}). Install Xcode and its command-line tools (\`xcode-select --install\`), then open Xcode once to finish setup.`,
10391
+ fixable: false
10392
+ };
10393
+ }
10394
+ }
10395
+ async function checkAndroidTarget(run) {
10396
+ try {
10397
+ await run("adb", ["version"]);
10398
+ return { name: CHECK_ANDROID, status: "pass", message: "adb is reachable", fixable: false };
10399
+ } catch (error) {
10400
+ const detail = error instanceof Error ? error.message : String(error);
10401
+ return {
10402
+ name: CHECK_ANDROID,
10403
+ status: "fail",
10404
+ message: `adb is not reachable (${detail}). Install Android platform-tools and add it to your PATH (e.g. \`brew install --cask android-platform-tools\`, or via Android Studio's SDK Manager).`,
10405
+ fixable: false
10406
+ };
10407
+ }
10408
+ }
10409
+ async function runDoctorChecks(deps) {
10410
+ const results = [];
10411
+ const probe = await deps.probePlaywright();
10412
+ results.push(checkNode(deps.nodeVersion));
10413
+ results.push(checkPlaywright(probe));
10414
+ results.push(checkChromium(probe));
10415
+ const prowlExists = deps.prowlDirExists(deps.cwd);
10416
+ results.push(checkProwlDir(prowlExists));
10417
+ const { result: configResult, config } = checkConfig(prowlExists, deps.loadConfig);
10418
+ results.push(configResult);
10419
+ if (config) {
10420
+ switch (config.target.type) {
10421
+ case "macos":
10422
+ results.push(await checkMacTarget(deps.collectMacdriverStatus, deps.env));
10423
+ break;
10424
+ case "ios":
10425
+ results.push(await checkIosTarget(deps.platform, deps.runCommand));
10426
+ break;
10427
+ case "android":
10428
+ results.push(await checkAndroidTarget(deps.runCommand));
10429
+ break;
10430
+ default:
10431
+ break;
10432
+ }
10433
+ }
10434
+ return results;
10435
+ }
10436
+ async function runDoctor(deps, options = {}) {
10437
+ let results = await runDoctorChecks(deps);
10438
+ const fixes = [];
10439
+ if (options.fix) {
10440
+ for (const result of results) {
10441
+ if (!result.fixable || result.status === "pass" || result.status === "skip") {
10442
+ continue;
10443
+ }
10444
+ try {
10445
+ if (result.name === CHECK_CHROMIUM) {
10446
+ await deps.installChromium();
10447
+ fixes.push({ name: result.name });
10448
+ } else if (result.name === CHECK_PROWL_DIR) {
10449
+ deps.scaffoldProwlDir(deps.cwd);
10450
+ fixes.push({ name: result.name });
10451
+ }
10452
+ } catch (error) {
10453
+ fixes.push({
10454
+ name: result.name,
10455
+ error: error instanceof Error ? error.message : String(error)
10456
+ });
10457
+ }
10458
+ }
10459
+ if (fixes.length > 0) {
10460
+ results = await runDoctorChecks(deps);
10461
+ }
10462
+ }
10463
+ const ok = !results.some((result) => result.status === "fail");
10464
+ return { results, fixes, ok };
10465
+ }
10466
+ async function defaultProbePlaywright() {
10467
+ try {
10468
+ const pw = await import("playwright");
10469
+ let chromiumPath = null;
10470
+ try {
10471
+ chromiumPath = pw.chromium.executablePath();
10472
+ } catch {
10473
+ chromiumPath = null;
10474
+ }
10475
+ const chromiumInstalled = chromiumPath ? import_node_fs23.default.existsSync(chromiumPath) : false;
10476
+ return { playwrightInstalled: true, chromiumPath, chromiumInstalled };
10477
+ } catch {
10478
+ return { playwrightInstalled: false, chromiumPath: null, chromiumInstalled: false };
10479
+ }
10480
+ }
10481
+ var defaultRunCommand = async (file, args) => execFileAsync2(file, args, { timeout: 5e3 });
10482
+ function createLocalRequire() {
10483
+ if (typeof __filename === "string") {
10484
+ return (0, import_node_module3.createRequire)(__filename);
10485
+ }
10486
+ return (0, import_node_module3.createRequire)(import_meta5.url);
10487
+ }
10488
+ function resolvePlaywrightCliPath(requireFn = createLocalRequire()) {
10489
+ return import_node_path27.default.join(import_node_path27.default.dirname(requireFn.resolve("playwright/package.json")), "cli.js");
10490
+ }
10491
+ function playwrightInstallChromiumCommand() {
10492
+ return {
10493
+ file: process.execPath,
10494
+ args: [resolvePlaywrightCliPath(), "install", "chromium"]
10495
+ };
10496
+ }
10497
+ async function defaultInstallChromium() {
10498
+ const command = playwrightInstallChromiumCommand();
10499
+ await execFileAsync2(command.file, command.args, { timeout: INSTALL_CHROMIUM_TIMEOUT_MS });
10500
+ }
10501
+ function defaultDoctorDeps() {
10502
+ return {
10503
+ nodeVersion: process.versions.node,
10504
+ platform: process.platform,
10505
+ cwd: process.cwd(),
10506
+ env: process.env,
10507
+ probePlaywright: defaultProbePlaywright,
10508
+ runCommand: defaultRunCommand,
10509
+ prowlDirExists: (cwd) => import_node_fs23.default.existsSync(import_node_path27.default.join(cwd, CONFIG_DIR)),
10510
+ loadConfig: () => loadConfig(),
10511
+ collectMacdriverStatus,
10512
+ installChromium: defaultInstallChromium,
10513
+ scaffoldProwlDir
10514
+ };
10515
+ }
10516
+
10517
+ // src/cli/commands/doctor.ts
10518
+ function symbol(status) {
10519
+ switch (status) {
10520
+ case "pass":
10521
+ return import_chalk16.default.green("\u2713");
10522
+ // ✓
10523
+ case "fail":
10524
+ return import_chalk16.default.red("\u2717");
10525
+ // ✗
10526
+ case "warn":
10527
+ return import_chalk16.default.yellow("\u26A0");
10528
+ // ⚠
10529
+ case "skip":
10530
+ return import_chalk16.default.gray("\u25CB");
10531
+ }
10532
+ }
10533
+ function renderResult(result) {
10534
+ console.log(` ${symbol(result.status)} ${import_chalk16.default.bold(result.name)}`);
10535
+ console.log(` ${import_chalk16.default.gray(result.message)}`);
10536
+ }
10537
+ function renderReport(report, fixRequested) {
10538
+ console.log(import_chalk16.default.bold("\nprowl doctor\n"));
10539
+ for (const result of report.results) {
10540
+ renderResult(result);
10541
+ }
10542
+ if (fixRequested) {
10543
+ console.log("");
10544
+ if (report.fixes.length === 0) {
10545
+ console.log(import_chalk16.default.gray(" No auto-fixable issues to repair."));
10546
+ } else {
10547
+ console.log(import_chalk16.default.bold(" Attempted fixes:"));
10548
+ for (const fix of report.fixes) {
10549
+ if (fix.error) {
10550
+ console.log(` ${import_chalk16.default.red("\u2717")} ${fix.name} \u2014 ${import_chalk16.default.gray(fix.error)}`);
10551
+ } else {
10552
+ console.log(` ${import_chalk16.default.green("\u2713")} ${fix.name}`);
10553
+ }
10554
+ }
10555
+ }
10556
+ }
10557
+ const counts = { pass: 0, warn: 0, fail: 0, skip: 0 };
10558
+ for (const result of report.results) {
10559
+ counts[result.status] += 1;
10560
+ }
10561
+ const parts = [
10562
+ import_chalk16.default.green(`${counts.pass} passed`),
10563
+ counts.warn > 0 ? import_chalk16.default.yellow(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`) : null,
10564
+ counts.fail > 0 ? import_chalk16.default.red(`${counts.fail} failed`) : null,
10565
+ counts.skip > 0 ? import_chalk16.default.gray(`${counts.skip} skipped`) : null
10566
+ ].filter((part) => part !== null);
10567
+ console.log("\n " + parts.join(import_chalk16.default.gray(", ")));
10568
+ if (counts.fail > 0) {
10569
+ console.log(import_chalk16.default.red.bold(" Environment has problems \u2014 fix the failures above, then re-run `prowl doctor`.\n"));
10570
+ } else if (counts.warn > 0) {
10571
+ console.log(import_chalk16.default.yellow.bold(" Environment is usable, with warnings.\n"));
10572
+ } else {
10573
+ console.log(import_chalk16.default.green.bold(" Environment is healthy.\n"));
10574
+ }
10575
+ }
10576
+ function buildDoctorCommand(deps = defaultDoctorDeps()) {
10577
+ return new import_commander14.Command("doctor").description("Check that your environment is set up to run Prowl hunts").option("--fix", "Attempt safe auto-repairs: install Chromium and scaffold a missing .prowl/").action(async (options) => {
10578
+ try {
10579
+ const report = await runDoctor(deps, { fix: options.fix });
10580
+ renderReport(report, Boolean(options.fix));
10581
+ if (!report.ok) {
10582
+ process.exitCode = 1;
10583
+ }
10584
+ } catch (error) {
10585
+ const message = error instanceof Error ? error.message : "doctor failed";
10586
+ console.error(import_chalk16.default.red(`Error: ${message}
10587
+ Re-run \`prowl doctor\`; if it fails again, report the error.`));
10588
+ process.exitCode = 1;
10589
+ }
10590
+ });
10591
+ }
10592
+
9722
10593
  // src/cli/program.ts
9723
10594
  var CLI_VERSION = package_default.version;
9724
10595
  function buildProgram() {
9725
- const program2 = new import_commander14.Command();
10596
+ const program2 = new import_commander15.Command();
9726
10597
  program2.name("prowl").description("CLI-first QA testing tool for deterministic Playwright flows").version(CLI_VERSION);
9727
10598
  program2.addCommand(buildRunCommand());
9728
10599
  program2.addCommand(buildCiCommand());
@@ -9737,6 +10608,7 @@ function buildProgram() {
9737
10608
  program2.addCommand(buildFlakyCommand());
9738
10609
  program2.addCommand(buildMcpCommand());
9739
10610
  program2.addCommand(buildMacdriverCommand());
10611
+ program2.addCommand(buildDoctorCommand());
9740
10612
  return program2;
9741
10613
  }
9742
10614