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/README.md +56 -7
- package/dist/{chunk-R7NUH44M.js → chunk-2RNOK64Y.js} +314 -399
- package/dist/chunk-2RNOK64Y.js.map +1 -0
- package/dist/{chunk-O3OUTZ2P.js → chunk-JFJQNJSJ.js} +394 -2
- package/dist/chunk-JFJQNJSJ.js.map +1 -0
- package/dist/index.cjs +1564 -692
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +597 -40
- package/dist/index.js.map +1 -1
- package/dist/lib.cjs +650 -346
- package/dist/lib.cjs.map +1 -1
- package/dist/lib.d.cts +95 -17
- package/dist/lib.d.ts +95 -17
- package/dist/lib.js +3 -3
- package/dist/{loader-X37URHUV.js → loader-JTHA4BYG.js} +2 -2
- package/examples/hunts/form.yml +53 -0
- package/examples/hunts/macos-hello.yml +49 -0
- package/package.json +1 -1
- package/dist/chunk-O3OUTZ2P.js.map +0 -1
- package/dist/chunk-R7NUH44M.js.map +0 -1
- /package/dist/{loader-X37URHUV.js.map → loader-JTHA4BYG.js.map} +0 -0
package/dist/index.cjs
CHANGED
|
@@ -398,575 +398,136 @@ var init_schema = __esm({
|
|
|
398
398
|
}
|
|
399
399
|
});
|
|
400
400
|
|
|
401
|
-
// src/config/
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
430
|
+
usedVars.push(name);
|
|
431
|
+
return varValue;
|
|
432
|
+
});
|
|
433
|
+
return { value, usedVars };
|
|
454
434
|
}
|
|
455
|
-
function
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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 (
|
|
470
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
|
|
498
|
+
fill: {
|
|
499
|
+
[labelResult.value]: valueResult.value
|
|
500
|
+
}
|
|
476
501
|
};
|
|
477
502
|
}
|
|
478
|
-
|
|
479
|
-
type
|
|
480
|
-
|
|
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
|
-
|
|
531
|
-
return allowed;
|
|
508
|
+
return { type: valueResult.value };
|
|
532
509
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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 (
|
|
541
|
-
|
|
515
|
+
if ("select" in step) {
|
|
516
|
+
return { select: interpolateSinglePair(step.select) };
|
|
542
517
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
548
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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 ("
|
|
837
|
+
if (type === "android") {
|
|
838
|
+
const androidTarget = target;
|
|
1123
839
|
return {
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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 ("
|
|
846
|
+
if (type === "ios") {
|
|
847
|
+
const iosTarget = target;
|
|
1131
848
|
return {
|
|
1132
|
-
|
|
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
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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 (
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
-
|
|
1148
|
-
|
|
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
|
|
929
|
+
return allowed;
|
|
1155
930
|
}
|
|
1156
|
-
function
|
|
1157
|
-
|
|
1158
|
-
|
|
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 (
|
|
1161
|
-
|
|
936
|
+
if (!import_node_fs.default.existsSync(resolvedPath)) {
|
|
937
|
+
throw new Error(`Config file not found at ${resolvedPath}`);
|
|
1162
938
|
}
|
|
1163
|
-
|
|
1164
|
-
|
|
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
|
-
|
|
1167
|
-
|
|
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
|
-
|
|
1170
|
-
|
|
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
|
-
|
|
1173
|
-
|
|
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
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
)
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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
|
|
1188
|
-
const
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
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
|
|
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
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
|
|
2425
|
-
|
|
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,
|
|
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}${
|
|
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} ${
|
|
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} ${
|
|
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,
|
|
3062
|
+
async function locate(query, path28) {
|
|
2860
3063
|
return transport.request(
|
|
2861
3064
|
"POST",
|
|
2862
|
-
`${base}${
|
|
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
|
-
|
|
3198
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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,
|
|
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}${
|
|
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} ${
|
|
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} ${
|
|
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,
|
|
3619
|
-
const parsed = await this.requestFull(method,
|
|
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,
|
|
3696
|
-
return transport.request("POST", `${base}${
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
5189
|
-
|
|
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: `${
|
|
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
|
|
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
|
|
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
|
-
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
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("
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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)}: ${
|
|
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
|
|
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: ${
|
|
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
|
|
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 ??
|
|
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 ??
|
|
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
|
|
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
|
|