prowl-tools 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -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) {
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 };
836
+ }
837
+ if (type === "android") {
838
+ const androidTarget = target;
839
+ return {
840
+ type: "android",
841
+ app: androidTarget.app,
842
+ ...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
843
+ ...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
844
+ };
845
+ }
846
+ if (type === "ios") {
847
+ const iosTarget = target;
1118
848
  return {
1119
- runScript: { file: interpolateString(step.runScript.file, 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 } : {}
1120
853
  };
1121
854
  }
1122
- if ("assertScreenshot" in step) {
1123
- return {
1124
- assertScreenshot: {
1125
- name: interpolateString(step.assertScreenshot.name, vars).value,
1126
- ...step.assertScreenshot.threshold !== void 0 ? { threshold: step.assertScreenshot.threshold } : {}
1127
- }
1128
- };
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;
1129
909
  }
1130
- if ("assertWithAI" in step) {
1131
- return {
1132
- assertWithAI: interpolateString(step.assertWithAI, vars).value
1133
- };
910
+ if (Array.isArray(value)) {
911
+ return value.map((item) => interpolateConfigStrings(item, vars));
1134
912
  }
1135
- if ("copyText" in step) {
1136
- return {
1137
- copyText: {
1138
- selector: interpolateString(step.copyText.selector, vars).value,
1139
- as: step.copyText.as
1140
- }
1141
- };
913
+ if (value && typeof value === "object") {
914
+ return Object.fromEntries(
915
+ Object.entries(value).map(([key, item]) => [key, interpolateConfigStrings(item, vars)])
916
+ );
1142
917
  }
1143
- if ("waitForDownload" in step) {
1144
- if (step.waitForDownload === null) {
1145
- return { waitForDownload: null };
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 [];
1174
973
  }
1175
- return assertion;
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 : [];
1176
977
  }
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;
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
+ };
1074
+ }
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_commander14 = require("commander");
1132
+
1133
+ // package.json
1134
+ var package_default = {
1135
+ name: "prowl-tools",
1136
+ version: "0.1.8",
1137
+ description: "E2E testing for native macOS apps and web apps from declarative YAML hunts.",
1138
+ type: "module",
1139
+ license: "Apache-2.0",
1140
+ author: "Prowl Tools",
1141
+ repository: {
1142
+ type: "git",
1143
+ url: "https://github.com/prowl-tools/prowl.git"
1144
+ },
1145
+ homepage: "https://prowl.tools",
1146
+ bugs: {
1147
+ url: "https://github.com/prowl-tools/prowl/issues"
1148
+ },
1149
+ keywords: [
1150
+ "testing",
1151
+ "qa",
1152
+ "e2e",
1153
+ "macos",
1154
+ "desktop-testing",
1155
+ "accessibility",
1156
+ "playwright",
1157
+ "yaml",
1158
+ "browser-testing",
1159
+ "automation",
1160
+ "web-testing",
1161
+ "cli"
1162
+ ],
1163
+ main: "dist/lib.cjs",
1164
+ module: "dist/lib.js",
1165
+ types: "dist/lib.d.ts",
1166
+ exports: {
1167
+ ".": {
1168
+ import: {
1169
+ types: "./dist/lib.d.ts",
1170
+ default: "./dist/lib.js"
1171
+ },
1172
+ require: {
1173
+ types: "./dist/lib.d.cts",
1174
+ default: "./dist/lib.cjs"
1175
+ }
1176
+ }
1177
+ },
1178
+ bin: {
1179
+ prowl: "dist/index.js"
1180
+ },
1181
+ files: [
1182
+ "dist",
1183
+ "examples",
1184
+ "LICENSE",
1185
+ "README.md",
1186
+ "NOTICE"
1187
+ ],
1188
+ engines: {
1189
+ node: ">=20.0.0"
1190
+ },
1191
+ scripts: {
1192
+ build: "tsup",
1193
+ lint: "eslint .",
1194
+ test: "vitest run",
1195
+ "test:watch": "vitest"
1196
+ },
1197
+ dependencies: {
1198
+ "@modelcontextprotocol/sdk": "^1.29.0",
1199
+ chalk: "^5.3.0",
1200
+ commander: "^12.1.0",
1201
+ dotenv: "^16.6.1",
1202
+ ora: "^8.1.1",
1203
+ pixelmatch: "^7.1.0",
1204
+ playwright: "^1.50.1",
1205
+ pngjs: "^7.0.0",
1206
+ yaml: "^2.6.1",
1207
+ zod: "^3.23.8"
1208
+ },
1209
+ optionalDependencies: {
1210
+ "appium-uiautomator2-server": "10.6.2",
1211
+ "appium-webdriveragent": "16.4.0"
1212
+ },
1213
+ devDependencies: {
1214
+ "@types/node": "^22.13.1",
1215
+ "@types/pngjs": "^6.0.5",
1216
+ "@typescript-eslint/eslint-plugin": "^7.18.0",
1217
+ "@typescript-eslint/parser": "^7.18.0",
1218
+ eslint: "^8.57.1",
1219
+ tsup: "^8.3.5",
1220
+ typescript: "^5.7.3",
1221
+ vitest: "^2.1.8"
1222
+ }
1223
+ };
1224
+
1225
+ // src/cli/commands/run.ts
1226
+ var import_commander = require("commander");
1227
+ var import_chalk3 = __toESM(require("chalk"), 1);
1228
+
1229
+ // src/runner/index.ts
1230
+ var import_node_fs14 = __toESM(require("fs"), 1);
1231
+ var import_node_path15 = __toESM(require("path"), 1);
1232
+ init_loader();
1233
+ init_interpolate();
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
  },
@@ -2269,6 +2325,101 @@ function rankNativeSelectors(fields) {
2269
2325
  return selectors;
2270
2326
  }
2271
2327
 
2328
+ // src/browser/touch-gestures.ts
2329
+ var DEFAULT_SWIPE_FRACTION = 0.75;
2330
+ var SCROLL_TO_PROBE_SWIPE_FRACTION = 0.6;
2331
+ var MAX_SWIPE_FRACTION = 0.9;
2332
+ var SWIPE_HOLD_MS = 100;
2333
+ var SWIPE_MOVE_DURATION_MS = 300;
2334
+ var SCROLL_TO_SWEEP_DEPTH = 10;
2335
+ var SCROLL_TO_PROBE_DIRECTIONS = [
2336
+ ...Array.from({ length: SCROLL_TO_SWEEP_DEPTH }, () => "down"),
2337
+ ...Array.from({ length: SCROLL_TO_SWEEP_DEPTH * 2 }, () => "up")
2338
+ ];
2339
+ var MAX_SCROLL_TO_SWIPES = SCROLL_TO_PROBE_DIRECTIONS.length;
2340
+ var OPPOSITE_SWIPE_DIRECTIONS = {
2341
+ up: "down",
2342
+ down: "up",
2343
+ left: "right",
2344
+ right: "left"
2345
+ };
2346
+ async function probeScrollIntoView({
2347
+ isVisible,
2348
+ swipe,
2349
+ directions = SCROLL_TO_PROBE_DIRECTIONS
2350
+ }) {
2351
+ if (await isVisible()) {
2352
+ return true;
2353
+ }
2354
+ for (const direction of directions) {
2355
+ await swipe(direction);
2356
+ if (await isVisible()) {
2357
+ return true;
2358
+ }
2359
+ }
2360
+ return false;
2361
+ }
2362
+ function toScreenSize(value, source) {
2363
+ const record = value ?? {};
2364
+ const width = Number(record.width);
2365
+ const height = Number(record.height);
2366
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
2367
+ throw new Error(`${source} did not return a usable screen size`);
2368
+ }
2369
+ return { width, height };
2370
+ }
2371
+ function isVertical(direction) {
2372
+ return direction === "up" || direction === "down";
2373
+ }
2374
+ function swipeDistanceFor(direction, size, amount) {
2375
+ if (amount !== void 0 && !Number.isFinite(amount)) {
2376
+ throw new Error("scroll amount must be a finite number");
2377
+ }
2378
+ const axis = isVertical(direction) ? size.height : size.width;
2379
+ const requested = amount === void 0 ? axis * DEFAULT_SWIPE_FRACTION : Math.abs(amount);
2380
+ const max = axis * MAX_SWIPE_FRACTION;
2381
+ return Math.max(1, Math.round(Math.min(requested, max)));
2382
+ }
2383
+ function scrollToProbeDistanceFor(direction, size) {
2384
+ const axis = isVertical(direction) ? size.height : size.width;
2385
+ return Math.max(1, Math.round(axis * SCROLL_TO_PROBE_SWIPE_FRACTION));
2386
+ }
2387
+ function swipeEndpoints(direction, size, distance) {
2388
+ const cx = Math.round(size.width / 2);
2389
+ const cy = Math.round(size.height / 2);
2390
+ const half = Math.round(distance / 2);
2391
+ switch (direction) {
2392
+ case "down":
2393
+ return { start: { x: cx, y: cy + half }, end: { x: cx, y: cy - half } };
2394
+ case "up":
2395
+ return { start: { x: cx, y: cy - half }, end: { x: cx, y: cy + half } };
2396
+ case "right":
2397
+ return { start: { x: cx + half, y: cy }, end: { x: cx - half, y: cy } };
2398
+ case "left":
2399
+ return { start: { x: cx - half, y: cy }, end: { x: cx + half, y: cy } };
2400
+ }
2401
+ }
2402
+ function buildSwipeActions(start, end) {
2403
+ return {
2404
+ type: "pointer",
2405
+ id: "finger1",
2406
+ parameters: { pointerType: "touch" },
2407
+ actions: [
2408
+ { type: "pointerMove", duration: 0, x: start.x, y: start.y, origin: "viewport" },
2409
+ { type: "pointerDown", button: 0 },
2410
+ { type: "pause", duration: SWIPE_HOLD_MS },
2411
+ { type: "pointerMove", duration: SWIPE_MOVE_DURATION_MS, x: end.x, y: end.y, origin: "viewport" },
2412
+ { type: "pointerUp", button: 0 }
2413
+ ]
2414
+ };
2415
+ }
2416
+ function buildDirectionalSwipe(direction, size, amount) {
2417
+ const normalizedDirection = amount !== void 0 && amount < 0 ? OPPOSITE_SWIPE_DIRECTIONS[direction] : direction;
2418
+ const distance = swipeDistanceFor(normalizedDirection, size, amount);
2419
+ const { start, end } = swipeEndpoints(normalizedDirection, size, distance);
2420
+ return { actions: buildSwipeActions(start, end), distance, start, end };
2421
+ }
2422
+
2272
2423
  // src/browser/android-driver.ts
2273
2424
  var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
2274
2425
  "query",
@@ -2384,6 +2535,11 @@ function createAndroidDriver(client, options = {}) {
2384
2535
  async function fillSelector(selector, value) {
2385
2536
  await client.setValue(await resolveOne(selector), value);
2386
2537
  }
2538
+ async function swipe(direction, amount, size) {
2539
+ const actualSize = size ?? await client.windowSize();
2540
+ const { actions } = buildDirectionalSwipe(direction, actualSize, amount);
2541
+ await client.performActions(actions);
2542
+ }
2387
2543
  return {
2388
2544
  capabilities: ANDROID_CAPABILITIES,
2389
2545
  // navigation -----------------------------------------------------------
@@ -2421,8 +2577,31 @@ function createAndroidDriver(client, options = {}) {
2421
2577
  hover() {
2422
2578
  return rejectUnsupported("hover");
2423
2579
  },
2424
- scrollIntoView() {
2425
- return rejectUnsupported("scrollTo");
2580
+ // Screen-centred swipe via the W3C actions endpoint (PROWL-080). Direction
2581
+ // semantics match the web step: scrolling "down" reveals lower content, so
2582
+ // the finger drags up. See ./touch-gestures.ts.
2583
+ async scroll(direction, amount) {
2584
+ await swipe(direction, amount);
2585
+ },
2586
+ // Resolve the element, short-circuiting if it is already present in the
2587
+ // hierarchy; otherwise use the shared bounded down/up mobile probe before
2588
+ // failing with a message naming the selector and attempts.
2589
+ async scrollIntoView(selector) {
2590
+ const query = parseAndroidSelector(selector);
2591
+ let probeSize;
2592
+ const found = await probeScrollIntoView({
2593
+ isVisible: async () => (await client.findElements(query)).length > 0,
2594
+ swipe: async (direction) => {
2595
+ probeSize ??= await client.windowSize();
2596
+ await swipe(direction, scrollToProbeDistanceFor(direction, probeSize), probeSize);
2597
+ }
2598
+ });
2599
+ if (found) {
2600
+ return;
2601
+ }
2602
+ throw new Error(
2603
+ `scrollTo: element "${selector}" not visible after ${MAX_SCROLL_TO_SWIPES} scroll attempts on the Android target`
2604
+ );
2426
2605
  },
2427
2606
  setInputFiles() {
2428
2607
  return rejectUnsupported("setInputFiles");
@@ -2894,6 +3073,13 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
2894
3073
  async pressKeyCode(keyCode) {
2895
3074
  await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
2896
3075
  },
3076
+ async windowSize() {
3077
+ const value = await transport.request("GET", `${base}/window/current/size`);
3078
+ return toScreenSize(value, "uiautomator2 /window/current/size");
3079
+ },
3080
+ async performActions(actions) {
3081
+ await transport.request("POST", `${base}/actions`, { actions: [actions] });
3082
+ },
2897
3083
  async screenshotPng() {
2898
3084
  const value = await transport.request("GET", `${base}/screenshot`);
2899
3085
  if (typeof value !== "string") {
@@ -3038,6 +3224,7 @@ async function launchAndroidSession(options) {
3038
3224
  readyDeadlineMs,
3039
3225
  appPackage: pkg
3040
3226
  });
3227
+ await launchPackage(runner, serial, pkg);
3041
3228
  const driver = createAndroidDriver(client, { appLabel: pkg });
3042
3229
  return { client, driver, package: pkg, serial, teardown };
3043
3230
  } catch (error) {
@@ -3065,6 +3252,7 @@ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
3065
3252
  ]);
3066
3253
  var WAIT_POLL_INTERVAL_MS2 = 250;
3067
3254
  var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
3255
+ var DISPLAYED_CHECK_CONCURRENCY = 4;
3068
3256
  var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
3069
3257
  function escapePredicateArg(value) {
3070
3258
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
@@ -3139,6 +3327,64 @@ function createIosDriver(client, options) {
3139
3327
  async function fillSelector(selector, value) {
3140
3328
  await client.setValue(await resolveOne(selector), value);
3141
3329
  }
3330
+ async function swipe(direction, amount, size) {
3331
+ const actualSize = size ?? await client.windowSize();
3332
+ const { actions } = buildDirectionalSwipe(direction, actualSize, amount);
3333
+ await client.performActions(actions);
3334
+ }
3335
+ async function visibleElementIds(query) {
3336
+ const ids = await client.findElements(query);
3337
+ const visible = Array.from({ length: ids.length }, () => null);
3338
+ let nextIndex = 0;
3339
+ const workerCount = Math.min(DISPLAYED_CHECK_CONCURRENCY, ids.length);
3340
+ await Promise.all(
3341
+ Array.from({ length: workerCount }, async () => {
3342
+ for (; ; ) {
3343
+ const index = nextIndex;
3344
+ nextIndex += 1;
3345
+ if (index >= ids.length) {
3346
+ return;
3347
+ }
3348
+ const id = ids[index];
3349
+ if (await client.isDisplayed(id)) {
3350
+ visible[index] = id;
3351
+ }
3352
+ }
3353
+ })
3354
+ );
3355
+ return visible.filter((id) => id !== null);
3356
+ }
3357
+ async function hasVisibleElement(query) {
3358
+ const ids = await client.findElements(query);
3359
+ if (ids.length === 0) {
3360
+ return false;
3361
+ }
3362
+ if (await client.isDisplayed(ids[0])) {
3363
+ return true;
3364
+ }
3365
+ let found = false;
3366
+ let nextIndex = 1;
3367
+ const workerCount = Math.min(DISPLAYED_CHECK_CONCURRENCY, ids.length - 1);
3368
+ await Promise.all(
3369
+ Array.from({ length: workerCount }, async () => {
3370
+ for (; ; ) {
3371
+ if (found) {
3372
+ return;
3373
+ }
3374
+ const index = nextIndex;
3375
+ nextIndex += 1;
3376
+ if (index >= ids.length) {
3377
+ return;
3378
+ }
3379
+ if (await client.isDisplayed(ids[index])) {
3380
+ found = true;
3381
+ return;
3382
+ }
3383
+ }
3384
+ })
3385
+ );
3386
+ return found;
3387
+ }
3142
3388
  async function pressKey(key) {
3143
3389
  const name = key.trim().toLowerCase();
3144
3390
  if (name === "enter" || name === "return") {
@@ -3170,6 +3416,9 @@ function createIosDriver(client, options) {
3170
3416
  async count(selector) {
3171
3417
  return (await client.findElements(parseIosSelector(selector))).length;
3172
3418
  },
3419
+ async visibleCount(selector) {
3420
+ return (await visibleElementIds(parseIosSelector(selector))).length;
3421
+ },
3173
3422
  async textContent(selector) {
3174
3423
  const id = await client.findElement(parseIosSelector(selector));
3175
3424
  if (id === null) {
@@ -3194,15 +3443,38 @@ function createIosDriver(client, options) {
3194
3443
  hover() {
3195
3444
  return rejectUnsupported("hover");
3196
3445
  },
3197
- scrollIntoView() {
3198
- return rejectUnsupported("scrollTo");
3446
+ // Screen-centred swipe via the W3C actions endpoint (PROWL-080). Direction
3447
+ // semantics match the web step: scrolling "down" reveals lower content, so
3448
+ // the finger drags up. See ./touch-gestures.ts.
3449
+ async scroll(direction, amount) {
3450
+ await swipe(direction, amount);
3451
+ },
3452
+ // Resolve the element, short-circuiting only if WDA reports a matching
3453
+ // element displayed in the viewport; hierarchy-only matches can be offscreen.
3454
+ // Otherwise use the shared bounded down/up mobile probe before failing.
3455
+ async scrollIntoView(selector) {
3456
+ const query = parseIosSelector(selector);
3457
+ let probeSize;
3458
+ const found = await probeScrollIntoView({
3459
+ isVisible: () => hasVisibleElement(query),
3460
+ swipe: async (direction) => {
3461
+ probeSize ??= await client.windowSize();
3462
+ await swipe(direction, scrollToProbeDistanceFor(direction, probeSize), probeSize);
3463
+ }
3464
+ });
3465
+ if (found) {
3466
+ return;
3467
+ }
3468
+ throw new Error(
3469
+ `scrollTo: element "${selector}" not visible after ${MAX_SCROLL_TO_SWIPES} scroll attempts on the iOS target`
3470
+ );
3199
3471
  },
3200
3472
  setInputFiles() {
3201
3473
  return rejectUnsupported("setInputFiles");
3202
3474
  },
3203
3475
  // semantic locators ----------------------------------------------------
3204
3476
  async countByRole(role, name) {
3205
- return (await client.findElements({ by: "role", role, name })).length;
3477
+ return (await visibleElementIds({ by: "role", role, name })).length;
3206
3478
  },
3207
3479
  async clickFirstByRole(role, name) {
3208
3480
  const id = await client.findElement({ by: "role", role, name });
@@ -3212,7 +3484,7 @@ function createIosDriver(client, options) {
3212
3484
  await client.click(id);
3213
3485
  },
3214
3486
  async countByLabel(label) {
3215
- return (await client.findElements({ by: "label", value: label })).length;
3487
+ return (await visibleElementIds({ by: "label", value: label })).length;
3216
3488
  },
3217
3489
  async fillFirstByLabel(label, value) {
3218
3490
  const id = await client.findElement({ by: "label", value: label });
@@ -3230,7 +3502,7 @@ function createIosDriver(client, options) {
3230
3502
  const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
3231
3503
  const deadline = Date.now() + timeoutMs;
3232
3504
  for (; ; ) {
3233
- if ((await client.findElements(query)).length > 0) {
3505
+ if (await hasVisibleElement(query)) {
3234
3506
  return;
3235
3507
  }
3236
3508
  if (Date.now() >= deadline) {
@@ -3723,12 +3995,29 @@ function createWdaAgentClient(transport, sessionId) {
3723
3995
  const value = await transport.request("GET", `${base}/element/${elementId}/text`);
3724
3996
  return typeof value === "string" ? value : value == null ? null : String(value);
3725
3997
  },
3998
+ async isDisplayed(elementId) {
3999
+ try {
4000
+ return await transport.request("GET", `${base}/element/${elementId}/displayed`) === true;
4001
+ } catch (error) {
4002
+ if (isNoSuchElement2(error)) {
4003
+ return false;
4004
+ }
4005
+ throw error;
4006
+ }
4007
+ },
3726
4008
  async sendKeys(keys) {
3727
4009
  await transport.request("POST", `${base}/wda/keys`, { value: keys });
3728
4010
  },
3729
4011
  async homescreen() {
3730
4012
  await transport.request("POST", "/wda/homescreen", {});
3731
4013
  },
4014
+ async windowSize() {
4015
+ const value = await transport.request("GET", `${base}/window/size`);
4016
+ return toScreenSize(value, "WebDriverAgent /window/size");
4017
+ },
4018
+ async performActions(actions) {
4019
+ await transport.request("POST", `${base}/actions`, { actions: [actions] });
4020
+ },
3732
4021
  async source() {
3733
4022
  const value = await transport.request("GET", "/source");
3734
4023
  if (typeof value !== "string") {
@@ -4082,6 +4371,7 @@ async function closeIosSession(session) {
4082
4371
  var import_node_fs9 = __toESM(require("fs"), 1);
4083
4372
  var import_node_path10 = __toESM(require("path"), 1);
4084
4373
  init_loader();
4374
+ init_interpolate();
4085
4375
 
4086
4376
  // src/runner/healing.ts
4087
4377
  var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
@@ -4779,11 +5069,14 @@ function toVisibilitySelector(value) {
4779
5069
  if (looksLikeSelector(value)) return value;
4780
5070
  return textContainsSelector(value);
4781
5071
  }
5072
+ function countVisible(driver, selector) {
5073
+ return driver.visibleCount?.(selector) ?? driver.count(selector);
5074
+ }
4782
5075
  async function runInlineAssert(driver, policy, assertion) {
4783
5076
  if (assertion.visible !== void 0) {
4784
5077
  const selector = toVisibilitySelector(assertion.visible);
4785
5078
  policy.assertAllowedSelector(selector);
4786
- const count = await driver.count(selector);
5079
+ const count = await countVisible(driver, selector);
4787
5080
  if (count === 0) {
4788
5081
  throw new Error(`Expected visible: ${assertion.visible}`);
4789
5082
  }
@@ -4792,7 +5085,7 @@ async function runInlineAssert(driver, policy, assertion) {
4792
5085
  if (assertion.notVisible !== void 0) {
4793
5086
  const selector = toVisibilitySelector(assertion.notVisible);
4794
5087
  policy.assertAllowedSelector(selector);
4795
- const count = await driver.count(selector);
5088
+ const count = await countVisible(driver, selector);
4796
5089
  if (count > 0) {
4797
5090
  throw new Error(`Expected not visible: ${assertion.notVisible}`);
4798
5091
  }
@@ -5182,25 +5475,21 @@ var STEP_HANDLERS = {
5182
5475
  }
5183
5476
  },
5184
5477
  scroll: {
5185
- capabilities: ["evaluate"],
5478
+ // Dispatched through the driver's `scroll` verb (interact), so native mobile
5479
+ // targets synthesize a touch swipe while web keeps its `window.scrollBy`
5480
+ // behavior. The per-target step gate rejects `scroll` on macOS before here.
5481
+ capabilities: ["interact"],
5186
5482
  run: async (h) => {
5187
5483
  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]);
5484
+ const { direction, amount } = h.step.scroll;
5485
+ await h.driver.scroll(direction, amount);
5197
5486
  return {
5198
5487
  kind: "result",
5199
5488
  result: {
5200
5489
  type: "scroll",
5201
5490
  status: "pass",
5202
5491
  durationMs: Date.now() - h.stepStart,
5203
- value: `${h.step.scroll.direction} ${amount}px`
5492
+ value: amount === void 0 ? direction : `${direction} ${amount}px`
5204
5493
  }
5205
5494
  };
5206
5495
  }
@@ -5246,7 +5535,7 @@ var STEP_HANDLERS = {
5246
5535
  const condition = h.step.if;
5247
5536
  const selector = condition.visible ?? condition.notVisible;
5248
5537
  h.policy.assertAllowedSelector(selector);
5249
- const count = await h.driver.count(selector);
5538
+ const count = await countVisible(h.driver, selector);
5250
5539
  const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
5251
5540
  if (conditionMet) {
5252
5541
  const subResult = await h.executeNested({
@@ -5333,7 +5622,7 @@ var STEP_HANDLERS = {
5333
5622
  const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
5334
5623
  h.policy.assertAllowedSelector(whileSelector);
5335
5624
  for (let i = 0; i < maxIter; i++) {
5336
- const whileCount = await h.driver.count(whileSelector);
5625
+ const whileCount = await countVisible(h.driver, whileSelector);
5337
5626
  const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
5338
5627
  if (!shouldContinue) break;
5339
5628
  totalSubSteps += repeat.steps.length;