prowl-tools 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/dist/{chunk-R7NUH44M.js → chunk-CWLRDV5P.js} +290 -399
- package/dist/chunk-CWLRDV5P.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 +930 -641
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/lib.cjs +626 -346
- package/dist/lib.cjs.map +1 -1
- package/dist/lib.d.cts +71 -17
- package/dist/lib.d.ts +71 -17
- package/dist/lib.js +3 -3
- package/dist/{loader-X37URHUV.js → loader-JTHA4BYG.js} +2 -2
- 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) {
|
|
678
|
+
if ("runScript" in step) {
|
|
679
|
+
return {
|
|
680
|
+
runScript: { file: interpolateString(step.runScript.file, vars).value }
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
if ("assertScreenshot" in step) {
|
|
684
|
+
return {
|
|
685
|
+
assertScreenshot: {
|
|
686
|
+
name: interpolateString(step.assertScreenshot.name, vars).value,
|
|
687
|
+
...step.assertScreenshot.threshold !== void 0 ? { threshold: step.assertScreenshot.threshold } : {}
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
if ("assertWithAI" in step) {
|
|
692
|
+
return {
|
|
693
|
+
assertWithAI: interpolateString(step.assertWithAI, vars).value
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
if ("copyText" in step) {
|
|
697
|
+
return {
|
|
698
|
+
copyText: {
|
|
699
|
+
selector: interpolateString(step.copyText.selector, vars).value,
|
|
700
|
+
as: step.copyText.as
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
if ("waitForDownload" in step) {
|
|
705
|
+
if (step.waitForDownload === null) {
|
|
706
|
+
return { waitForDownload: null };
|
|
707
|
+
}
|
|
708
|
+
return {
|
|
709
|
+
waitForDownload: {
|
|
710
|
+
...step.waitForDownload.filename !== void 0 ? { filename: interpolateString(step.waitForDownload.filename, vars).value } : {},
|
|
711
|
+
...step.waitForDownload.timeout !== void 0 ? { timeout: step.waitForDownload.timeout } : {}
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
return step;
|
|
716
|
+
}
|
|
717
|
+
function interpolateAssertion(assertion, vars) {
|
|
718
|
+
if ("selectorExists" in assertion) {
|
|
719
|
+
return { selectorExists: interpolateString(assertion.selectorExists, vars).value };
|
|
720
|
+
}
|
|
721
|
+
if ("selectorNotExists" in assertion) {
|
|
722
|
+
return { selectorNotExists: interpolateString(assertion.selectorNotExists, vars).value };
|
|
723
|
+
}
|
|
724
|
+
if ("urlIncludes" in assertion) {
|
|
725
|
+
return { urlIncludes: interpolateString(assertion.urlIncludes, vars).value };
|
|
726
|
+
}
|
|
727
|
+
if ("urlEquals" in assertion) {
|
|
728
|
+
return { urlEquals: interpolateString(assertion.urlEquals, vars).value };
|
|
729
|
+
}
|
|
730
|
+
if ("noConsoleErrors" in assertion) {
|
|
731
|
+
return { noConsoleErrors: assertion.noConsoleErrors };
|
|
732
|
+
}
|
|
733
|
+
if ("noNetworkErrors" in assertion) {
|
|
734
|
+
return { noNetworkErrors: assertion.noNetworkErrors };
|
|
735
|
+
}
|
|
736
|
+
return assertion;
|
|
737
|
+
}
|
|
738
|
+
function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
|
|
739
|
+
const redactedFillSteps = /* @__PURE__ */ new Set();
|
|
740
|
+
const envVars = Object.fromEntries(
|
|
741
|
+
Object.entries(env).filter(([, value]) => value !== void 0)
|
|
742
|
+
);
|
|
743
|
+
const baseVars = { ...randomVars, ...envVars };
|
|
744
|
+
const resolvedHuntVars = {};
|
|
745
|
+
for (const [key, value] of Object.entries(hunt.vars ?? {})) {
|
|
746
|
+
resolvedHuntVars[key] = interpolateString(value, baseVars).value;
|
|
747
|
+
}
|
|
748
|
+
const vars = { ...baseVars, ...resolvedHuntVars };
|
|
749
|
+
const redactionValues = /* @__PURE__ */ new Set();
|
|
750
|
+
collectInterpolatedValues(hunt.steps, vars, redactionValues);
|
|
751
|
+
collectInterpolatedValues(hunt.assertions, vars, redactionValues);
|
|
752
|
+
const steps = hunt.steps.map(
|
|
753
|
+
(step, index) => interpolateStep(step, vars, `${index}`, redactedFillSteps)
|
|
754
|
+
);
|
|
755
|
+
const assertions = hunt.assertions?.map((assertion) => interpolateAssertion(assertion, vars));
|
|
756
|
+
return {
|
|
757
|
+
hunt: {
|
|
758
|
+
...hunt,
|
|
759
|
+
steps,
|
|
760
|
+
assertions
|
|
761
|
+
},
|
|
762
|
+
redactedFillSteps,
|
|
763
|
+
randomVars,
|
|
764
|
+
redactionValues: [...redactionValues]
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
var import_node_crypto, VAR_PATTERN, RANDOM_FIRST_NAMES, RANDOM_LAST_NAMES;
|
|
768
|
+
var init_interpolate = __esm({
|
|
769
|
+
"src/config/interpolate.ts"() {
|
|
770
|
+
"use strict";
|
|
771
|
+
import_node_crypto = __toESM(require("crypto"), 1);
|
|
772
|
+
VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
|
|
773
|
+
RANDOM_FIRST_NAMES = ["Alex", "Jordan", "Morgan", "Taylor", "Casey", "Riley", "Quinn", "Avery"];
|
|
774
|
+
RANDOM_LAST_NAMES = ["Smith", "Johnson", "Brown", "Davis", "Wilson", "Clark", "Hall", "Young"];
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// src/config/loader.ts
|
|
779
|
+
var loader_exports = {};
|
|
780
|
+
__export(loader_exports, {
|
|
781
|
+
CONFIG_DIR: () => CONFIG_DIR,
|
|
782
|
+
LEGACY_CONFIG_DIR: () => LEGACY_CONFIG_DIR,
|
|
783
|
+
ensureAllowedDomain: () => ensureAllowedDomain,
|
|
784
|
+
findConfigPath: () => findConfigPath,
|
|
785
|
+
listHunts: () => listHunts,
|
|
786
|
+
loadConfig: () => loadConfig,
|
|
787
|
+
loadHunt: () => loadHunt,
|
|
788
|
+
loadHuntMeta: () => loadHuntMeta,
|
|
789
|
+
loadHuntTags: () => loadHuntTags,
|
|
790
|
+
resolveViewport: () => resolveViewport,
|
|
791
|
+
warnLegacyConfigDir: () => warnLegacyConfigDir
|
|
792
|
+
});
|
|
793
|
+
function warnLegacyConfigDir() {
|
|
794
|
+
if (legacyDirWarned) {
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
legacyDirWarned = true;
|
|
798
|
+
console.warn(
|
|
799
|
+
'Warning: the ".prowlqa/" config directory is deprecated; rename it to ".prowl/". Support for ".prowlqa/" will be removed in a future release.'
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
function findConfigPath(startDir) {
|
|
803
|
+
let current = startDir;
|
|
804
|
+
while (current) {
|
|
805
|
+
for (const dir of [CONFIG_DIR, LEGACY_CONFIG_DIR]) {
|
|
806
|
+
const candidate = import_node_path.default.join(current, dir, "config.yml");
|
|
807
|
+
if (import_node_fs.default.existsSync(candidate)) {
|
|
808
|
+
return candidate;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
const parent = import_node_path.default.dirname(current);
|
|
812
|
+
if (parent === current) {
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
current = parent;
|
|
816
|
+
}
|
|
817
|
+
return null;
|
|
818
|
+
}
|
|
819
|
+
function resolveViewport(value) {
|
|
820
|
+
if (value === void 0) {
|
|
821
|
+
return DEFAULT_CONFIG.browser.viewport;
|
|
822
|
+
}
|
|
823
|
+
if (typeof value === "string") {
|
|
824
|
+
const preset = VIEWPORT_PRESETS[value];
|
|
825
|
+
if (!preset) {
|
|
826
|
+
throw new Error(`Unknown viewport preset: "${value}". Use mobile, tablet, or desktop.`);
|
|
827
|
+
}
|
|
828
|
+
return preset;
|
|
829
|
+
}
|
|
830
|
+
return value;
|
|
831
|
+
}
|
|
832
|
+
function resolveTarget(target) {
|
|
833
|
+
const type = target?.type;
|
|
834
|
+
if (type === "macos") {
|
|
835
|
+
return { type: "macos", app: target.app };
|
|
836
|
+
}
|
|
837
|
+
if (type === "android") {
|
|
838
|
+
const androidTarget = target;
|
|
839
|
+
return {
|
|
840
|
+
type: "android",
|
|
841
|
+
app: androidTarget.app,
|
|
842
|
+
...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
|
|
843
|
+
...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
if (type === "ios") {
|
|
847
|
+
const iosTarget = target;
|
|
1118
848
|
return {
|
|
1119
|
-
|
|
849
|
+
type: "ios",
|
|
850
|
+
app: iosTarget.app,
|
|
851
|
+
...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
|
|
852
|
+
...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
|
|
1120
853
|
};
|
|
1121
854
|
}
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
855
|
+
return {
|
|
856
|
+
type: "web",
|
|
857
|
+
url: target?.url ?? DEFAULT_WEB_URL
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
function mergeConfig(partial) {
|
|
861
|
+
return {
|
|
862
|
+
target: resolveTarget(partial.target),
|
|
863
|
+
browser: {
|
|
864
|
+
headless: partial.browser?.headless ?? DEFAULT_CONFIG.browser.headless,
|
|
865
|
+
slowMo: partial.browser?.slowMo ?? DEFAULT_CONFIG.browser.slowMo,
|
|
866
|
+
timeout: partial.browser?.timeout ?? DEFAULT_CONFIG.browser.timeout,
|
|
867
|
+
engine: partial.browser?.engine ?? DEFAULT_CONFIG.browser.engine,
|
|
868
|
+
channel: partial.browser?.channel,
|
|
869
|
+
viewport: resolveViewport(partial.browser?.viewport)
|
|
870
|
+
},
|
|
871
|
+
artifacts: {
|
|
872
|
+
screenshots: partial.artifacts?.screenshots ?? DEFAULT_CONFIG.artifacts.screenshots,
|
|
873
|
+
networkHar: partial.artifacts?.networkHar ?? DEFAULT_CONFIG.artifacts.networkHar,
|
|
874
|
+
console: partial.artifacts?.console ?? DEFAULT_CONFIG.artifacts.console,
|
|
875
|
+
junit: partial.artifacts?.junit ?? DEFAULT_CONFIG.artifacts.junit
|
|
876
|
+
},
|
|
877
|
+
assertions: {
|
|
878
|
+
noConsoleErrors: partial.assertions?.noConsoleErrors ?? DEFAULT_CONFIG.assertions.noConsoleErrors,
|
|
879
|
+
noNetworkErrors: partial.assertions?.noNetworkErrors ?? DEFAULT_CONFIG.assertions.noNetworkErrors,
|
|
880
|
+
maxTotalTimeMs: partial.assertions?.maxTotalTimeMs ?? DEFAULT_CONFIG.assertions.maxTotalTimeMs,
|
|
881
|
+
networkIgnorePatterns: partial.assertions?.networkIgnorePatterns ?? DEFAULT_CONFIG.assertions.networkIgnorePatterns
|
|
882
|
+
},
|
|
883
|
+
guardrails: {
|
|
884
|
+
maxSteps: partial.guardrails?.maxSteps ?? DEFAULT_CONFIG.guardrails.maxSteps,
|
|
885
|
+
allowedDomains: partial.guardrails?.allowedDomains ?? DEFAULT_CONFIG.guardrails.allowedDomains,
|
|
886
|
+
allowedApps: partial.guardrails?.allowedApps ?? DEFAULT_CONFIG.guardrails.allowedApps,
|
|
887
|
+
forbiddenSelectors: partial.guardrails?.forbiddenSelectors ?? DEFAULT_CONFIG.guardrails.forbiddenSelectors,
|
|
888
|
+
selfHealing: partial.guardrails?.selfHealing ?? DEFAULT_CONFIG.guardrails.selfHealing
|
|
889
|
+
},
|
|
890
|
+
auth: {
|
|
891
|
+
storageStatePath: partial.auth?.storageStatePath ?? (partial.auth !== void 0 ? DEFAULT_CONFIG.auth.storageStatePath : void 0)
|
|
892
|
+
},
|
|
893
|
+
history: {
|
|
894
|
+
maxRuns: partial.history?.maxRuns ?? DEFAULT_CONFIG.history.maxRuns
|
|
895
|
+
},
|
|
896
|
+
bugLog: partial.bugLog,
|
|
897
|
+
tracing: partial.tracing,
|
|
898
|
+
reliability: partial.reliability
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
function envStringVars(env) {
|
|
902
|
+
return Object.fromEntries(
|
|
903
|
+
Object.entries(env).filter(([, value]) => value !== void 0)
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
function interpolateConfigStrings(value, vars) {
|
|
907
|
+
if (typeof value === "string") {
|
|
908
|
+
return interpolateString(value, vars).value;
|
|
1129
909
|
}
|
|
1130
|
-
if (
|
|
1131
|
-
return
|
|
1132
|
-
assertWithAI: interpolateString(step.assertWithAI, vars).value
|
|
1133
|
-
};
|
|
910
|
+
if (Array.isArray(value)) {
|
|
911
|
+
return value.map((item) => interpolateConfigStrings(item, vars));
|
|
1134
912
|
}
|
|
1135
|
-
if ("
|
|
1136
|
-
return
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
as: step.copyText.as
|
|
1140
|
-
}
|
|
1141
|
-
};
|
|
913
|
+
if (value && typeof value === "object") {
|
|
914
|
+
return Object.fromEntries(
|
|
915
|
+
Object.entries(value).map(([key, item]) => [key, interpolateConfigStrings(item, vars)])
|
|
916
|
+
);
|
|
1142
917
|
}
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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 [];
|
|
1174
973
|
}
|
|
1175
|
-
|
|
974
|
+
const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
|
|
975
|
+
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
976
|
+
return Array.isArray(parsed.tags) ? parsed.tags : [];
|
|
1176
977
|
}
|
|
1177
|
-
function
|
|
1178
|
-
|
|
1179
|
-
const
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
const
|
|
1184
|
-
|
|
1185
|
-
|
|
978
|
+
function loadHuntMeta(huntName, configDir) {
|
|
979
|
+
assertValidHuntName(huntName);
|
|
980
|
+
const huntPath = import_node_path.default.join(configDir, "hunts", `${huntName}.yml`);
|
|
981
|
+
if (!import_node_fs.default.existsSync(huntPath)) {
|
|
982
|
+
return { tags: [] };
|
|
983
|
+
}
|
|
984
|
+
const raw = import_node_fs.default.readFileSync(huntPath, "utf-8");
|
|
985
|
+
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
986
|
+
return {
|
|
987
|
+
description: typeof parsed.description === "string" ? parsed.description : void 0,
|
|
988
|
+
tags: Array.isArray(parsed.tags) ? parsed.tags : []
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
function listHunts(configDir) {
|
|
992
|
+
const huntsDir = import_node_path.default.join(configDir, "hunts");
|
|
993
|
+
if (!import_node_fs.default.existsSync(huntsDir)) {
|
|
994
|
+
return [];
|
|
995
|
+
}
|
|
996
|
+
const stats = import_node_fs.default.statSync(huntsDir);
|
|
997
|
+
if (!stats.isDirectory()) {
|
|
998
|
+
throw new Error(`Hunts path is not a directory: ${huntsDir}`);
|
|
999
|
+
}
|
|
1000
|
+
const results = [];
|
|
1001
|
+
function scanDir(dir) {
|
|
1002
|
+
const entries = import_node_fs.default.readdirSync(dir, { withFileTypes: true });
|
|
1003
|
+
for (const entry of entries) {
|
|
1004
|
+
if (entry.isFile() && entry.name.endsWith(".yml")) {
|
|
1005
|
+
const fullPath = import_node_path.default.join(dir, entry.name);
|
|
1006
|
+
const relative = import_node_path.default.relative(huntsDir, fullPath);
|
|
1007
|
+
results.push(relative.replace(/\.yml$/, ""));
|
|
1008
|
+
} else if (entry.isDirectory()) {
|
|
1009
|
+
scanDir(import_node_path.default.join(dir, entry.name));
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
scanDir(huntsDir);
|
|
1014
|
+
return results.sort((a, b) => a.localeCompare(b));
|
|
1015
|
+
}
|
|
1016
|
+
var import_node_fs, import_node_path, import_yaml, import_dotenv, DEFAULT_WEB_URL, DEFAULT_CONFIG, CONFIG_DIR, LEGACY_CONFIG_DIR, legacyDirWarned, VIEWPORT_PRESETS;
|
|
1017
|
+
var init_loader = __esm({
|
|
1018
|
+
"src/config/loader.ts"() {
|
|
1019
|
+
"use strict";
|
|
1020
|
+
import_node_fs = __toESM(require("fs"), 1);
|
|
1021
|
+
import_node_path = __toESM(require("path"), 1);
|
|
1022
|
+
import_yaml = __toESM(require("yaml"), 1);
|
|
1023
|
+
import_dotenv = __toESM(require("dotenv"), 1);
|
|
1024
|
+
init_schema();
|
|
1025
|
+
init_hunt_name();
|
|
1026
|
+
init_interpolate();
|
|
1027
|
+
DEFAULT_WEB_URL = "http://localhost:3000";
|
|
1028
|
+
DEFAULT_CONFIG = {
|
|
1029
|
+
target: {
|
|
1030
|
+
type: "web",
|
|
1031
|
+
url: DEFAULT_WEB_URL
|
|
1032
|
+
},
|
|
1033
|
+
browser: {
|
|
1034
|
+
headless: true,
|
|
1035
|
+
slowMo: 0,
|
|
1036
|
+
timeout: 3e4,
|
|
1037
|
+
engine: "chromium",
|
|
1038
|
+
viewport: { width: 1280, height: 720 }
|
|
1039
|
+
},
|
|
1040
|
+
artifacts: {
|
|
1041
|
+
screenshots: "on-failure",
|
|
1042
|
+
networkHar: false,
|
|
1043
|
+
console: true,
|
|
1044
|
+
junit: false
|
|
1045
|
+
},
|
|
1046
|
+
assertions: {
|
|
1047
|
+
noConsoleErrors: true,
|
|
1048
|
+
noNetworkErrors: true,
|
|
1049
|
+
maxTotalTimeMs: 3e4,
|
|
1050
|
+
networkIgnorePatterns: []
|
|
1051
|
+
},
|
|
1052
|
+
guardrails: {
|
|
1053
|
+
maxSteps: 50,
|
|
1054
|
+
allowedDomains: ["localhost", "127.0.0.1", "0.0.0.0"],
|
|
1055
|
+
allowedApps: [],
|
|
1056
|
+
forbiddenSelectors: ["[data-danger]", ".delete-btn"],
|
|
1057
|
+
selfHealing: false
|
|
1058
|
+
},
|
|
1059
|
+
auth: {
|
|
1060
|
+
storageStatePath: ".prowl/auth-state.json"
|
|
1061
|
+
},
|
|
1062
|
+
history: {
|
|
1063
|
+
maxRuns: 100
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
CONFIG_DIR = ".prowl";
|
|
1067
|
+
LEGACY_CONFIG_DIR = ".prowlqa";
|
|
1068
|
+
legacyDirWarned = false;
|
|
1069
|
+
VIEWPORT_PRESETS = {
|
|
1070
|
+
mobile: { width: 375, height: 812 },
|
|
1071
|
+
tablet: { width: 768, height: 1024 },
|
|
1072
|
+
desktop: { width: 1280, height: 720 }
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
// src/runner/visual.ts
|
|
1078
|
+
var visual_exports = {};
|
|
1079
|
+
__export(visual_exports, {
|
|
1080
|
+
compareScreenshots: () => compareScreenshots,
|
|
1081
|
+
ensureBaselineDir: () => ensureBaselineDir
|
|
1082
|
+
});
|
|
1083
|
+
async function compareScreenshots(baselinePath, currentPath, diffPath, threshold) {
|
|
1084
|
+
const baselineData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(baselinePath));
|
|
1085
|
+
const currentData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(currentPath));
|
|
1086
|
+
const { width, height } = baselineData;
|
|
1087
|
+
if (currentData.width !== width || currentData.height !== height) {
|
|
1088
|
+
const diff2 = new import_pngjs.PNG({ width, height });
|
|
1089
|
+
import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
|
|
1090
|
+
return {
|
|
1091
|
+
match: false,
|
|
1092
|
+
diffPercentage: 1,
|
|
1093
|
+
diffImagePath: diffPath
|
|
1094
|
+
};
|
|
1186
1095
|
}
|
|
1187
|
-
const
|
|
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_commander14 = require("commander");
|
|
1132
|
+
|
|
1133
|
+
// package.json
|
|
1134
|
+
var package_default = {
|
|
1135
|
+
name: "prowl-tools",
|
|
1136
|
+
version: "0.1.8",
|
|
1137
|
+
description: "E2E testing for native macOS apps and web apps from declarative YAML hunts.",
|
|
1138
|
+
type: "module",
|
|
1139
|
+
license: "Apache-2.0",
|
|
1140
|
+
author: "Prowl Tools",
|
|
1141
|
+
repository: {
|
|
1142
|
+
type: "git",
|
|
1143
|
+
url: "https://github.com/prowl-tools/prowl.git"
|
|
1144
|
+
},
|
|
1145
|
+
homepage: "https://prowl.tools",
|
|
1146
|
+
bugs: {
|
|
1147
|
+
url: "https://github.com/prowl-tools/prowl/issues"
|
|
1148
|
+
},
|
|
1149
|
+
keywords: [
|
|
1150
|
+
"testing",
|
|
1151
|
+
"qa",
|
|
1152
|
+
"e2e",
|
|
1153
|
+
"macos",
|
|
1154
|
+
"desktop-testing",
|
|
1155
|
+
"accessibility",
|
|
1156
|
+
"playwright",
|
|
1157
|
+
"yaml",
|
|
1158
|
+
"browser-testing",
|
|
1159
|
+
"automation",
|
|
1160
|
+
"web-testing",
|
|
1161
|
+
"cli"
|
|
1162
|
+
],
|
|
1163
|
+
main: "dist/lib.cjs",
|
|
1164
|
+
module: "dist/lib.js",
|
|
1165
|
+
types: "dist/lib.d.ts",
|
|
1166
|
+
exports: {
|
|
1167
|
+
".": {
|
|
1168
|
+
import: {
|
|
1169
|
+
types: "./dist/lib.d.ts",
|
|
1170
|
+
default: "./dist/lib.js"
|
|
1171
|
+
},
|
|
1172
|
+
require: {
|
|
1173
|
+
types: "./dist/lib.d.cts",
|
|
1174
|
+
default: "./dist/lib.cjs"
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
},
|
|
1178
|
+
bin: {
|
|
1179
|
+
prowl: "dist/index.js"
|
|
1180
|
+
},
|
|
1181
|
+
files: [
|
|
1182
|
+
"dist",
|
|
1183
|
+
"examples",
|
|
1184
|
+
"LICENSE",
|
|
1185
|
+
"README.md",
|
|
1186
|
+
"NOTICE"
|
|
1187
|
+
],
|
|
1188
|
+
engines: {
|
|
1189
|
+
node: ">=20.0.0"
|
|
1190
|
+
},
|
|
1191
|
+
scripts: {
|
|
1192
|
+
build: "tsup",
|
|
1193
|
+
lint: "eslint .",
|
|
1194
|
+
test: "vitest run",
|
|
1195
|
+
"test:watch": "vitest"
|
|
1196
|
+
},
|
|
1197
|
+
dependencies: {
|
|
1198
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
1199
|
+
chalk: "^5.3.0",
|
|
1200
|
+
commander: "^12.1.0",
|
|
1201
|
+
dotenv: "^16.6.1",
|
|
1202
|
+
ora: "^8.1.1",
|
|
1203
|
+
pixelmatch: "^7.1.0",
|
|
1204
|
+
playwright: "^1.50.1",
|
|
1205
|
+
pngjs: "^7.0.0",
|
|
1206
|
+
yaml: "^2.6.1",
|
|
1207
|
+
zod: "^3.23.8"
|
|
1208
|
+
},
|
|
1209
|
+
optionalDependencies: {
|
|
1210
|
+
"appium-uiautomator2-server": "10.6.2",
|
|
1211
|
+
"appium-webdriveragent": "16.4.0"
|
|
1212
|
+
},
|
|
1213
|
+
devDependencies: {
|
|
1214
|
+
"@types/node": "^22.13.1",
|
|
1215
|
+
"@types/pngjs": "^6.0.5",
|
|
1216
|
+
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
1217
|
+
"@typescript-eslint/parser": "^7.18.0",
|
|
1218
|
+
eslint: "^8.57.1",
|
|
1219
|
+
tsup: "^8.3.5",
|
|
1220
|
+
typescript: "^5.7.3",
|
|
1221
|
+
vitest: "^2.1.8"
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
|
|
1225
|
+
// src/cli/commands/run.ts
|
|
1226
|
+
var import_commander = require("commander");
|
|
1227
|
+
var import_chalk3 = __toESM(require("chalk"), 1);
|
|
1228
|
+
|
|
1229
|
+
// src/runner/index.ts
|
|
1230
|
+
var import_node_fs14 = __toESM(require("fs"), 1);
|
|
1231
|
+
var import_node_path15 = __toESM(require("path"), 1);
|
|
1232
|
+
init_loader();
|
|
1233
|
+
init_interpolate();
|
|
1206
1234
|
|
|
1207
1235
|
// src/config/target.ts
|
|
1208
1236
|
var import_node_child_process = require("child_process");
|
|
@@ -1220,10 +1248,17 @@ var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
|
|
|
1220
1248
|
"select",
|
|
1221
1249
|
"selectOption",
|
|
1222
1250
|
"setInputFiles",
|
|
1223
|
-
"waitForDownload"
|
|
1224
|
-
"scroll"
|
|
1225
|
-
// directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
|
|
1251
|
+
"waitForDownload"
|
|
1226
1252
|
]);
|
|
1253
|
+
var MACOS_UNSUPPORTED_STEP_TYPES = /* @__PURE__ */ new Set(["scroll"]);
|
|
1254
|
+
function macosUnsupportedReason(step) {
|
|
1255
|
+
for (const type of MACOS_UNSUPPORTED_STEP_TYPES) {
|
|
1256
|
+
if (type in step) {
|
|
1257
|
+
return type;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return null;
|
|
1261
|
+
}
|
|
1227
1262
|
function webOnlyReason(step) {
|
|
1228
1263
|
for (const type of WEB_ONLY_STEP_TYPES) {
|
|
1229
1264
|
if (type in step) {
|
|
@@ -1259,6 +1294,14 @@ function assertStepsSupportedByTarget(steps, target) {
|
|
|
1259
1294
|
`Step "${reason}" is not supported by the ${label} target. It is web-only; use a portable step (click, fill, type, press, wait, assert visible, screenshot, etc.).`
|
|
1260
1295
|
);
|
|
1261
1296
|
}
|
|
1297
|
+
if (target === "macos") {
|
|
1298
|
+
const macReason = macosUnsupportedReason(step);
|
|
1299
|
+
if (macReason) {
|
|
1300
|
+
throw new Error(
|
|
1301
|
+
`Step "${macReason}" is not supported by the macOS target. Directional scroll is a touch swipe available on the iOS and Android targets; there is no macOS accessibility equivalent (use scrollTo to bring a specific element into view instead).`
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1262
1305
|
if ("if" in step) {
|
|
1263
1306
|
assertStepsSupportedByTarget(step.if.then, target);
|
|
1264
1307
|
if (step.if.else) {
|
|
@@ -1542,6 +1585,16 @@ function createPlaywrightDriver(page) {
|
|
|
1542
1585
|
async hover(selector) {
|
|
1543
1586
|
await page.locator(selector).hover();
|
|
1544
1587
|
},
|
|
1588
|
+
async scroll(direction, amount = 500) {
|
|
1589
|
+
const deltas = {
|
|
1590
|
+
up: [0, -amount],
|
|
1591
|
+
down: [0, amount],
|
|
1592
|
+
left: [-amount, 0],
|
|
1593
|
+
right: [amount, 0]
|
|
1594
|
+
};
|
|
1595
|
+
const [x, y] = deltas[direction];
|
|
1596
|
+
await page.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
|
|
1597
|
+
},
|
|
1545
1598
|
async scrollIntoView(selector) {
|
|
1546
1599
|
await page.locator(selector).scrollIntoViewIfNeeded();
|
|
1547
1600
|
},
|
|
@@ -1736,6 +1789,9 @@ function createMacDriver(client, options = {}) {
|
|
|
1736
1789
|
async hover(selector) {
|
|
1737
1790
|
await query("hover", selector);
|
|
1738
1791
|
},
|
|
1792
|
+
scroll() {
|
|
1793
|
+
return rejectUnsupported("scroll");
|
|
1794
|
+
},
|
|
1739
1795
|
async scrollIntoView(selector) {
|
|
1740
1796
|
await query("scrollTo", selector);
|
|
1741
1797
|
},
|
|
@@ -2269,6 +2325,101 @@ function rankNativeSelectors(fields) {
|
|
|
2269
2325
|
return selectors;
|
|
2270
2326
|
}
|
|
2271
2327
|
|
|
2328
|
+
// src/browser/touch-gestures.ts
|
|
2329
|
+
var DEFAULT_SWIPE_FRACTION = 0.75;
|
|
2330
|
+
var SCROLL_TO_PROBE_SWIPE_FRACTION = 0.6;
|
|
2331
|
+
var MAX_SWIPE_FRACTION = 0.9;
|
|
2332
|
+
var SWIPE_HOLD_MS = 100;
|
|
2333
|
+
var SWIPE_MOVE_DURATION_MS = 300;
|
|
2334
|
+
var SCROLL_TO_SWEEP_DEPTH = 10;
|
|
2335
|
+
var SCROLL_TO_PROBE_DIRECTIONS = [
|
|
2336
|
+
...Array.from({ length: SCROLL_TO_SWEEP_DEPTH }, () => "down"),
|
|
2337
|
+
...Array.from({ length: SCROLL_TO_SWEEP_DEPTH * 2 }, () => "up")
|
|
2338
|
+
];
|
|
2339
|
+
var MAX_SCROLL_TO_SWIPES = SCROLL_TO_PROBE_DIRECTIONS.length;
|
|
2340
|
+
var OPPOSITE_SWIPE_DIRECTIONS = {
|
|
2341
|
+
up: "down",
|
|
2342
|
+
down: "up",
|
|
2343
|
+
left: "right",
|
|
2344
|
+
right: "left"
|
|
2345
|
+
};
|
|
2346
|
+
async function probeScrollIntoView({
|
|
2347
|
+
isVisible,
|
|
2348
|
+
swipe,
|
|
2349
|
+
directions = SCROLL_TO_PROBE_DIRECTIONS
|
|
2350
|
+
}) {
|
|
2351
|
+
if (await isVisible()) {
|
|
2352
|
+
return true;
|
|
2353
|
+
}
|
|
2354
|
+
for (const direction of directions) {
|
|
2355
|
+
await swipe(direction);
|
|
2356
|
+
if (await isVisible()) {
|
|
2357
|
+
return true;
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
return false;
|
|
2361
|
+
}
|
|
2362
|
+
function toScreenSize(value, source) {
|
|
2363
|
+
const record = value ?? {};
|
|
2364
|
+
const width = Number(record.width);
|
|
2365
|
+
const height = Number(record.height);
|
|
2366
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
2367
|
+
throw new Error(`${source} did not return a usable screen size`);
|
|
2368
|
+
}
|
|
2369
|
+
return { width, height };
|
|
2370
|
+
}
|
|
2371
|
+
function isVertical(direction) {
|
|
2372
|
+
return direction === "up" || direction === "down";
|
|
2373
|
+
}
|
|
2374
|
+
function swipeDistanceFor(direction, size, amount) {
|
|
2375
|
+
if (amount !== void 0 && !Number.isFinite(amount)) {
|
|
2376
|
+
throw new Error("scroll amount must be a finite number");
|
|
2377
|
+
}
|
|
2378
|
+
const axis = isVertical(direction) ? size.height : size.width;
|
|
2379
|
+
const requested = amount === void 0 ? axis * DEFAULT_SWIPE_FRACTION : Math.abs(amount);
|
|
2380
|
+
const max = axis * MAX_SWIPE_FRACTION;
|
|
2381
|
+
return Math.max(1, Math.round(Math.min(requested, max)));
|
|
2382
|
+
}
|
|
2383
|
+
function scrollToProbeDistanceFor(direction, size) {
|
|
2384
|
+
const axis = isVertical(direction) ? size.height : size.width;
|
|
2385
|
+
return Math.max(1, Math.round(axis * SCROLL_TO_PROBE_SWIPE_FRACTION));
|
|
2386
|
+
}
|
|
2387
|
+
function swipeEndpoints(direction, size, distance) {
|
|
2388
|
+
const cx = Math.round(size.width / 2);
|
|
2389
|
+
const cy = Math.round(size.height / 2);
|
|
2390
|
+
const half = Math.round(distance / 2);
|
|
2391
|
+
switch (direction) {
|
|
2392
|
+
case "down":
|
|
2393
|
+
return { start: { x: cx, y: cy + half }, end: { x: cx, y: cy - half } };
|
|
2394
|
+
case "up":
|
|
2395
|
+
return { start: { x: cx, y: cy - half }, end: { x: cx, y: cy + half } };
|
|
2396
|
+
case "right":
|
|
2397
|
+
return { start: { x: cx + half, y: cy }, end: { x: cx - half, y: cy } };
|
|
2398
|
+
case "left":
|
|
2399
|
+
return { start: { x: cx - half, y: cy }, end: { x: cx + half, y: cy } };
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
function buildSwipeActions(start, end) {
|
|
2403
|
+
return {
|
|
2404
|
+
type: "pointer",
|
|
2405
|
+
id: "finger1",
|
|
2406
|
+
parameters: { pointerType: "touch" },
|
|
2407
|
+
actions: [
|
|
2408
|
+
{ type: "pointerMove", duration: 0, x: start.x, y: start.y, origin: "viewport" },
|
|
2409
|
+
{ type: "pointerDown", button: 0 },
|
|
2410
|
+
{ type: "pause", duration: SWIPE_HOLD_MS },
|
|
2411
|
+
{ type: "pointerMove", duration: SWIPE_MOVE_DURATION_MS, x: end.x, y: end.y, origin: "viewport" },
|
|
2412
|
+
{ type: "pointerUp", button: 0 }
|
|
2413
|
+
]
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
function buildDirectionalSwipe(direction, size, amount) {
|
|
2417
|
+
const normalizedDirection = amount !== void 0 && amount < 0 ? OPPOSITE_SWIPE_DIRECTIONS[direction] : direction;
|
|
2418
|
+
const distance = swipeDistanceFor(normalizedDirection, size, amount);
|
|
2419
|
+
const { start, end } = swipeEndpoints(normalizedDirection, size, distance);
|
|
2420
|
+
return { actions: buildSwipeActions(start, end), distance, start, end };
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2272
2423
|
// src/browser/android-driver.ts
|
|
2273
2424
|
var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
2274
2425
|
"query",
|
|
@@ -2384,6 +2535,11 @@ function createAndroidDriver(client, options = {}) {
|
|
|
2384
2535
|
async function fillSelector(selector, value) {
|
|
2385
2536
|
await client.setValue(await resolveOne(selector), value);
|
|
2386
2537
|
}
|
|
2538
|
+
async function swipe(direction, amount, size) {
|
|
2539
|
+
const actualSize = size ?? await client.windowSize();
|
|
2540
|
+
const { actions } = buildDirectionalSwipe(direction, actualSize, amount);
|
|
2541
|
+
await client.performActions(actions);
|
|
2542
|
+
}
|
|
2387
2543
|
return {
|
|
2388
2544
|
capabilities: ANDROID_CAPABILITIES,
|
|
2389
2545
|
// navigation -----------------------------------------------------------
|
|
@@ -2421,8 +2577,31 @@ function createAndroidDriver(client, options = {}) {
|
|
|
2421
2577
|
hover() {
|
|
2422
2578
|
return rejectUnsupported("hover");
|
|
2423
2579
|
},
|
|
2424
|
-
|
|
2425
|
-
|
|
2580
|
+
// Screen-centred swipe via the W3C actions endpoint (PROWL-080). Direction
|
|
2581
|
+
// semantics match the web step: scrolling "down" reveals lower content, so
|
|
2582
|
+
// the finger drags up. See ./touch-gestures.ts.
|
|
2583
|
+
async scroll(direction, amount) {
|
|
2584
|
+
await swipe(direction, amount);
|
|
2585
|
+
},
|
|
2586
|
+
// Resolve the element, short-circuiting if it is already present in the
|
|
2587
|
+
// hierarchy; otherwise use the shared bounded down/up mobile probe before
|
|
2588
|
+
// failing with a message naming the selector and attempts.
|
|
2589
|
+
async scrollIntoView(selector) {
|
|
2590
|
+
const query = parseAndroidSelector(selector);
|
|
2591
|
+
let probeSize;
|
|
2592
|
+
const found = await probeScrollIntoView({
|
|
2593
|
+
isVisible: async () => (await client.findElements(query)).length > 0,
|
|
2594
|
+
swipe: async (direction) => {
|
|
2595
|
+
probeSize ??= await client.windowSize();
|
|
2596
|
+
await swipe(direction, scrollToProbeDistanceFor(direction, probeSize), probeSize);
|
|
2597
|
+
}
|
|
2598
|
+
});
|
|
2599
|
+
if (found) {
|
|
2600
|
+
return;
|
|
2601
|
+
}
|
|
2602
|
+
throw new Error(
|
|
2603
|
+
`scrollTo: element "${selector}" not visible after ${MAX_SCROLL_TO_SWIPES} scroll attempts on the Android target`
|
|
2604
|
+
);
|
|
2426
2605
|
},
|
|
2427
2606
|
setInputFiles() {
|
|
2428
2607
|
return rejectUnsupported("setInputFiles");
|
|
@@ -2894,6 +3073,13 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
|
|
|
2894
3073
|
async pressKeyCode(keyCode) {
|
|
2895
3074
|
await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
|
|
2896
3075
|
},
|
|
3076
|
+
async windowSize() {
|
|
3077
|
+
const value = await transport.request("GET", `${base}/window/current/size`);
|
|
3078
|
+
return toScreenSize(value, "uiautomator2 /window/current/size");
|
|
3079
|
+
},
|
|
3080
|
+
async performActions(actions) {
|
|
3081
|
+
await transport.request("POST", `${base}/actions`, { actions: [actions] });
|
|
3082
|
+
},
|
|
2897
3083
|
async screenshotPng() {
|
|
2898
3084
|
const value = await transport.request("GET", `${base}/screenshot`);
|
|
2899
3085
|
if (typeof value !== "string") {
|
|
@@ -3038,6 +3224,7 @@ async function launchAndroidSession(options) {
|
|
|
3038
3224
|
readyDeadlineMs,
|
|
3039
3225
|
appPackage: pkg
|
|
3040
3226
|
});
|
|
3227
|
+
await launchPackage(runner, serial, pkg);
|
|
3041
3228
|
const driver = createAndroidDriver(client, { appLabel: pkg });
|
|
3042
3229
|
return { client, driver, package: pkg, serial, teardown };
|
|
3043
3230
|
} catch (error) {
|
|
@@ -3065,6 +3252,7 @@ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
|
3065
3252
|
]);
|
|
3066
3253
|
var WAIT_POLL_INTERVAL_MS2 = 250;
|
|
3067
3254
|
var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
|
|
3255
|
+
var DISPLAYED_CHECK_CONCURRENCY = 4;
|
|
3068
3256
|
var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
|
|
3069
3257
|
function escapePredicateArg(value) {
|
|
3070
3258
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
@@ -3139,6 +3327,64 @@ function createIosDriver(client, options) {
|
|
|
3139
3327
|
async function fillSelector(selector, value) {
|
|
3140
3328
|
await client.setValue(await resolveOne(selector), value);
|
|
3141
3329
|
}
|
|
3330
|
+
async function swipe(direction, amount, size) {
|
|
3331
|
+
const actualSize = size ?? await client.windowSize();
|
|
3332
|
+
const { actions } = buildDirectionalSwipe(direction, actualSize, amount);
|
|
3333
|
+
await client.performActions(actions);
|
|
3334
|
+
}
|
|
3335
|
+
async function visibleElementIds(query) {
|
|
3336
|
+
const ids = await client.findElements(query);
|
|
3337
|
+
const visible = Array.from({ length: ids.length }, () => null);
|
|
3338
|
+
let nextIndex = 0;
|
|
3339
|
+
const workerCount = Math.min(DISPLAYED_CHECK_CONCURRENCY, ids.length);
|
|
3340
|
+
await Promise.all(
|
|
3341
|
+
Array.from({ length: workerCount }, async () => {
|
|
3342
|
+
for (; ; ) {
|
|
3343
|
+
const index = nextIndex;
|
|
3344
|
+
nextIndex += 1;
|
|
3345
|
+
if (index >= ids.length) {
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
const id = ids[index];
|
|
3349
|
+
if (await client.isDisplayed(id)) {
|
|
3350
|
+
visible[index] = id;
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
})
|
|
3354
|
+
);
|
|
3355
|
+
return visible.filter((id) => id !== null);
|
|
3356
|
+
}
|
|
3357
|
+
async function hasVisibleElement(query) {
|
|
3358
|
+
const ids = await client.findElements(query);
|
|
3359
|
+
if (ids.length === 0) {
|
|
3360
|
+
return false;
|
|
3361
|
+
}
|
|
3362
|
+
if (await client.isDisplayed(ids[0])) {
|
|
3363
|
+
return true;
|
|
3364
|
+
}
|
|
3365
|
+
let found = false;
|
|
3366
|
+
let nextIndex = 1;
|
|
3367
|
+
const workerCount = Math.min(DISPLAYED_CHECK_CONCURRENCY, ids.length - 1);
|
|
3368
|
+
await Promise.all(
|
|
3369
|
+
Array.from({ length: workerCount }, async () => {
|
|
3370
|
+
for (; ; ) {
|
|
3371
|
+
if (found) {
|
|
3372
|
+
return;
|
|
3373
|
+
}
|
|
3374
|
+
const index = nextIndex;
|
|
3375
|
+
nextIndex += 1;
|
|
3376
|
+
if (index >= ids.length) {
|
|
3377
|
+
return;
|
|
3378
|
+
}
|
|
3379
|
+
if (await client.isDisplayed(ids[index])) {
|
|
3380
|
+
found = true;
|
|
3381
|
+
return;
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
})
|
|
3385
|
+
);
|
|
3386
|
+
return found;
|
|
3387
|
+
}
|
|
3142
3388
|
async function pressKey(key) {
|
|
3143
3389
|
const name = key.trim().toLowerCase();
|
|
3144
3390
|
if (name === "enter" || name === "return") {
|
|
@@ -3170,6 +3416,9 @@ function createIosDriver(client, options) {
|
|
|
3170
3416
|
async count(selector) {
|
|
3171
3417
|
return (await client.findElements(parseIosSelector(selector))).length;
|
|
3172
3418
|
},
|
|
3419
|
+
async visibleCount(selector) {
|
|
3420
|
+
return (await visibleElementIds(parseIosSelector(selector))).length;
|
|
3421
|
+
},
|
|
3173
3422
|
async textContent(selector) {
|
|
3174
3423
|
const id = await client.findElement(parseIosSelector(selector));
|
|
3175
3424
|
if (id === null) {
|
|
@@ -3194,15 +3443,38 @@ function createIosDriver(client, options) {
|
|
|
3194
3443
|
hover() {
|
|
3195
3444
|
return rejectUnsupported("hover");
|
|
3196
3445
|
},
|
|
3197
|
-
|
|
3198
|
-
|
|
3446
|
+
// Screen-centred swipe via the W3C actions endpoint (PROWL-080). Direction
|
|
3447
|
+
// semantics match the web step: scrolling "down" reveals lower content, so
|
|
3448
|
+
// the finger drags up. See ./touch-gestures.ts.
|
|
3449
|
+
async scroll(direction, amount) {
|
|
3450
|
+
await swipe(direction, amount);
|
|
3451
|
+
},
|
|
3452
|
+
// Resolve the element, short-circuiting only if WDA reports a matching
|
|
3453
|
+
// element displayed in the viewport; hierarchy-only matches can be offscreen.
|
|
3454
|
+
// Otherwise use the shared bounded down/up mobile probe before failing.
|
|
3455
|
+
async scrollIntoView(selector) {
|
|
3456
|
+
const query = parseIosSelector(selector);
|
|
3457
|
+
let probeSize;
|
|
3458
|
+
const found = await probeScrollIntoView({
|
|
3459
|
+
isVisible: () => hasVisibleElement(query),
|
|
3460
|
+
swipe: async (direction) => {
|
|
3461
|
+
probeSize ??= await client.windowSize();
|
|
3462
|
+
await swipe(direction, scrollToProbeDistanceFor(direction, probeSize), probeSize);
|
|
3463
|
+
}
|
|
3464
|
+
});
|
|
3465
|
+
if (found) {
|
|
3466
|
+
return;
|
|
3467
|
+
}
|
|
3468
|
+
throw new Error(
|
|
3469
|
+
`scrollTo: element "${selector}" not visible after ${MAX_SCROLL_TO_SWIPES} scroll attempts on the iOS target`
|
|
3470
|
+
);
|
|
3199
3471
|
},
|
|
3200
3472
|
setInputFiles() {
|
|
3201
3473
|
return rejectUnsupported("setInputFiles");
|
|
3202
3474
|
},
|
|
3203
3475
|
// semantic locators ----------------------------------------------------
|
|
3204
3476
|
async countByRole(role, name) {
|
|
3205
|
-
return (await
|
|
3477
|
+
return (await visibleElementIds({ by: "role", role, name })).length;
|
|
3206
3478
|
},
|
|
3207
3479
|
async clickFirstByRole(role, name) {
|
|
3208
3480
|
const id = await client.findElement({ by: "role", role, name });
|
|
@@ -3212,7 +3484,7 @@ function createIosDriver(client, options) {
|
|
|
3212
3484
|
await client.click(id);
|
|
3213
3485
|
},
|
|
3214
3486
|
async countByLabel(label) {
|
|
3215
|
-
return (await
|
|
3487
|
+
return (await visibleElementIds({ by: "label", value: label })).length;
|
|
3216
3488
|
},
|
|
3217
3489
|
async fillFirstByLabel(label, value) {
|
|
3218
3490
|
const id = await client.findElement({ by: "label", value: label });
|
|
@@ -3230,7 +3502,7 @@ function createIosDriver(client, options) {
|
|
|
3230
3502
|
const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
|
|
3231
3503
|
const deadline = Date.now() + timeoutMs;
|
|
3232
3504
|
for (; ; ) {
|
|
3233
|
-
if (
|
|
3505
|
+
if (await hasVisibleElement(query)) {
|
|
3234
3506
|
return;
|
|
3235
3507
|
}
|
|
3236
3508
|
if (Date.now() >= deadline) {
|
|
@@ -3723,12 +3995,29 @@ function createWdaAgentClient(transport, sessionId) {
|
|
|
3723
3995
|
const value = await transport.request("GET", `${base}/element/${elementId}/text`);
|
|
3724
3996
|
return typeof value === "string" ? value : value == null ? null : String(value);
|
|
3725
3997
|
},
|
|
3998
|
+
async isDisplayed(elementId) {
|
|
3999
|
+
try {
|
|
4000
|
+
return await transport.request("GET", `${base}/element/${elementId}/displayed`) === true;
|
|
4001
|
+
} catch (error) {
|
|
4002
|
+
if (isNoSuchElement2(error)) {
|
|
4003
|
+
return false;
|
|
4004
|
+
}
|
|
4005
|
+
throw error;
|
|
4006
|
+
}
|
|
4007
|
+
},
|
|
3726
4008
|
async sendKeys(keys) {
|
|
3727
4009
|
await transport.request("POST", `${base}/wda/keys`, { value: keys });
|
|
3728
4010
|
},
|
|
3729
4011
|
async homescreen() {
|
|
3730
4012
|
await transport.request("POST", "/wda/homescreen", {});
|
|
3731
4013
|
},
|
|
4014
|
+
async windowSize() {
|
|
4015
|
+
const value = await transport.request("GET", `${base}/window/size`);
|
|
4016
|
+
return toScreenSize(value, "WebDriverAgent /window/size");
|
|
4017
|
+
},
|
|
4018
|
+
async performActions(actions) {
|
|
4019
|
+
await transport.request("POST", `${base}/actions`, { actions: [actions] });
|
|
4020
|
+
},
|
|
3732
4021
|
async source() {
|
|
3733
4022
|
const value = await transport.request("GET", "/source");
|
|
3734
4023
|
if (typeof value !== "string") {
|
|
@@ -4082,6 +4371,7 @@ async function closeIosSession(session) {
|
|
|
4082
4371
|
var import_node_fs9 = __toESM(require("fs"), 1);
|
|
4083
4372
|
var import_node_path10 = __toESM(require("path"), 1);
|
|
4084
4373
|
init_loader();
|
|
4374
|
+
init_interpolate();
|
|
4085
4375
|
|
|
4086
4376
|
// src/runner/healing.ts
|
|
4087
4377
|
var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
|
|
@@ -4779,11 +5069,14 @@ function toVisibilitySelector(value) {
|
|
|
4779
5069
|
if (looksLikeSelector(value)) return value;
|
|
4780
5070
|
return textContainsSelector(value);
|
|
4781
5071
|
}
|
|
5072
|
+
function countVisible(driver, selector) {
|
|
5073
|
+
return driver.visibleCount?.(selector) ?? driver.count(selector);
|
|
5074
|
+
}
|
|
4782
5075
|
async function runInlineAssert(driver, policy, assertion) {
|
|
4783
5076
|
if (assertion.visible !== void 0) {
|
|
4784
5077
|
const selector = toVisibilitySelector(assertion.visible);
|
|
4785
5078
|
policy.assertAllowedSelector(selector);
|
|
4786
|
-
const count = await driver
|
|
5079
|
+
const count = await countVisible(driver, selector);
|
|
4787
5080
|
if (count === 0) {
|
|
4788
5081
|
throw new Error(`Expected visible: ${assertion.visible}`);
|
|
4789
5082
|
}
|
|
@@ -4792,7 +5085,7 @@ async function runInlineAssert(driver, policy, assertion) {
|
|
|
4792
5085
|
if (assertion.notVisible !== void 0) {
|
|
4793
5086
|
const selector = toVisibilitySelector(assertion.notVisible);
|
|
4794
5087
|
policy.assertAllowedSelector(selector);
|
|
4795
|
-
const count = await driver
|
|
5088
|
+
const count = await countVisible(driver, selector);
|
|
4796
5089
|
if (count > 0) {
|
|
4797
5090
|
throw new Error(`Expected not visible: ${assertion.notVisible}`);
|
|
4798
5091
|
}
|
|
@@ -5182,25 +5475,21 @@ var STEP_HANDLERS = {
|
|
|
5182
5475
|
}
|
|
5183
5476
|
},
|
|
5184
5477
|
scroll: {
|
|
5185
|
-
|
|
5478
|
+
// Dispatched through the driver's `scroll` verb (interact), so native mobile
|
|
5479
|
+
// targets synthesize a touch swipe while web keeps its `window.scrollBy`
|
|
5480
|
+
// behavior. The per-target step gate rejects `scroll` on macOS before here.
|
|
5481
|
+
capabilities: ["interact"],
|
|
5186
5482
|
run: async (h) => {
|
|
5187
5483
|
if (!("scroll" in h.step)) unknownStep();
|
|
5188
|
-
const amount = h.step.scroll
|
|
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]);
|
|
5484
|
+
const { direction, amount } = h.step.scroll;
|
|
5485
|
+
await h.driver.scroll(direction, amount);
|
|
5197
5486
|
return {
|
|
5198
5487
|
kind: "result",
|
|
5199
5488
|
result: {
|
|
5200
5489
|
type: "scroll",
|
|
5201
5490
|
status: "pass",
|
|
5202
5491
|
durationMs: Date.now() - h.stepStart,
|
|
5203
|
-
value: `${
|
|
5492
|
+
value: amount === void 0 ? direction : `${direction} ${amount}px`
|
|
5204
5493
|
}
|
|
5205
5494
|
};
|
|
5206
5495
|
}
|
|
@@ -5246,7 +5535,7 @@ var STEP_HANDLERS = {
|
|
|
5246
5535
|
const condition = h.step.if;
|
|
5247
5536
|
const selector = condition.visible ?? condition.notVisible;
|
|
5248
5537
|
h.policy.assertAllowedSelector(selector);
|
|
5249
|
-
const count = await h.driver
|
|
5538
|
+
const count = await countVisible(h.driver, selector);
|
|
5250
5539
|
const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
|
|
5251
5540
|
if (conditionMet) {
|
|
5252
5541
|
const subResult = await h.executeNested({
|
|
@@ -5333,7 +5622,7 @@ var STEP_HANDLERS = {
|
|
|
5333
5622
|
const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
|
|
5334
5623
|
h.policy.assertAllowedSelector(whileSelector);
|
|
5335
5624
|
for (let i = 0; i < maxIter; i++) {
|
|
5336
|
-
const whileCount = await h.driver
|
|
5625
|
+
const whileCount = await countVisible(h.driver, whileSelector);
|
|
5337
5626
|
const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
|
|
5338
5627
|
if (!shouldContinue) break;
|
|
5339
5628
|
totalSubSteps += repeat.steps.length;
|