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