prowl-tools 0.1.3 → 0.1.4
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/LICENSE +1 -1
- package/NOTICE +1 -1
- package/README.md +115 -3
- package/dist/{chunk-NXXGJOBG.js → chunk-MBAIGNVO.js} +38 -13
- package/dist/chunk-MBAIGNVO.js.map +1 -0
- package/dist/{chunk-T7YLXF6X.js → chunk-ZEFVTKQT.js} +1773 -756
- package/dist/chunk-ZEFVTKQT.js.map +1 -0
- package/dist/index.cjs +2008 -973
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +41 -30
- package/dist/index.js.map +1 -1
- package/dist/lib.cjs +1848 -807
- package/dist/lib.cjs.map +1 -1
- package/dist/lib.d.cts +314 -17
- package/dist/lib.d.ts +314 -17
- package/dist/lib.js +28 -4
- package/dist/{loader-5RDNTJHH.js → loader-XMTDB6OL.js} +2 -2
- package/package.json +2 -2
- package/dist/chunk-NXXGJOBG.js.map +0 -1
- package/dist/chunk-T7YLXF6X.js.map +0 -1
- /package/dist/{loader-5RDNTJHH.js.map → loader-XMTDB6OL.js.map} +0 -0
package/dist/index.cjs
CHANGED
|
@@ -30,6 +30,15 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
30
30
|
mod
|
|
31
31
|
));
|
|
32
32
|
|
|
33
|
+
// src/types/index.ts
|
|
34
|
+
var SUPPORTED_BROWSER_ENGINES;
|
|
35
|
+
var init_types = __esm({
|
|
36
|
+
"src/types/index.ts"() {
|
|
37
|
+
"use strict";
|
|
38
|
+
SUPPORTED_BROWSER_ENGINES = ["chromium", "firefox", "webkit"];
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
33
42
|
// src/config/hunt-name.ts
|
|
34
43
|
function isValidHuntName(name) {
|
|
35
44
|
return HUNT_NAME_PATTERN.test(name);
|
|
@@ -50,21 +59,29 @@ var init_hunt_name = __esm({
|
|
|
50
59
|
});
|
|
51
60
|
|
|
52
61
|
// src/config/schema.ts
|
|
53
|
-
var import_zod, configSchema, navigateStepSchema, clickStepSchema, singleKeyValueSchema, fillStepSchema, typeStepSchema, pressStepSchema, waitForSelectorStepSchema, waitStepSchema, waitForUrlStepSchema, waitForNetworkIdleStepSchema, selectOptionStepSchema, selectStepSchema, onDialogStepSchema, setInputFilesStepSchema, inlineAssertStepSchema, runHuntStepSchema, hoverStepSchema, scrollStepSchema, scrollToStepSchema, screenshotStepSchema, ifStepSchema, repeatStepSchema, mockRouteStepSchema, unmockRouteStepSchema, evalScriptStepSchema, runScriptStepSchema, assertScreenshotStepSchema, copyTextStepSchema, waitForDownloadStepSchema, stepSchema, assertionSchema, huntSchema;
|
|
62
|
+
var import_zod, webTargetSchema, macosTargetSchema, targetSchema, configSchema, navigateStepSchema, clickStepSchema, singleKeyValueSchema, fillStepSchema, typeStepSchema, pressStepSchema, waitForSelectorStepSchema, waitStepSchema, waitForUrlStepSchema, waitForNetworkIdleStepSchema, selectOptionStepSchema, selectStepSchema, onDialogStepSchema, setInputFilesStepSchema, inlineAssertStepSchema, runHuntStepSchema, hoverStepSchema, scrollStepSchema, scrollToStepSchema, screenshotStepSchema, ifStepSchema, repeatStepSchema, mockRouteStepSchema, unmockRouteStepSchema, evalScriptStepSchema, runScriptStepSchema, assertScreenshotStepSchema, copyTextStepSchema, waitForDownloadStepSchema, stepSchema, assertionSchema, huntSchema;
|
|
54
63
|
var init_schema = __esm({
|
|
55
64
|
"src/config/schema.ts"() {
|
|
56
65
|
"use strict";
|
|
57
66
|
import_zod = require("zod");
|
|
67
|
+
init_types();
|
|
58
68
|
init_hunt_name();
|
|
69
|
+
webTargetSchema = import_zod.z.object({
|
|
70
|
+
type: import_zod.z.literal("web").optional(),
|
|
71
|
+
url: import_zod.z.string().min(1)
|
|
72
|
+
}).strict();
|
|
73
|
+
macosTargetSchema = import_zod.z.object({
|
|
74
|
+
type: import_zod.z.literal("macos"),
|
|
75
|
+
app: import_zod.z.string().min(1)
|
|
76
|
+
}).strict();
|
|
77
|
+
targetSchema = import_zod.z.union([macosTargetSchema, webTargetSchema]);
|
|
59
78
|
configSchema = import_zod.z.object({
|
|
60
|
-
target:
|
|
61
|
-
url: import_zod.z.string().min(1)
|
|
62
|
-
}),
|
|
79
|
+
target: targetSchema,
|
|
63
80
|
browser: import_zod.z.object({
|
|
64
81
|
headless: import_zod.z.boolean().optional(),
|
|
65
82
|
slowMo: import_zod.z.number().optional(),
|
|
66
83
|
timeout: import_zod.z.number().optional(),
|
|
67
|
-
engine: import_zod.z.enum(
|
|
84
|
+
engine: import_zod.z.enum(SUPPORTED_BROWSER_ENGINES).optional(),
|
|
68
85
|
channel: import_zod.z.enum([
|
|
69
86
|
"chromium",
|
|
70
87
|
"chrome",
|
|
@@ -96,6 +113,7 @@ var init_schema = __esm({
|
|
|
96
113
|
guardrails: import_zod.z.object({
|
|
97
114
|
maxSteps: import_zod.z.number().optional(),
|
|
98
115
|
allowedDomains: import_zod.z.array(import_zod.z.string()).optional(),
|
|
116
|
+
allowedApps: import_zod.z.array(import_zod.z.string()).optional(),
|
|
99
117
|
forbiddenSelectors: import_zod.z.array(import_zod.z.string()).optional(),
|
|
100
118
|
selfHealing: import_zod.z.boolean().optional()
|
|
101
119
|
}).optional(),
|
|
@@ -398,11 +416,18 @@ function resolveViewport(value) {
|
|
|
398
416
|
}
|
|
399
417
|
return value;
|
|
400
418
|
}
|
|
419
|
+
function resolveTarget(target) {
|
|
420
|
+
if (target && target.type === "macos") {
|
|
421
|
+
return { type: "macos", app: target.app };
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
type: "web",
|
|
425
|
+
url: target?.url ?? DEFAULT_WEB_URL
|
|
426
|
+
};
|
|
427
|
+
}
|
|
401
428
|
function mergeConfig(partial) {
|
|
402
429
|
return {
|
|
403
|
-
target:
|
|
404
|
-
url: partial.target?.url ?? DEFAULT_CONFIG.target.url
|
|
405
|
-
},
|
|
430
|
+
target: resolveTarget(partial.target),
|
|
406
431
|
browser: {
|
|
407
432
|
headless: partial.browser?.headless ?? DEFAULT_CONFIG.browser.headless,
|
|
408
433
|
slowMo: partial.browser?.slowMo ?? DEFAULT_CONFIG.browser.slowMo,
|
|
@@ -426,6 +451,7 @@ function mergeConfig(partial) {
|
|
|
426
451
|
guardrails: {
|
|
427
452
|
maxSteps: partial.guardrails?.maxSteps ?? DEFAULT_CONFIG.guardrails.maxSteps,
|
|
428
453
|
allowedDomains: partial.guardrails?.allowedDomains ?? DEFAULT_CONFIG.guardrails.allowedDomains,
|
|
454
|
+
allowedApps: partial.guardrails?.allowedApps ?? DEFAULT_CONFIG.guardrails.allowedApps,
|
|
429
455
|
forbiddenSelectors: partial.guardrails?.forbiddenSelectors ?? DEFAULT_CONFIG.guardrails.forbiddenSelectors,
|
|
430
456
|
selfHealing: partial.guardrails?.selfHealing ?? DEFAULT_CONFIG.guardrails.selfHealing
|
|
431
457
|
},
|
|
@@ -468,10 +494,12 @@ function loadConfig(configPath) {
|
|
|
468
494
|
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
469
495
|
const validated = configSchema.parse(parsed);
|
|
470
496
|
const config = mergeConfig(validated);
|
|
471
|
-
config.
|
|
472
|
-
config.guardrails.allowedDomains
|
|
473
|
-
|
|
474
|
-
|
|
497
|
+
if (config.target.type === "web") {
|
|
498
|
+
config.guardrails.allowedDomains = ensureAllowedDomain(
|
|
499
|
+
config.guardrails.allowedDomains,
|
|
500
|
+
config.target.url
|
|
501
|
+
);
|
|
502
|
+
}
|
|
475
503
|
return { config, configPath: resolvedPath, configDir };
|
|
476
504
|
}
|
|
477
505
|
function loadHunt(huntName, configDir) {
|
|
@@ -533,7 +561,7 @@ function listHunts(configDir) {
|
|
|
533
561
|
scanDir(huntsDir);
|
|
534
562
|
return results.sort((a, b) => a.localeCompare(b));
|
|
535
563
|
}
|
|
536
|
-
var import_node_fs, import_node_path, import_yaml, import_dotenv, DEFAULT_CONFIG, CONFIG_DIR, LEGACY_CONFIG_DIR, legacyDirWarned, VIEWPORT_PRESETS;
|
|
564
|
+
var import_node_fs, import_node_path, import_yaml, import_dotenv, DEFAULT_WEB_URL, DEFAULT_CONFIG, CONFIG_DIR, LEGACY_CONFIG_DIR, legacyDirWarned, VIEWPORT_PRESETS;
|
|
537
565
|
var init_loader = __esm({
|
|
538
566
|
"src/config/loader.ts"() {
|
|
539
567
|
"use strict";
|
|
@@ -543,9 +571,11 @@ var init_loader = __esm({
|
|
|
543
571
|
import_dotenv = __toESM(require("dotenv"), 1);
|
|
544
572
|
init_schema();
|
|
545
573
|
init_hunt_name();
|
|
574
|
+
DEFAULT_WEB_URL = "http://localhost:3000";
|
|
546
575
|
DEFAULT_CONFIG = {
|
|
547
576
|
target: {
|
|
548
|
-
|
|
577
|
+
type: "web",
|
|
578
|
+
url: DEFAULT_WEB_URL
|
|
549
579
|
},
|
|
550
580
|
browser: {
|
|
551
581
|
headless: true,
|
|
@@ -569,6 +599,7 @@ var init_loader = __esm({
|
|
|
569
599
|
guardrails: {
|
|
570
600
|
maxSteps: 50,
|
|
571
601
|
allowedDomains: ["localhost", "127.0.0.1", "0.0.0.0"],
|
|
602
|
+
allowedApps: [],
|
|
572
603
|
forbiddenSelectors: ["[data-danger]", ".delete-btn"],
|
|
573
604
|
selfHealing: false
|
|
574
605
|
},
|
|
@@ -597,12 +628,12 @@ __export(visual_exports, {
|
|
|
597
628
|
ensureBaselineDir: () => ensureBaselineDir
|
|
598
629
|
});
|
|
599
630
|
async function compareScreenshots(baselinePath, currentPath, diffPath, threshold) {
|
|
600
|
-
const baselineData = import_pngjs.PNG.sync.read(
|
|
601
|
-
const currentData = import_pngjs.PNG.sync.read(
|
|
631
|
+
const baselineData = import_pngjs.PNG.sync.read(import_node_fs5.default.readFileSync(baselinePath));
|
|
632
|
+
const currentData = import_pngjs.PNG.sync.read(import_node_fs5.default.readFileSync(currentPath));
|
|
602
633
|
const { width, height } = baselineData;
|
|
603
634
|
if (currentData.width !== width || currentData.height !== height) {
|
|
604
635
|
const diff2 = new import_pngjs.PNG({ width, height });
|
|
605
|
-
|
|
636
|
+
import_node_fs5.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
|
|
606
637
|
return {
|
|
607
638
|
match: false,
|
|
608
639
|
diffPercentage: 1,
|
|
@@ -620,7 +651,7 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
|
|
|
620
651
|
);
|
|
621
652
|
const totalPixels = width * height;
|
|
622
653
|
const diffPercentage = diffPixels / totalPixels;
|
|
623
|
-
|
|
654
|
+
import_node_fs5.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
|
|
624
655
|
return {
|
|
625
656
|
match: diffPercentage <= threshold,
|
|
626
657
|
diffPercentage,
|
|
@@ -628,16 +659,16 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
|
|
|
628
659
|
};
|
|
629
660
|
}
|
|
630
661
|
function ensureBaselineDir(configDir) {
|
|
631
|
-
const baselineDir =
|
|
632
|
-
|
|
662
|
+
const baselineDir = import_node_path5.default.join(configDir, "baselines");
|
|
663
|
+
import_node_fs5.default.mkdirSync(baselineDir, { recursive: true });
|
|
633
664
|
return baselineDir;
|
|
634
665
|
}
|
|
635
|
-
var
|
|
666
|
+
var import_node_fs5, import_node_path5, import_pngjs, import_pixelmatch;
|
|
636
667
|
var init_visual = __esm({
|
|
637
668
|
"src/runner/visual.ts"() {
|
|
638
669
|
"use strict";
|
|
639
|
-
|
|
640
|
-
|
|
670
|
+
import_node_fs5 = __toESM(require("fs"), 1);
|
|
671
|
+
import_node_path5 = __toESM(require("path"), 1);
|
|
641
672
|
import_pngjs = require("pngjs");
|
|
642
673
|
import_pixelmatch = __toESM(require("pixelmatch"), 1);
|
|
643
674
|
}
|
|
@@ -649,11 +680,11 @@ var import_commander13 = require("commander");
|
|
|
649
680
|
// package.json
|
|
650
681
|
var package_default = {
|
|
651
682
|
name: "prowl-tools",
|
|
652
|
-
version: "0.1.
|
|
683
|
+
version: "0.1.4",
|
|
653
684
|
description: "CLI-first QA testing tool for deterministic Playwright flows.",
|
|
654
685
|
type: "module",
|
|
655
686
|
license: "Apache-2.0",
|
|
656
|
-
author: "
|
|
687
|
+
author: "Prowl Tools",
|
|
657
688
|
repository: {
|
|
658
689
|
type: "git",
|
|
659
690
|
url: "https://github.com/prowl-tools/prowl.git"
|
|
@@ -736,8 +767,8 @@ var import_commander = require("commander");
|
|
|
736
767
|
var import_chalk3 = __toESM(require("chalk"), 1);
|
|
737
768
|
|
|
738
769
|
// src/runner/index.ts
|
|
739
|
-
var
|
|
740
|
-
var
|
|
770
|
+
var import_node_fs11 = __toESM(require("fs"), 1);
|
|
771
|
+
var import_node_path11 = __toESM(require("path"), 1);
|
|
741
772
|
init_loader();
|
|
742
773
|
|
|
743
774
|
// src/config/interpolate.ts
|
|
@@ -785,7 +816,7 @@ function generateRandomVars(randomSource) {
|
|
|
785
816
|
const hex = randomBytes(4).toString("hex");
|
|
786
817
|
const firstIndex = Math.floor(random() * RANDOM_FIRST_NAMES.length);
|
|
787
818
|
const lastIndex = Math.floor(random() * RANDOM_LAST_NAMES.length);
|
|
788
|
-
const
|
|
819
|
+
const num2 = Math.floor(random() * 9e3) + 1e3;
|
|
789
820
|
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
790
821
|
let text = "";
|
|
791
822
|
for (let i = 0; i < 8; i++) {
|
|
@@ -794,7 +825,7 @@ function generateRandomVars(randomSource) {
|
|
|
794
825
|
return {
|
|
795
826
|
RANDOM_EMAIL: `prowl_${hex}@test.com`,
|
|
796
827
|
RANDOM_NAME: `${RANDOM_FIRST_NAMES[firstIndex]} ${RANDOM_LAST_NAMES[lastIndex]}`,
|
|
797
|
-
RANDOM_NUMBER: String(
|
|
828
|
+
RANDOM_NUMBER: String(num2),
|
|
798
829
|
RANDOM_UUID: randomUUID(),
|
|
799
830
|
RANDOM_TEXT: text
|
|
800
831
|
};
|
|
@@ -1106,255 +1137,943 @@ function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
|
|
|
1106
1137
|
};
|
|
1107
1138
|
}
|
|
1108
1139
|
|
|
1109
|
-
// src/
|
|
1140
|
+
// src/config/target.ts
|
|
1141
|
+
var import_node_child_process = require("child_process");
|
|
1110
1142
|
var import_node_fs2 = __toESM(require("fs"), 1);
|
|
1111
1143
|
var import_node_path2 = __toESM(require("path"), 1);
|
|
1112
|
-
var
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1144
|
+
var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
|
|
1145
|
+
"navigate",
|
|
1146
|
+
"waitForUrl",
|
|
1147
|
+
"waitForNetworkIdle",
|
|
1148
|
+
"mockRoute",
|
|
1149
|
+
"unmockRoute",
|
|
1150
|
+
"evalScript",
|
|
1151
|
+
"runScript",
|
|
1152
|
+
"onDialog",
|
|
1153
|
+
"select",
|
|
1154
|
+
"selectOption",
|
|
1155
|
+
"setInputFiles",
|
|
1156
|
+
"waitForDownload",
|
|
1157
|
+
"scroll"
|
|
1158
|
+
// directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
|
|
1159
|
+
]);
|
|
1160
|
+
function webOnlyReason(step) {
|
|
1161
|
+
for (const type of WEB_ONLY_STEP_TYPES) {
|
|
1162
|
+
if (type in step) {
|
|
1163
|
+
return type;
|
|
1164
|
+
}
|
|
1124
1165
|
}
|
|
1125
|
-
if (
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
|
|
1166
|
+
if ("assert" in step) {
|
|
1167
|
+
const assertion = step.assert;
|
|
1168
|
+
if (assertion.urlIncludes !== void 0 || assertion.urlEquals !== void 0) {
|
|
1169
|
+
return "assert (url)";
|
|
1130
1170
|
}
|
|
1131
1171
|
}
|
|
1132
|
-
|
|
1133
|
-
|
|
1172
|
+
return null;
|
|
1173
|
+
}
|
|
1174
|
+
function assertStepsSupportedByTarget(steps, target) {
|
|
1175
|
+
if (target !== "macos") {
|
|
1176
|
+
return;
|
|
1134
1177
|
}
|
|
1135
|
-
const
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1178
|
+
for (const step of steps) {
|
|
1179
|
+
const reason = webOnlyReason(step);
|
|
1180
|
+
if (reason) {
|
|
1181
|
+
throw new Error(
|
|
1182
|
+
`Step "${reason}" is not supported by the macOS target. It is web-only; use a portable step (click, fill, type, press, wait, assert visible, screenshot, etc.).`
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
if ("if" in step) {
|
|
1186
|
+
assertStepsSupportedByTarget(step.if.then, target);
|
|
1187
|
+
if (step.if.else) {
|
|
1188
|
+
assertStepsSupportedByTarget(step.if.else, target);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
if ("repeat" in step) {
|
|
1192
|
+
assertStepsSupportedByTarget(step.repeat.steps, target);
|
|
1193
|
+
}
|
|
1143
1194
|
}
|
|
1144
|
-
return { browser, context, page, tracePath };
|
|
1145
1195
|
}
|
|
1146
|
-
|
|
1147
|
-
if (
|
|
1148
|
-
|
|
1196
|
+
function assertHuntAssertionsSupportedByTarget(assertions, target) {
|
|
1197
|
+
if (target !== "macos" || !assertions || assertions.length === 0) {
|
|
1198
|
+
return;
|
|
1149
1199
|
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1200
|
+
throw new Error(
|
|
1201
|
+
"Hunt-level assertions are not supported by the macOS target. Use inline assert visible/notVisible steps instead."
|
|
1202
|
+
);
|
|
1152
1203
|
}
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
var import_node_fs4 = __toESM(require("fs"), 1);
|
|
1156
|
-
var import_node_path4 = __toESM(require("path"), 1);
|
|
1157
|
-
|
|
1158
|
-
// src/browser/actions.ts
|
|
1159
|
-
async function clickElement(page, selector) {
|
|
1160
|
-
await page.locator(selector).click();
|
|
1204
|
+
function trimTrailingPathSeparators(value) {
|
|
1205
|
+
return value.replace(/[\\/]+$/g, "");
|
|
1161
1206
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1207
|
+
function looksLikeMacosAppPath(app) {
|
|
1208
|
+
const trimmed = trimTrailingPathSeparators(app);
|
|
1209
|
+
return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
|
|
1164
1210
|
}
|
|
1165
|
-
|
|
1166
|
-
|
|
1211
|
+
function normalizeAppPath(app) {
|
|
1212
|
+
return import_node_path2.default.resolve(trimTrailingPathSeparators(app));
|
|
1167
1213
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1214
|
+
function parseBundleIdentifier(plist) {
|
|
1215
|
+
const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
|
|
1216
|
+
return match?.[1]?.trim() || null;
|
|
1170
1217
|
}
|
|
1171
|
-
function
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1218
|
+
function readBundleIdentifier(appPath) {
|
|
1219
|
+
const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), "Contents", "Info.plist");
|
|
1220
|
+
if (!import_node_fs2.default.existsSync(infoPlistPath)) {
|
|
1221
|
+
return null;
|
|
1222
|
+
}
|
|
1223
|
+
try {
|
|
1224
|
+
const parsed = parseBundleIdentifier(import_node_fs2.default.readFileSync(infoPlistPath, "utf-8"));
|
|
1225
|
+
if (parsed) {
|
|
1226
|
+
return parsed;
|
|
1177
1227
|
}
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
async function setInputFiles(page, selector, files) {
|
|
1181
|
-
await page.locator(selector).setInputFiles(files);
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
// src/runner/steps.ts
|
|
1185
|
-
init_loader();
|
|
1186
|
-
|
|
1187
|
-
// src/runner/healing.ts
|
|
1188
|
-
var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
|
|
1189
|
-
function extractSelectorIntent(selector) {
|
|
1190
|
-
const raw = [];
|
|
1191
|
-
for (const match of selector.matchAll(/[#.]([A-Za-z_][\w-]*)/g)) {
|
|
1192
|
-
raw.push(match[1]);
|
|
1228
|
+
} catch {
|
|
1193
1229
|
}
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
if (value) raw.push(value);
|
|
1230
|
+
if (process.platform !== "darwin" || !import_node_fs2.default.existsSync("/usr/bin/plutil")) {
|
|
1231
|
+
return null;
|
|
1197
1232
|
}
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1233
|
+
try {
|
|
1234
|
+
const output = (0, import_node_child_process.execFileSync)(
|
|
1235
|
+
"/usr/bin/plutil",
|
|
1236
|
+
["-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlistPath],
|
|
1237
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1e3 }
|
|
1238
|
+
);
|
|
1239
|
+
return output.trim() || null;
|
|
1240
|
+
} catch {
|
|
1241
|
+
return null;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
function macosAppAllowedIdentities(app) {
|
|
1245
|
+
const identities = /* @__PURE__ */ new Set([app]);
|
|
1246
|
+
if (looksLikeMacosAppPath(app)) {
|
|
1247
|
+
const normalizedPath = normalizeAppPath(app);
|
|
1248
|
+
identities.add(trimTrailingPathSeparators(app));
|
|
1249
|
+
identities.add(normalizedPath);
|
|
1250
|
+
const bundleName = import_node_path2.default.basename(normalizedPath).replace(/\.app$/i, "");
|
|
1251
|
+
if (bundleName) {
|
|
1252
|
+
identities.add(bundleName);
|
|
1253
|
+
}
|
|
1254
|
+
const bundleId = readBundleIdentifier(app);
|
|
1255
|
+
if (bundleId) {
|
|
1256
|
+
identities.add(bundleId);
|
|
1205
1257
|
}
|
|
1206
1258
|
}
|
|
1207
|
-
return
|
|
1208
|
-
}
|
|
1209
|
-
function splitToken(token) {
|
|
1210
|
-
return token.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[\s\-_.:]+/).filter((part) => part.length > 0);
|
|
1259
|
+
return [...identities];
|
|
1211
1260
|
}
|
|
1212
|
-
function
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
const escaped = label.replace(/"/g, '\\"');
|
|
1216
|
-
const candidates = [];
|
|
1217
|
-
candidates.push({ selector: `text=${label}`, strategy: "text" });
|
|
1218
|
-
candidates.push({ selector: `[aria-label*="${escaped}" i]`, strategy: "aria" });
|
|
1219
|
-
for (const tag of INTERACTIVE_TAGS) {
|
|
1220
|
-
candidates.push({ selector: `${tag}:has-text("${escaped}")`, strategy: "structural" });
|
|
1261
|
+
function assertTargetAppAllowed(allowedApps, app) {
|
|
1262
|
+
if (allowedApps.length === 0) {
|
|
1263
|
+
return;
|
|
1221
1264
|
}
|
|
1222
|
-
|
|
1265
|
+
const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => macosAppAllowedIdentities(allowedApp)));
|
|
1266
|
+
if (macosAppAllowedIdentities(app).some((identity) => allowedIdentities.has(identity))) {
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
throw new Error(
|
|
1270
|
+
`Target app "${app}" is not in guardrails.allowedApps (${allowedApps.join(", ")}).`
|
|
1271
|
+
);
|
|
1223
1272
|
}
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1273
|
+
|
|
1274
|
+
// src/browser/playwright-driver.ts
|
|
1275
|
+
var import_node_fs3 = __toESM(require("fs"), 1);
|
|
1276
|
+
var import_node_path3 = __toESM(require("path"), 1);
|
|
1277
|
+
var import_playwright = require("playwright");
|
|
1278
|
+
var ENGINES = { chromium: import_playwright.chromium, firefox: import_playwright.firefox, webkit: import_playwright.webkit };
|
|
1279
|
+
async function launchBrowser(options) {
|
|
1280
|
+
const engineName = options.engine ?? "chromium";
|
|
1281
|
+
const engine = Object.prototype.hasOwnProperty.call(ENGINES, engineName) ? ENGINES[engineName] : void 0;
|
|
1282
|
+
if (!engine) {
|
|
1283
|
+
throw new Error(
|
|
1284
|
+
`Unsupported browser engine "${String(engineName)}". Available engines: ${Object.keys(ENGINES).join(", ")}.`
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
const browser = await engine.launch({
|
|
1288
|
+
headless: options.headless,
|
|
1289
|
+
slowMo: options.slowMo,
|
|
1290
|
+
channel: options.channel
|
|
1291
|
+
});
|
|
1292
|
+
try {
|
|
1293
|
+
const contextOptions = {};
|
|
1294
|
+
if (options.viewport) {
|
|
1295
|
+
contextOptions.viewport = options.viewport;
|
|
1296
|
+
}
|
|
1297
|
+
if (options.storageStatePath) {
|
|
1298
|
+
if (import_node_fs3.default.existsSync(options.storageStatePath)) {
|
|
1299
|
+
contextOptions.storageState = options.storageStatePath;
|
|
1300
|
+
} else {
|
|
1301
|
+
console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
if (options.recordHar) {
|
|
1305
|
+
contextOptions.recordHar = { path: import_node_path3.default.join(options.runDir, "network.har") };
|
|
1306
|
+
}
|
|
1307
|
+
const context = await browser.newContext(contextOptions);
|
|
1308
|
+
const page = await context.newPage();
|
|
1309
|
+
page.setDefaultTimeout(options.timeout);
|
|
1310
|
+
page.setDefaultNavigationTimeout(options.timeout);
|
|
1311
|
+
let tracePath;
|
|
1312
|
+
if (options.trace) {
|
|
1313
|
+
tracePath = import_node_path3.default.join(options.runDir, "trace.zip");
|
|
1314
|
+
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
1315
|
+
}
|
|
1316
|
+
return { browser, context, page, tracePath };
|
|
1317
|
+
} catch (error) {
|
|
1228
1318
|
try {
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
continue;
|
|
1319
|
+
await browser.close();
|
|
1320
|
+
} catch (closeError) {
|
|
1321
|
+
console.warn(`Failed to close browser after setup error: ${formatError(closeError)}`);
|
|
1233
1322
|
}
|
|
1234
|
-
|
|
1235
|
-
|
|
1323
|
+
throw error;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
async function closeBrowser(session) {
|
|
1327
|
+
try {
|
|
1328
|
+
if (session.tracePath) {
|
|
1329
|
+
await session.context.tracing.stop({ path: session.tracePath });
|
|
1236
1330
|
}
|
|
1331
|
+
await session.context.close();
|
|
1332
|
+
} finally {
|
|
1333
|
+
await session.browser.close();
|
|
1237
1334
|
}
|
|
1238
|
-
return null;
|
|
1239
1335
|
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1336
|
+
async function saveStorageState(session, storageStatePath) {
|
|
1337
|
+
await session.context.storageState({ path: storageStatePath });
|
|
1338
|
+
}
|
|
1339
|
+
var ALL_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
1340
|
+
"navigate",
|
|
1341
|
+
"query",
|
|
1342
|
+
"interact",
|
|
1343
|
+
"wait",
|
|
1344
|
+
"screenshot",
|
|
1345
|
+
"evaluate",
|
|
1346
|
+
"response",
|
|
1347
|
+
"route",
|
|
1348
|
+
"dialog",
|
|
1349
|
+
"files",
|
|
1350
|
+
"download"
|
|
1351
|
+
]);
|
|
1352
|
+
function formatError(error) {
|
|
1353
|
+
return error instanceof Error ? error.message : String(error);
|
|
1354
|
+
}
|
|
1243
1355
|
function unwrapTextSelector(value) {
|
|
1244
1356
|
const trimmed = value.trim();
|
|
1245
|
-
if (trimmed.startsWith(
|
|
1246
|
-
return
|
|
1247
|
-
}
|
|
1248
|
-
if (trimmed.startsWith("text='") && trimmed.endsWith("'")) {
|
|
1249
|
-
return trimmed.slice(6, -1);
|
|
1357
|
+
if (!trimmed.startsWith("text=")) {
|
|
1358
|
+
return null;
|
|
1250
1359
|
}
|
|
1251
|
-
|
|
1252
|
-
|
|
1360
|
+
const raw = trimmed.slice(5);
|
|
1361
|
+
const first = raw[0];
|
|
1362
|
+
if (first === '"' || first === "'") {
|
|
1363
|
+
const unquoted = raw.slice(1);
|
|
1364
|
+
return unquoted.endsWith(first) ? unquoted.slice(0, -1) : unquoted;
|
|
1253
1365
|
}
|
|
1254
|
-
return
|
|
1255
|
-
}
|
|
1256
|
-
function matchesForbiddenPattern(selector, forbidden) {
|
|
1257
|
-
const selectorText = unwrapTextSelector(selector);
|
|
1258
|
-
if (selectorText === null) {
|
|
1259
|
-
return false;
|
|
1260
|
-
}
|
|
1261
|
-
const forbiddenText = unwrapTextSelector(forbidden);
|
|
1262
|
-
if (forbiddenText !== null) {
|
|
1263
|
-
return selectorText.includes(forbiddenText);
|
|
1264
|
-
}
|
|
1265
|
-
return selectorText.includes(forbidden);
|
|
1366
|
+
return raw;
|
|
1266
1367
|
}
|
|
1267
|
-
function
|
|
1268
|
-
return
|
|
1269
|
-
|
|
1270
|
-
|
|
1368
|
+
function createPlaywrightDriver(page) {
|
|
1369
|
+
return {
|
|
1370
|
+
capabilities: ALL_CAPABILITIES,
|
|
1371
|
+
async goto(url, options) {
|
|
1372
|
+
if (options?.waitUntil !== void 0) {
|
|
1373
|
+
await page.goto(url, { waitUntil: options.waitUntil });
|
|
1374
|
+
} else {
|
|
1375
|
+
await page.goto(url);
|
|
1376
|
+
}
|
|
1377
|
+
},
|
|
1378
|
+
currentUrl() {
|
|
1379
|
+
return page.url();
|
|
1380
|
+
},
|
|
1381
|
+
count(selector) {
|
|
1382
|
+
return page.locator(selector).count();
|
|
1383
|
+
},
|
|
1384
|
+
textContent(selector) {
|
|
1385
|
+
return page.locator(selector).textContent();
|
|
1386
|
+
},
|
|
1387
|
+
async click(selector) {
|
|
1388
|
+
await page.locator(selector).click();
|
|
1389
|
+
},
|
|
1390
|
+
async clickFirst(selector) {
|
|
1391
|
+
await page.locator(selector).first().click();
|
|
1392
|
+
},
|
|
1393
|
+
async fill(selector, value) {
|
|
1394
|
+
await page.locator(selector).fill(value);
|
|
1395
|
+
},
|
|
1396
|
+
async fillFirst(selector, value) {
|
|
1397
|
+
await page.locator(selector).first().fill(value);
|
|
1398
|
+
},
|
|
1399
|
+
async press(selector, key) {
|
|
1400
|
+
await page.locator(selector).press(key);
|
|
1401
|
+
},
|
|
1402
|
+
async selectOption(selector, value) {
|
|
1403
|
+
await page.locator(selector).selectOption(value);
|
|
1404
|
+
},
|
|
1405
|
+
async selectOptionFirst(selector, value) {
|
|
1406
|
+
await page.locator(selector).first().selectOption(value);
|
|
1407
|
+
},
|
|
1408
|
+
async hover(selector) {
|
|
1409
|
+
await page.locator(selector).hover();
|
|
1410
|
+
},
|
|
1411
|
+
async scrollIntoView(selector) {
|
|
1412
|
+
await page.locator(selector).scrollIntoViewIfNeeded();
|
|
1413
|
+
},
|
|
1414
|
+
async setInputFiles(selector, files) {
|
|
1415
|
+
await page.locator(selector).setInputFiles(files);
|
|
1416
|
+
},
|
|
1417
|
+
countByRole(role, name) {
|
|
1418
|
+
return page.getByRole(role, { name }).count();
|
|
1419
|
+
},
|
|
1420
|
+
async clickFirstByRole(role, name) {
|
|
1421
|
+
await page.getByRole(role, { name }).first().click();
|
|
1422
|
+
},
|
|
1423
|
+
countByLabel(label) {
|
|
1424
|
+
return page.getByLabel(label, { exact: true }).count();
|
|
1425
|
+
},
|
|
1426
|
+
async fillFirstByLabel(label, value) {
|
|
1427
|
+
await page.getByLabel(label, { exact: true }).first().fill(value);
|
|
1428
|
+
},
|
|
1429
|
+
async selectOptionFirstByLabel(label, value) {
|
|
1430
|
+
await page.getByLabel(label, { exact: true }).first().selectOption(value);
|
|
1431
|
+
},
|
|
1432
|
+
async waitForSelector(selector, options) {
|
|
1433
|
+
await page.waitForSelector(selector, { timeout: options?.timeout });
|
|
1434
|
+
},
|
|
1435
|
+
async waitForUrl(predicate, options) {
|
|
1436
|
+
await page.waitForURL((url) => predicate(url.toString()), { timeout: options?.timeout });
|
|
1437
|
+
},
|
|
1438
|
+
async waitForNetworkIdle(options) {
|
|
1439
|
+
await page.waitForLoadState("networkidle", { timeout: options?.timeout });
|
|
1440
|
+
},
|
|
1441
|
+
evaluate(pageFunction, arg) {
|
|
1442
|
+
const raw = page.evaluate;
|
|
1443
|
+
const result = arg === void 0 ? raw.call(page, pageFunction) : raw.call(page, pageFunction, arg);
|
|
1444
|
+
return result;
|
|
1445
|
+
},
|
|
1446
|
+
async screenshot(options) {
|
|
1447
|
+
await page.screenshot({ path: options.path, fullPage: options.fullPage });
|
|
1448
|
+
},
|
|
1449
|
+
onResponse(handler) {
|
|
1450
|
+
page.on("response", handler);
|
|
1451
|
+
},
|
|
1452
|
+
async route(url, handler) {
|
|
1453
|
+
await page.route(url, async (pwRoute) => {
|
|
1454
|
+
try {
|
|
1455
|
+
await handler({
|
|
1456
|
+
fulfill: (response) => pwRoute.fulfill(response)
|
|
1457
|
+
});
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
try {
|
|
1460
|
+
await pwRoute.abort("failed");
|
|
1461
|
+
} catch (abortError) {
|
|
1462
|
+
throw new Error(
|
|
1463
|
+
`Route handler failed for ${url}: ${formatError(error)}. Route abort also failed: ${formatError(abortError)}`
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
throw new Error(`Route handler failed for ${url}: ${formatError(error)}`);
|
|
1467
|
+
}
|
|
1468
|
+
});
|
|
1469
|
+
},
|
|
1470
|
+
async unroute(url) {
|
|
1471
|
+
await page.unroute(url);
|
|
1472
|
+
},
|
|
1473
|
+
onDialog(action) {
|
|
1474
|
+
page.once("dialog", (dialog) => {
|
|
1475
|
+
const response = action === "accept" ? dialog.accept() : dialog.dismiss();
|
|
1476
|
+
response.catch((error) => {
|
|
1477
|
+
console.warn(`Failed to ${action} dialog: ${formatError(error)}`);
|
|
1478
|
+
});
|
|
1479
|
+
});
|
|
1480
|
+
},
|
|
1481
|
+
waitForDownloadEvent(options) {
|
|
1482
|
+
return page.waitForEvent("download", { timeout: options?.timeout });
|
|
1483
|
+
},
|
|
1484
|
+
parseTextSelector(selector) {
|
|
1485
|
+
return unwrapTextSelector(selector);
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1271
1488
|
}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1489
|
+
|
|
1490
|
+
// src/browser/mac-helper.ts
|
|
1491
|
+
var import_node_child_process2 = require("child_process");
|
|
1492
|
+
var import_node_fs4 = __toESM(require("fs"), 1);
|
|
1493
|
+
var import_node_path4 = __toESM(require("path"), 1);
|
|
1494
|
+
var import_node_url = require("url");
|
|
1495
|
+
|
|
1496
|
+
// src/browser/mac-driver.ts
|
|
1497
|
+
var MAC_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
1498
|
+
"query",
|
|
1499
|
+
"interact",
|
|
1500
|
+
"wait",
|
|
1501
|
+
"screenshot"
|
|
1502
|
+
]);
|
|
1503
|
+
var STATUS_ITEM_SELECTOR = "statusitem";
|
|
1504
|
+
var MENU_PREFIX = "menu=";
|
|
1505
|
+
function unquote(value) {
|
|
1506
|
+
const trimmed = value.trim();
|
|
1507
|
+
const first = trimmed[0];
|
|
1508
|
+
if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
|
|
1509
|
+
return trimmed.slice(1, -1);
|
|
1275
1510
|
}
|
|
1511
|
+
return trimmed;
|
|
1276
1512
|
}
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1513
|
+
function parseMacSelector(selector) {
|
|
1514
|
+
const trimmed = selector.trim();
|
|
1515
|
+
const idMatch = /^id=(.+)$/s.exec(trimmed);
|
|
1516
|
+
if (idMatch) {
|
|
1517
|
+
return { by: "id", value: unquote(idMatch[1]) };
|
|
1281
1518
|
}
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
return { selector };
|
|
1519
|
+
const roleMatch = /^role=([A-Za-z][\w-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
|
|
1520
|
+
if (roleMatch) {
|
|
1521
|
+
const name = roleMatch[2] !== void 0 ? unquote(roleMatch[2]) : void 0;
|
|
1522
|
+
return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
|
|
1287
1523
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1524
|
+
const labelMatch = /^label=(.+)$/s.exec(trimmed);
|
|
1525
|
+
if (labelMatch) {
|
|
1526
|
+
return { by: "label", value: unquote(labelMatch[1]) };
|
|
1290
1527
|
}
|
|
1291
|
-
const
|
|
1292
|
-
if (
|
|
1293
|
-
return {
|
|
1528
|
+
const textMatch = /^text=(.+)$/s.exec(trimmed);
|
|
1529
|
+
if (textMatch) {
|
|
1530
|
+
return { by: "text", value: unquote(textMatch[1]) };
|
|
1294
1531
|
}
|
|
1295
|
-
|
|
1296
|
-
console.warn(
|
|
1297
|
-
`Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
|
|
1298
|
-
);
|
|
1299
|
-
return { selector: healed.selector, healedFrom: healed.healedFrom };
|
|
1532
|
+
return { by: "text", value: trimmed };
|
|
1300
1533
|
}
|
|
1301
|
-
function
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
}
|
|
1306
|
-
throw new Error(`Hunt has ${stepCount} steps. Max allowed is ${maxSteps}.`);
|
|
1534
|
+
function unwrapMacTextSelector(selector) {
|
|
1535
|
+
const trimmed = selector.trim();
|
|
1536
|
+
if (!trimmed.startsWith("text=")) {
|
|
1537
|
+
return null;
|
|
1307
1538
|
}
|
|
1539
|
+
return unquote(trimmed.slice(5));
|
|
1308
1540
|
}
|
|
1309
|
-
function
|
|
1310
|
-
|
|
1311
|
-
if ("click" in step) return "click";
|
|
1312
|
-
if ("fill" in step) return "fill";
|
|
1313
|
-
if ("type" in step) return "type";
|
|
1314
|
-
if ("selectOption" in step) return "selectOption";
|
|
1315
|
-
if ("select" in step) return "select";
|
|
1316
|
-
if ("onDialog" in step) return "onDialog";
|
|
1317
|
-
if ("setInputFiles" in step) return "setInputFiles";
|
|
1318
|
-
if ("runHunt" in step) return "runHunt";
|
|
1319
|
-
if ("assert" in step) return "assert";
|
|
1320
|
-
if ("press" in step) return "press";
|
|
1321
|
-
if ("wait" in step) return "wait";
|
|
1322
|
-
if ("waitForSelector" in step) return "waitForSelector";
|
|
1323
|
-
if ("waitForUrl" in step) return "waitForUrl";
|
|
1324
|
-
if ("waitForNetworkIdle" in step) return "waitForNetworkIdle";
|
|
1325
|
-
if ("hover" in step) return "hover";
|
|
1326
|
-
if ("scroll" in step) return "scroll";
|
|
1327
|
-
if ("scrollTo" in step) return "scrollTo";
|
|
1328
|
-
if ("screenshot" in step) return "screenshot";
|
|
1329
|
-
if ("if" in step) return "if";
|
|
1330
|
-
if ("repeat" in step) return "repeat";
|
|
1331
|
-
if ("mockRoute" in step) return "mockRoute";
|
|
1332
|
-
if ("unmockRoute" in step) return "unmockRoute";
|
|
1333
|
-
if ("evalScript" in step) return "evalScript";
|
|
1334
|
-
if ("runScript" in step) return "runScript";
|
|
1335
|
-
if ("assertScreenshot" in step) return "assertScreenshot";
|
|
1336
|
-
if ("copyText" in step) return "copyText";
|
|
1337
|
-
if ("waitForDownload" in step) return "waitForDownload";
|
|
1338
|
-
return "step";
|
|
1339
|
-
}
|
|
1340
|
-
var RUNTIME_VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
|
|
1341
|
-
function substituteRuntimeVars(input, vars) {
|
|
1342
|
-
return input.replace(RUNTIME_VAR_PATTERN, (match, name) => {
|
|
1343
|
-
const value = vars.get(name);
|
|
1344
|
-
return value !== void 0 ? value : match;
|
|
1345
|
-
});
|
|
1541
|
+
function num(value) {
|
|
1542
|
+
return typeof value === "number" ? value : Number(value ?? 0);
|
|
1346
1543
|
}
|
|
1347
|
-
function
|
|
1348
|
-
const
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
return { click: { selector: sub(step.click.selector) } };
|
|
1544
|
+
function createMacDriver(client, options = {}) {
|
|
1545
|
+
const unsupported = (verb) => new Error(`${verb} is not supported by the macOS target`);
|
|
1546
|
+
const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
|
|
1547
|
+
async function query(cmd, selector, extra) {
|
|
1548
|
+
return client.request(cmd, { query: parseMacSelector(selector), ...extra });
|
|
1353
1549
|
}
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1550
|
+
async function clickSelector(selector) {
|
|
1551
|
+
const trimmed = selector.trim();
|
|
1552
|
+
if (trimmed.toLowerCase() === STATUS_ITEM_SELECTOR) {
|
|
1553
|
+
await client.request("openMenu");
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
if (trimmed.toLowerCase().startsWith(MENU_PREFIX)) {
|
|
1557
|
+
await client.request("clickMenu", { title: trimmed.slice(MENU_PREFIX.length).trim() });
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
await query("click", selector);
|
|
1561
|
+
}
|
|
1562
|
+
async function fillSelector(selector, value) {
|
|
1563
|
+
if (selector.trim() === ":focus") {
|
|
1564
|
+
await client.request("fill", { query: { by: "focused" }, value });
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
await query("fill", selector, { value });
|
|
1568
|
+
}
|
|
1569
|
+
return {
|
|
1570
|
+
capabilities: MAC_CAPABILITIES,
|
|
1571
|
+
// navigation -----------------------------------------------------------
|
|
1572
|
+
goto(_url, _options) {
|
|
1573
|
+
return rejectUnsupported("navigate");
|
|
1574
|
+
},
|
|
1575
|
+
currentUrl() {
|
|
1576
|
+
return `macos:${options.appLabel ?? ""}`;
|
|
1577
|
+
},
|
|
1578
|
+
// queries --------------------------------------------------------------
|
|
1579
|
+
async count(selector) {
|
|
1580
|
+
const result = await query("count", selector);
|
|
1581
|
+
return num(result.count);
|
|
1582
|
+
},
|
|
1583
|
+
async textContent(selector) {
|
|
1584
|
+
const result = await query("text", selector);
|
|
1585
|
+
return result.text === void 0 || result.text === null ? null : String(result.text);
|
|
1586
|
+
},
|
|
1587
|
+
// interactions ---------------------------------------------------------
|
|
1588
|
+
click: clickSelector,
|
|
1589
|
+
clickFirst: clickSelector,
|
|
1590
|
+
fill: fillSelector,
|
|
1591
|
+
fillFirst: fillSelector,
|
|
1592
|
+
async press(selector, key) {
|
|
1593
|
+
await query("press", selector, { key });
|
|
1594
|
+
},
|
|
1595
|
+
selectOption() {
|
|
1596
|
+
return rejectUnsupported("select");
|
|
1597
|
+
},
|
|
1598
|
+
selectOptionFirst() {
|
|
1599
|
+
return rejectUnsupported("select");
|
|
1600
|
+
},
|
|
1601
|
+
async hover(selector) {
|
|
1602
|
+
await query("hover", selector);
|
|
1603
|
+
},
|
|
1604
|
+
async scrollIntoView(selector) {
|
|
1605
|
+
await query("scrollTo", selector);
|
|
1606
|
+
},
|
|
1607
|
+
setInputFiles() {
|
|
1608
|
+
return rejectUnsupported("setInputFiles");
|
|
1609
|
+
},
|
|
1610
|
+
// semantic locators ----------------------------------------------------
|
|
1611
|
+
async countByRole(role, name) {
|
|
1612
|
+
const result = await client.request("count", { query: { by: "role", role, name } });
|
|
1613
|
+
return num(result.count);
|
|
1614
|
+
},
|
|
1615
|
+
async clickFirstByRole(role, name) {
|
|
1616
|
+
await client.request("click", { query: { by: "role", role, name } });
|
|
1617
|
+
},
|
|
1618
|
+
async countByLabel(label) {
|
|
1619
|
+
const result = await client.request("count", { query: { by: "label", value: label } });
|
|
1620
|
+
return num(result.count);
|
|
1621
|
+
},
|
|
1622
|
+
async fillFirstByLabel(label, value) {
|
|
1623
|
+
await client.request("fill", { query: { by: "label", value: label }, value });
|
|
1624
|
+
},
|
|
1625
|
+
selectOptionFirstByLabel() {
|
|
1626
|
+
return rejectUnsupported("select");
|
|
1627
|
+
},
|
|
1628
|
+
// waiting --------------------------------------------------------------
|
|
1629
|
+
async waitForSelector(selector, waitOptions) {
|
|
1630
|
+
const extra = waitOptions?.timeout !== void 0 ? { timeout: waitOptions.timeout / 1e3 } : void 0;
|
|
1631
|
+
await query("waitFor", selector, extra);
|
|
1632
|
+
},
|
|
1633
|
+
waitForUrl() {
|
|
1634
|
+
return rejectUnsupported("waitForUrl");
|
|
1635
|
+
},
|
|
1636
|
+
waitForNetworkIdle() {
|
|
1637
|
+
return rejectUnsupported("waitForNetworkIdle");
|
|
1638
|
+
},
|
|
1639
|
+
// scripting & artifacts ------------------------------------------------
|
|
1640
|
+
evaluate() {
|
|
1641
|
+
return rejectUnsupported("evalScript");
|
|
1642
|
+
},
|
|
1643
|
+
async screenshot(screenshotOptions) {
|
|
1644
|
+
await client.request("screenshot", { path: screenshotOptions.path });
|
|
1645
|
+
},
|
|
1646
|
+
// network / dialogs / downloads (all web-only) -------------------------
|
|
1647
|
+
onResponse(_handler) {
|
|
1648
|
+
throw unsupported("onResponse");
|
|
1649
|
+
},
|
|
1650
|
+
route(_url, _handler) {
|
|
1651
|
+
return rejectUnsupported("mockRoute");
|
|
1652
|
+
},
|
|
1653
|
+
unroute() {
|
|
1654
|
+
return rejectUnsupported("unmockRoute");
|
|
1655
|
+
},
|
|
1656
|
+
onDialog(_action) {
|
|
1657
|
+
throw unsupported("onDialog");
|
|
1658
|
+
},
|
|
1659
|
+
waitForDownloadEvent() {
|
|
1660
|
+
return rejectUnsupported("waitForDownload");
|
|
1661
|
+
},
|
|
1662
|
+
parseTextSelector(selector) {
|
|
1663
|
+
return unwrapMacTextSelector(selector);
|
|
1664
|
+
}
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// src/browser/mac-helper.ts
|
|
1669
|
+
var import_meta = {};
|
|
1670
|
+
var HELPER_BINARY = "prowl-macdriver";
|
|
1671
|
+
function macdriverBuildInstructions() {
|
|
1672
|
+
return "The macOS target requires the experimental `prowl-macdriver` helper, which is not shipped in the npm package. Build it locally:\n cd macdriver && swift build -c release\nor point Prowl at a prebuilt binary via the PROWL_MACDRIVER_BIN environment variable.";
|
|
1673
|
+
}
|
|
1674
|
+
function getPackageRoot() {
|
|
1675
|
+
let dir = import_node_path4.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
|
|
1676
|
+
const root = import_node_path4.default.parse(dir).root;
|
|
1677
|
+
while (dir !== root) {
|
|
1678
|
+
if (import_node_fs4.default.existsSync(import_node_path4.default.join(dir, "package.json"))) {
|
|
1679
|
+
return dir;
|
|
1680
|
+
}
|
|
1681
|
+
dir = import_node_path4.default.dirname(dir);
|
|
1682
|
+
}
|
|
1683
|
+
return root;
|
|
1684
|
+
}
|
|
1685
|
+
function resolveHelperBinary(env = process.env) {
|
|
1686
|
+
const override = env.PROWL_MACDRIVER_BIN;
|
|
1687
|
+
if (override) {
|
|
1688
|
+
if (!import_node_fs4.default.existsSync(override)) {
|
|
1689
|
+
throw new Error(
|
|
1690
|
+
`PROWL_MACDRIVER_BIN points at a missing file: ${override}
|
|
1691
|
+
${macdriverBuildInstructions()}`
|
|
1692
|
+
);
|
|
1693
|
+
}
|
|
1694
|
+
return override;
|
|
1695
|
+
}
|
|
1696
|
+
const root = getPackageRoot();
|
|
1697
|
+
const candidates = [
|
|
1698
|
+
import_node_path4.default.join(root, "macdriver", ".build", "release", HELPER_BINARY),
|
|
1699
|
+
import_node_path4.default.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
|
|
1700
|
+
];
|
|
1701
|
+
for (const candidate of candidates) {
|
|
1702
|
+
if (import_node_fs4.default.existsSync(candidate)) {
|
|
1703
|
+
return candidate;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
throw new Error(`Could not find the ${HELPER_BINARY} helper binary.
|
|
1707
|
+
${macdriverBuildInstructions()}`);
|
|
1708
|
+
}
|
|
1709
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
1710
|
+
var SpawnMacHelperClient = class {
|
|
1711
|
+
child;
|
|
1712
|
+
pending = /* @__PURE__ */ new Map();
|
|
1713
|
+
requestTimeoutMs;
|
|
1714
|
+
stdoutBuffer = "";
|
|
1715
|
+
stderrBuffer = "";
|
|
1716
|
+
nextId = 1;
|
|
1717
|
+
closed = false;
|
|
1718
|
+
terminalError;
|
|
1719
|
+
constructor(binaryPath, options = {}) {
|
|
1720
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
1721
|
+
this.child = (0, import_node_child_process2.spawn)(binaryPath, ["serve"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
1722
|
+
this.child.stdout?.setEncoding("utf-8");
|
|
1723
|
+
this.child.stderr?.setEncoding("utf-8");
|
|
1724
|
+
this.child.stdout?.on("data", (chunk) => this.onStdout(chunk));
|
|
1725
|
+
this.child.stderr?.on("data", (chunk) => {
|
|
1726
|
+
this.stderrBuffer = (this.stderrBuffer + chunk).slice(-4e3);
|
|
1727
|
+
});
|
|
1728
|
+
this.child.on("error", (error) => this.recordTerminalFailure(error));
|
|
1729
|
+
this.child.on("exit", (code) => {
|
|
1730
|
+
if (!this.closed) {
|
|
1731
|
+
const detail = this.stderrBuffer.trim();
|
|
1732
|
+
this.recordTerminalFailure(
|
|
1733
|
+
new Error(`prowl-macdriver exited unexpectedly (code ${code ?? "null"})${detail ? `: ${detail}` : ""}`)
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
onStdout(chunk) {
|
|
1739
|
+
this.stdoutBuffer += chunk;
|
|
1740
|
+
let newlineIndex = this.stdoutBuffer.indexOf("\n");
|
|
1741
|
+
while (newlineIndex !== -1) {
|
|
1742
|
+
const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
|
|
1743
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
|
1744
|
+
if (line.length > 0) {
|
|
1745
|
+
this.dispatch(line);
|
|
1746
|
+
}
|
|
1747
|
+
newlineIndex = this.stdoutBuffer.indexOf("\n");
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
dispatch(line) {
|
|
1751
|
+
let message;
|
|
1752
|
+
try {
|
|
1753
|
+
message = JSON.parse(line);
|
|
1754
|
+
} catch {
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
const id = typeof message.id === "number" ? message.id : void 0;
|
|
1758
|
+
if (id === void 0) {
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
const pending = this.pending.get(id);
|
|
1762
|
+
if (!pending) {
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
this.pending.delete(id);
|
|
1766
|
+
clearTimeout(pending.timer);
|
|
1767
|
+
if (message.ok === true) {
|
|
1768
|
+
pending.resolve(message.result ?? {});
|
|
1769
|
+
} else {
|
|
1770
|
+
pending.reject(new Error(typeof message.error === "string" ? message.error : "prowl-macdriver error"));
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
failAll(error) {
|
|
1774
|
+
for (const pending of this.pending.values()) {
|
|
1775
|
+
clearTimeout(pending.timer);
|
|
1776
|
+
pending.reject(error);
|
|
1777
|
+
}
|
|
1778
|
+
this.pending.clear();
|
|
1779
|
+
}
|
|
1780
|
+
recordTerminalFailure(error) {
|
|
1781
|
+
this.terminalError ??= error;
|
|
1782
|
+
this.closed = true;
|
|
1783
|
+
this.failAll(this.terminalError);
|
|
1784
|
+
}
|
|
1785
|
+
/** Number of in-flight requests awaiting a response (for teardown/tests). */
|
|
1786
|
+
get pendingCount() {
|
|
1787
|
+
return this.pending.size;
|
|
1788
|
+
}
|
|
1789
|
+
request(cmd, params = {}) {
|
|
1790
|
+
if (this.terminalError) {
|
|
1791
|
+
return Promise.reject(this.terminalError);
|
|
1792
|
+
}
|
|
1793
|
+
if (this.closed) {
|
|
1794
|
+
return Promise.reject(new Error("prowl-macdriver client is closed"));
|
|
1795
|
+
}
|
|
1796
|
+
const id = this.nextId++;
|
|
1797
|
+
const payload = JSON.stringify({ id, cmd, ...params });
|
|
1798
|
+
return new Promise((resolve, reject) => {
|
|
1799
|
+
const timer = setTimeout(() => {
|
|
1800
|
+
if (this.pending.delete(id)) {
|
|
1801
|
+
const shown = this.requestTimeoutMs >= 1e3 ? `${Math.round(this.requestTimeoutMs / 1e3)}s` : `${this.requestTimeoutMs}ms`;
|
|
1802
|
+
reject(new Error(`prowl-macdriver request "${cmd}" timed out after ${shown}`));
|
|
1803
|
+
}
|
|
1804
|
+
}, this.requestTimeoutMs);
|
|
1805
|
+
timer.unref?.();
|
|
1806
|
+
this.pending.set(id, { cmd, resolve, reject, timer });
|
|
1807
|
+
this.child.stdin?.write(payload + "\n", (error) => {
|
|
1808
|
+
if (error && this.pending.delete(id)) {
|
|
1809
|
+
clearTimeout(timer);
|
|
1810
|
+
reject(error);
|
|
1811
|
+
}
|
|
1812
|
+
});
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
async close() {
|
|
1816
|
+
if (this.closed) {
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
this.closed = true;
|
|
1820
|
+
try {
|
|
1821
|
+
this.child.stdin?.write(JSON.stringify({ cmd: "shutdown" }) + "\n");
|
|
1822
|
+
this.child.stdin?.end();
|
|
1823
|
+
} catch {
|
|
1824
|
+
}
|
|
1825
|
+
await new Promise((resolve) => {
|
|
1826
|
+
if (this.child.exitCode !== null || this.child.signalCode !== null) {
|
|
1827
|
+
resolve();
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
const timer = setTimeout(() => {
|
|
1831
|
+
this.child.kill("SIGKILL");
|
|
1832
|
+
resolve();
|
|
1833
|
+
}, 2e3);
|
|
1834
|
+
this.child.once("exit", () => {
|
|
1835
|
+
clearTimeout(timer);
|
|
1836
|
+
resolve();
|
|
1837
|
+
});
|
|
1838
|
+
});
|
|
1839
|
+
this.failAll(new Error("prowl-macdriver client is closed"));
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
async function launchMacSession(options) {
|
|
1843
|
+
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_REQUEST_TIMEOUT_MS) + 5e3;
|
|
1844
|
+
const client = options.clientFactory ? options.clientFactory() : new SpawnMacHelperClient(resolveHelperBinary(), { requestTimeoutMs });
|
|
1845
|
+
const timeoutSeconds = (options.timeoutMs ?? 1e4) / 1e3;
|
|
1846
|
+
try {
|
|
1847
|
+
const trust = await client.request("check");
|
|
1848
|
+
if (trust.trusted !== true) {
|
|
1849
|
+
throw new Error(
|
|
1850
|
+
"Prowl's macOS target is not trusted for Accessibility. Grant the hosting terminal/app permission in System Settings \u2192 Privacy & Security \u2192 Accessibility, then retry."
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
const launched = await client.request("launch", { app: options.app, timeout: timeoutSeconds });
|
|
1854
|
+
const bundleId = String(launched.bundleId ?? options.app);
|
|
1855
|
+
const driver = createMacDriver(client, { appLabel: bundleId });
|
|
1856
|
+
return { client, driver, bundleId };
|
|
1857
|
+
} catch (error) {
|
|
1858
|
+
await client.close().catch(() => void 0);
|
|
1859
|
+
throw error;
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
async function closeMacSession(session) {
|
|
1863
|
+
try {
|
|
1864
|
+
await session.client.request("quit");
|
|
1865
|
+
} catch {
|
|
1866
|
+
} finally {
|
|
1867
|
+
await session.client.close();
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// src/runner/steps.ts
|
|
1872
|
+
var import_node_fs6 = __toESM(require("fs"), 1);
|
|
1873
|
+
var import_node_path6 = __toESM(require("path"), 1);
|
|
1874
|
+
init_loader();
|
|
1875
|
+
|
|
1876
|
+
// src/runner/healing.ts
|
|
1877
|
+
var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
|
|
1878
|
+
function extractSelectorIntent(selector) {
|
|
1879
|
+
const raw = [];
|
|
1880
|
+
for (const match of selector.matchAll(/[#.]([A-Za-z_][\w-]*)/g)) {
|
|
1881
|
+
raw.push(match[1]);
|
|
1882
|
+
}
|
|
1883
|
+
for (const match of selector.matchAll(/\[[A-Za-z_:-]+\s*[~|^$*]?=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\]/g)) {
|
|
1884
|
+
const value = match[1] ?? match[2] ?? match[3];
|
|
1885
|
+
if (value) raw.push(value);
|
|
1886
|
+
}
|
|
1887
|
+
const words = [];
|
|
1888
|
+
for (const token of raw) {
|
|
1889
|
+
for (const part of splitToken(token)) {
|
|
1890
|
+
const lower = part.toLowerCase();
|
|
1891
|
+
if (lower.length > 0 && !words.includes(lower)) {
|
|
1892
|
+
words.push(lower);
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
return { words, label: words.join(" ") };
|
|
1897
|
+
}
|
|
1898
|
+
function splitToken(token) {
|
|
1899
|
+
return token.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[\s\-_.:]+/).filter((part) => part.length > 0);
|
|
1900
|
+
}
|
|
1901
|
+
function buildHealCandidates(selector) {
|
|
1902
|
+
const { words, label } = extractSelectorIntent(selector);
|
|
1903
|
+
if (words.length === 0) return [];
|
|
1904
|
+
const escaped = label.replace(/"/g, '\\"');
|
|
1905
|
+
const candidates = [];
|
|
1906
|
+
candidates.push({ selector: `text=${label}`, strategy: "text" });
|
|
1907
|
+
candidates.push({ selector: `[aria-label*="${escaped}" i]`, strategy: "aria" });
|
|
1908
|
+
for (const tag of INTERACTIVE_TAGS) {
|
|
1909
|
+
candidates.push({ selector: `${tag}:has-text("${escaped}")`, strategy: "structural" });
|
|
1910
|
+
}
|
|
1911
|
+
return candidates;
|
|
1912
|
+
}
|
|
1913
|
+
async function healSelector(probe, selector, options) {
|
|
1914
|
+
if (!options.enabled) return null;
|
|
1915
|
+
for (const candidate of buildHealCandidates(selector)) {
|
|
1916
|
+
let count;
|
|
1917
|
+
try {
|
|
1918
|
+
const locator = probe.locator(candidate.selector);
|
|
1919
|
+
count = await locator.count();
|
|
1920
|
+
} catch {
|
|
1921
|
+
continue;
|
|
1922
|
+
}
|
|
1923
|
+
if (count === 1) {
|
|
1924
|
+
return { selector: candidate.selector, healedFrom: selector, strategy: candidate.strategy };
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
return null;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// src/runner/policy.ts
|
|
1931
|
+
var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
|
|
1932
|
+
function createRunPolicy(driver, options) {
|
|
1933
|
+
const { forbiddenSelectors, allowedDomains, maxSteps, selfHealing } = options;
|
|
1934
|
+
const allowedApps = options.allowedApps ?? [];
|
|
1935
|
+
function matchesForbiddenPattern(selector, forbidden) {
|
|
1936
|
+
const selectorText = driver.parseTextSelector(selector);
|
|
1937
|
+
if (selectorText === null) {
|
|
1938
|
+
return false;
|
|
1939
|
+
}
|
|
1940
|
+
const forbiddenText = driver.parseTextSelector(forbidden);
|
|
1941
|
+
if (forbiddenText !== null) {
|
|
1942
|
+
return selectorText.includes(forbiddenText);
|
|
1943
|
+
}
|
|
1944
|
+
return selectorText.includes(forbidden);
|
|
1945
|
+
}
|
|
1946
|
+
function isForbiddenSelector(selector) {
|
|
1947
|
+
return forbiddenSelectors.some(
|
|
1948
|
+
(forbidden) => selector.includes(forbidden) || matchesForbiddenPattern(selector, forbidden)
|
|
1949
|
+
);
|
|
1950
|
+
}
|
|
1951
|
+
function assertAllowedSelector(selector) {
|
|
1952
|
+
if (isForbiddenSelector(selector)) {
|
|
1953
|
+
throw new Error(`Forbidden selector: ${selector}`);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
function assertWithinMaxSteps(stepCount, huntName) {
|
|
1957
|
+
if (stepCount > maxSteps) {
|
|
1958
|
+
if (huntName) {
|
|
1959
|
+
throw new Error(`Hunt "${huntName}" has ${stepCount} steps. Max allowed is ${maxSteps}.`);
|
|
1960
|
+
}
|
|
1961
|
+
throw new Error(`Hunt has ${stepCount} steps. Max allowed is ${maxSteps}.`);
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
function ensureUrlAllowed(urlValue) {
|
|
1965
|
+
for (const protocol of ALWAYS_ALLOWED_PROTOCOLS) {
|
|
1966
|
+
if (urlValue.startsWith(protocol)) {
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
let url;
|
|
1971
|
+
try {
|
|
1972
|
+
url = new URL(urlValue);
|
|
1973
|
+
} catch {
|
|
1974
|
+
throw new Error(`Navigation target is not a valid absolute URL: ${urlValue}`);
|
|
1975
|
+
}
|
|
1976
|
+
if (!allowedDomains.includes(url.hostname)) {
|
|
1977
|
+
throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
function ensureAppAllowed(app) {
|
|
1981
|
+
if (!allowedApps.includes(app)) {
|
|
1982
|
+
throw new Error(`Interaction with disallowed app: ${app}`);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
function ensureLocationAllowed(activeDriver) {
|
|
1986
|
+
if (activeDriver.capabilities.has("navigate")) {
|
|
1987
|
+
ensureUrlAllowed(activeDriver.currentUrl());
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
const healProbe = {
|
|
1991
|
+
locator: (selector) => ({ count: () => driver.count(selector) })
|
|
1992
|
+
};
|
|
1993
|
+
async function resolveActionSelector(selector) {
|
|
1994
|
+
assertAllowedSelector(selector);
|
|
1995
|
+
if (!selfHealing) {
|
|
1996
|
+
return { selector };
|
|
1997
|
+
}
|
|
1998
|
+
let matched = false;
|
|
1999
|
+
try {
|
|
2000
|
+
matched = await driver.count(selector) > 0;
|
|
2001
|
+
} catch {
|
|
2002
|
+
return { selector };
|
|
2003
|
+
}
|
|
2004
|
+
if (matched) {
|
|
2005
|
+
return { selector };
|
|
2006
|
+
}
|
|
2007
|
+
const healed = await healSelector(healProbe, selector, { enabled: true });
|
|
2008
|
+
if (!healed) {
|
|
2009
|
+
return { selector };
|
|
2010
|
+
}
|
|
2011
|
+
assertAllowedSelector(healed.selector);
|
|
2012
|
+
console.warn(
|
|
2013
|
+
`Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
|
|
2014
|
+
);
|
|
2015
|
+
return { selector: healed.selector, healedFrom: healed.healedFrom };
|
|
2016
|
+
}
|
|
2017
|
+
return {
|
|
2018
|
+
assertWithinMaxSteps,
|
|
2019
|
+
ensureUrlAllowed,
|
|
2020
|
+
ensureAppAllowed,
|
|
2021
|
+
ensureLocationAllowed,
|
|
2022
|
+
assertAllowedSelector,
|
|
2023
|
+
resolveActionSelector
|
|
2024
|
+
};
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// src/runner/steps.ts
|
|
2028
|
+
function getStepType(step) {
|
|
2029
|
+
if ("navigate" in step) return "navigate";
|
|
2030
|
+
if ("click" in step) return "click";
|
|
2031
|
+
if ("fill" in step) return "fill";
|
|
2032
|
+
if ("type" in step) return "type";
|
|
2033
|
+
if ("selectOption" in step) return "selectOption";
|
|
2034
|
+
if ("select" in step) return "select";
|
|
2035
|
+
if ("onDialog" in step) return "onDialog";
|
|
2036
|
+
if ("setInputFiles" in step) return "setInputFiles";
|
|
2037
|
+
if ("runHunt" in step) return "runHunt";
|
|
2038
|
+
if ("assert" in step) return "assert";
|
|
2039
|
+
if ("press" in step) return "press";
|
|
2040
|
+
if ("wait" in step) return "wait";
|
|
2041
|
+
if ("waitForSelector" in step) return "waitForSelector";
|
|
2042
|
+
if ("waitForUrl" in step) return "waitForUrl";
|
|
2043
|
+
if ("waitForNetworkIdle" in step) return "waitForNetworkIdle";
|
|
2044
|
+
if ("hover" in step) return "hover";
|
|
2045
|
+
if ("scroll" in step) return "scroll";
|
|
2046
|
+
if ("scrollTo" in step) return "scrollTo";
|
|
2047
|
+
if ("screenshot" in step) return "screenshot";
|
|
2048
|
+
if ("if" in step) return "if";
|
|
2049
|
+
if ("repeat" in step) return "repeat";
|
|
2050
|
+
if ("mockRoute" in step) return "mockRoute";
|
|
2051
|
+
if ("unmockRoute" in step) return "unmockRoute";
|
|
2052
|
+
if ("evalScript" in step) return "evalScript";
|
|
2053
|
+
if ("runScript" in step) return "runScript";
|
|
2054
|
+
if ("assertScreenshot" in step) return "assertScreenshot";
|
|
2055
|
+
if ("copyText" in step) return "copyText";
|
|
2056
|
+
if ("waitForDownload" in step) return "waitForDownload";
|
|
2057
|
+
return "step";
|
|
2058
|
+
}
|
|
2059
|
+
var RUNTIME_VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
|
|
2060
|
+
function substituteRuntimeVars(input, vars) {
|
|
2061
|
+
return input.replace(RUNTIME_VAR_PATTERN, (match, name) => {
|
|
2062
|
+
const value = vars.get(name);
|
|
2063
|
+
return value !== void 0 ? value : match;
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
function applyRuntimeVars(step, vars) {
|
|
2067
|
+
const sub = (s) => substituteRuntimeVars(s, vars);
|
|
2068
|
+
if ("navigate" in step) return { navigate: sub(step.navigate) };
|
|
2069
|
+
if ("click" in step) {
|
|
2070
|
+
if (typeof step.click === "string") return { click: sub(step.click) };
|
|
2071
|
+
return { click: { selector: sub(step.click.selector) } };
|
|
2072
|
+
}
|
|
2073
|
+
if ("fill" in step) {
|
|
2074
|
+
if ("selector" in step.fill && "value" in step.fill) {
|
|
2075
|
+
const f = step.fill;
|
|
2076
|
+
return { fill: { selector: sub(f.selector), value: sub(f.value) } };
|
|
1358
2077
|
}
|
|
1359
2078
|
const [key, value] = Object.entries(step.fill)[0];
|
|
1360
2079
|
return { fill: { [sub(key)]: sub(value) } };
|
|
@@ -1409,17 +2128,6 @@ function applyRuntimeVars(step, vars) {
|
|
|
1409
2128
|
function isExplicitFillStep(value) {
|
|
1410
2129
|
return typeof value.selector === "string" && typeof value.value === "string";
|
|
1411
2130
|
}
|
|
1412
|
-
function ensureAllowedUrl(urlValue, allowedDomains) {
|
|
1413
|
-
for (const protocol of ALWAYS_ALLOWED_PROTOCOLS) {
|
|
1414
|
-
if (urlValue.startsWith(protocol)) {
|
|
1415
|
-
return;
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
const url = new URL(urlValue);
|
|
1419
|
-
if (!allowedDomains.includes(url.hostname)) {
|
|
1420
|
-
throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
2131
|
function resolveNavigationTarget(targetUrl, value) {
|
|
1424
2132
|
try {
|
|
1425
2133
|
return new URL(value, targetUrl).toString();
|
|
@@ -1446,56 +2154,50 @@ function getSinglePair(value, stepType) {
|
|
|
1446
2154
|
}
|
|
1447
2155
|
return entries[0];
|
|
1448
2156
|
}
|
|
1449
|
-
async function clickByTextWithFallback(
|
|
2157
|
+
async function clickByTextWithFallback(driver, policy, text) {
|
|
1450
2158
|
const roleSelector = `role=button[name="${escapeForAttribute(text)}"]`;
|
|
1451
|
-
assertAllowedSelector(roleSelector
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
await button.first().click();
|
|
2159
|
+
policy.assertAllowedSelector(roleSelector);
|
|
2160
|
+
if (await driver.countByRole("button", text)) {
|
|
2161
|
+
await driver.clickFirstByRole("button", text);
|
|
1455
2162
|
return roleSelector;
|
|
1456
2163
|
}
|
|
1457
2164
|
const selector = exactTextSelector(text);
|
|
1458
|
-
assertAllowedSelector(selector
|
|
1459
|
-
await
|
|
2165
|
+
policy.assertAllowedSelector(selector);
|
|
2166
|
+
await driver.clickFirst(selector);
|
|
1460
2167
|
return selector;
|
|
1461
2168
|
}
|
|
1462
|
-
async function fillByLabelOrPlaceholder(
|
|
2169
|
+
async function fillByLabelOrPlaceholder(driver, policy, label, value) {
|
|
1463
2170
|
const labelSelector = `label="${escapeForAttribute(label)}"`;
|
|
1464
|
-
assertAllowedSelector(labelSelector
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
await byLabel.first().fill(value);
|
|
2171
|
+
policy.assertAllowedSelector(labelSelector);
|
|
2172
|
+
if (await driver.countByLabel(label)) {
|
|
2173
|
+
await driver.fillFirstByLabel(label, value);
|
|
1468
2174
|
return labelSelector;
|
|
1469
2175
|
}
|
|
1470
2176
|
const placeholder = `input[placeholder="${escapeForAttribute(label)}"], textarea[placeholder="${escapeForAttribute(label)}"]`;
|
|
1471
|
-
assertAllowedSelector(placeholder
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
await byPlaceholder.first().fill(value);
|
|
2177
|
+
policy.assertAllowedSelector(placeholder);
|
|
2178
|
+
if (await driver.count(placeholder)) {
|
|
2179
|
+
await driver.fillFirst(placeholder, value);
|
|
1475
2180
|
return placeholder;
|
|
1476
2181
|
}
|
|
1477
2182
|
throw new Error(`Could not resolve fill shorthand for "${label}"`);
|
|
1478
2183
|
}
|
|
1479
|
-
async function selectByLabelOrFallback(
|
|
2184
|
+
async function selectByLabelOrFallback(driver, policy, label, value) {
|
|
1480
2185
|
const labelSelector = `label="${escapeForAttribute(label)}"`;
|
|
1481
|
-
assertAllowedSelector(labelSelector
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
await byLabel.first().selectOption(value);
|
|
2186
|
+
policy.assertAllowedSelector(labelSelector);
|
|
2187
|
+
if (await driver.countByLabel(label)) {
|
|
2188
|
+
await driver.selectOptionFirstByLabel(label, value);
|
|
1485
2189
|
return labelSelector;
|
|
1486
2190
|
}
|
|
1487
2191
|
const ariaSelector = `select[aria-label="${escapeForAttribute(label)}"]`;
|
|
1488
|
-
assertAllowedSelector(ariaSelector
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
await byAria.first().selectOption(value);
|
|
2192
|
+
policy.assertAllowedSelector(ariaSelector);
|
|
2193
|
+
if (await driver.count(ariaSelector)) {
|
|
2194
|
+
await driver.selectOptionFirst(ariaSelector, value);
|
|
1492
2195
|
return ariaSelector;
|
|
1493
2196
|
}
|
|
1494
2197
|
const placeholderSelector = `select[placeholder="${escapeForAttribute(label)}"]`;
|
|
1495
|
-
assertAllowedSelector(placeholderSelector
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
await byPlaceholder.first().selectOption(value);
|
|
2198
|
+
policy.assertAllowedSelector(placeholderSelector);
|
|
2199
|
+
if (await driver.count(placeholderSelector)) {
|
|
2200
|
+
await driver.selectOptionFirst(placeholderSelector, value);
|
|
1499
2201
|
return placeholderSelector;
|
|
1500
2202
|
}
|
|
1501
2203
|
throw new Error(`Could not resolve select shorthand for "${label}"`);
|
|
@@ -1623,11 +2325,11 @@ function toVisibilitySelector(value) {
|
|
|
1623
2325
|
if (looksLikeSelector(value)) return value;
|
|
1624
2326
|
return textContainsSelector(value);
|
|
1625
2327
|
}
|
|
1626
|
-
async function runInlineAssert(
|
|
2328
|
+
async function runInlineAssert(driver, policy, assertion) {
|
|
1627
2329
|
if (assertion.visible !== void 0) {
|
|
1628
2330
|
const selector = toVisibilitySelector(assertion.visible);
|
|
1629
|
-
assertAllowedSelector(selector
|
|
1630
|
-
const count = await
|
|
2331
|
+
policy.assertAllowedSelector(selector);
|
|
2332
|
+
const count = await driver.count(selector);
|
|
1631
2333
|
if (count === 0) {
|
|
1632
2334
|
throw new Error(`Expected visible: ${assertion.visible}`);
|
|
1633
2335
|
}
|
|
@@ -1635,22 +2337,22 @@ async function runInlineAssert(page, assertion, forbiddenSelectors) {
|
|
|
1635
2337
|
}
|
|
1636
2338
|
if (assertion.notVisible !== void 0) {
|
|
1637
2339
|
const selector = toVisibilitySelector(assertion.notVisible);
|
|
1638
|
-
assertAllowedSelector(selector
|
|
1639
|
-
const count = await
|
|
2340
|
+
policy.assertAllowedSelector(selector);
|
|
2341
|
+
const count = await driver.count(selector);
|
|
1640
2342
|
if (count > 0) {
|
|
1641
2343
|
throw new Error(`Expected not visible: ${assertion.notVisible}`);
|
|
1642
2344
|
}
|
|
1643
2345
|
return `notVisible:${assertion.notVisible}`;
|
|
1644
2346
|
}
|
|
1645
2347
|
if (assertion.urlIncludes !== void 0) {
|
|
1646
|
-
const current =
|
|
2348
|
+
const current = driver.currentUrl();
|
|
1647
2349
|
if (!current.includes(assertion.urlIncludes)) {
|
|
1648
2350
|
throw new Error(`URL did not include ${assertion.urlIncludes}`);
|
|
1649
2351
|
}
|
|
1650
2352
|
return `urlIncludes:${assertion.urlIncludes}`;
|
|
1651
2353
|
}
|
|
1652
2354
|
if (assertion.urlEquals !== void 0) {
|
|
1653
|
-
const current =
|
|
2355
|
+
const current = driver.currentUrl();
|
|
1654
2356
|
if (current !== assertion.urlEquals) {
|
|
1655
2357
|
throw new Error(`URL did not equal ${assertion.urlEquals}`);
|
|
1656
2358
|
}
|
|
@@ -1659,7 +2361,7 @@ async function runInlineAssert(page, assertion, forbiddenSelectors) {
|
|
|
1659
2361
|
throw new Error("assert step is missing an assertion type");
|
|
1660
2362
|
}
|
|
1661
2363
|
function screenshotPath(screenshotsDir, fileName) {
|
|
1662
|
-
return
|
|
2364
|
+
return import_node_path6.default.join(screenshotsDir, fileName);
|
|
1663
2365
|
}
|
|
1664
2366
|
function stepPath(prefix, index) {
|
|
1665
2367
|
return prefix ? `${prefix}.${index}` : `${index}`;
|
|
@@ -1667,8 +2369,8 @@ function stepPath(prefix, index) {
|
|
|
1667
2369
|
function isWaitForDownloadStep(step) {
|
|
1668
2370
|
return step !== void 0 && "waitForDownload" in step;
|
|
1669
2371
|
}
|
|
1670
|
-
function armDownloadListener(
|
|
1671
|
-
const downloadPromise =
|
|
2372
|
+
function armDownloadListener(driver, timeout) {
|
|
2373
|
+
const downloadPromise = driver.waitForDownloadEvent({ timeout });
|
|
1672
2374
|
void downloadPromise.catch(() => void 0);
|
|
1673
2375
|
return downloadPromise;
|
|
1674
2376
|
}
|
|
@@ -1676,14 +2378,14 @@ function validateDownloadFilename(suggestedFilename) {
|
|
|
1676
2378
|
const safeFilename = suggestedFilename.trim();
|
|
1677
2379
|
const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
|
|
1678
2380
|
const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
|
|
1679
|
-
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !==
|
|
2381
|
+
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path6.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
|
|
1680
2382
|
throw new Error(`Invalid download filename: "${suggestedFilename}"`);
|
|
1681
2383
|
}
|
|
1682
2384
|
return safeFilename;
|
|
1683
2385
|
}
|
|
1684
|
-
async function captureScreenshot(
|
|
2386
|
+
async function captureScreenshot(taker, filePath) {
|
|
1685
2387
|
try {
|
|
1686
|
-
await
|
|
2388
|
+
await taker.screenshot({ path: filePath, fullPage: true });
|
|
1687
2389
|
} catch (error) {
|
|
1688
2390
|
const message = error instanceof Error ? error.message : "Screenshot failed";
|
|
1689
2391
|
throw new Error(`Failed to capture screenshot at ${filePath}: ${message}`);
|
|
@@ -1702,608 +2404,784 @@ async function executeNestedSteps(context, overrides) {
|
|
|
1702
2404
|
}
|
|
1703
2405
|
return result;
|
|
1704
2406
|
}
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
screenshots.push(relative);
|
|
1719
|
-
return relative;
|
|
1720
|
-
};
|
|
1721
|
-
for (let index = 0; index < context.steps.length; index += 1) {
|
|
1722
|
-
const currentStepPath = stepPath(context.stepPathPrefix, index);
|
|
1723
|
-
if (Date.now() - runStartedAtMs > context.maxTotalTimeMs) {
|
|
1724
|
-
results.push({
|
|
1725
|
-
type: "timeout",
|
|
1726
|
-
status: "fail",
|
|
1727
|
-
durationMs: 0,
|
|
1728
|
-
error: `Max total time exceeded (${context.maxTotalTimeMs}ms)`
|
|
1729
|
-
});
|
|
1730
|
-
return { results, screenshots, failed: true, error: "Max total time exceeded" };
|
|
1731
|
-
}
|
|
1732
|
-
const runtimeVars = context.runtimeVars ?? /* @__PURE__ */ new Map();
|
|
1733
|
-
context.runtimeVars = runtimeVars;
|
|
1734
|
-
let step = context.steps[index];
|
|
1735
|
-
if (runtimeVars.size > 0) {
|
|
1736
|
-
step = applyRuntimeVars(step, runtimeVars);
|
|
2407
|
+
function unknownStep() {
|
|
2408
|
+
throw new Error("Unknown step type");
|
|
2409
|
+
}
|
|
2410
|
+
var STEP_HANDLERS = {
|
|
2411
|
+
navigate: {
|
|
2412
|
+
capabilities: ["navigate"],
|
|
2413
|
+
run: async (h) => {
|
|
2414
|
+
if (!("navigate" in h.step)) unknownStep();
|
|
2415
|
+
const destination = resolveNavigationTarget(h.context.targetUrl, h.step.navigate);
|
|
2416
|
+
h.policy.ensureUrlAllowed(destination);
|
|
2417
|
+
await h.driver.goto(destination);
|
|
2418
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2419
|
+
return { kind: "result", result: { type: "navigate", status: "pass", durationMs: Date.now() - h.stepStart } };
|
|
1737
2420
|
}
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
let healedFrom;
|
|
1758
|
-
if (typeof step.click === "string") {
|
|
1759
|
-
selector = await clickByTextWithFallback(
|
|
1760
|
-
context.page,
|
|
1761
|
-
step.click,
|
|
1762
|
-
context.forbiddenSelectors
|
|
1763
|
-
);
|
|
1764
|
-
} else {
|
|
1765
|
-
const resolved = await resolveActionSelector(context, step.click.selector);
|
|
1766
|
-
await clickElement(context.page, resolved.selector);
|
|
1767
|
-
selector = resolved.selector;
|
|
1768
|
-
healedFrom = resolved.healedFrom;
|
|
1769
|
-
}
|
|
1770
|
-
ensureAllowedUrl(context.page.url(), context.allowedDomains);
|
|
1771
|
-
stepResult = {
|
|
2421
|
+
},
|
|
2422
|
+
click: {
|
|
2423
|
+
capabilities: ["interact", "query"],
|
|
2424
|
+
run: async (h) => {
|
|
2425
|
+
if (!("click" in h.step)) unknownStep();
|
|
2426
|
+
let selector;
|
|
2427
|
+
let healedFrom;
|
|
2428
|
+
if (typeof h.step.click === "string") {
|
|
2429
|
+
selector = await clickByTextWithFallback(h.driver, h.policy, h.step.click);
|
|
2430
|
+
} else {
|
|
2431
|
+
const resolved = await h.policy.resolveActionSelector(h.step.click.selector);
|
|
2432
|
+
await h.driver.click(resolved.selector);
|
|
2433
|
+
selector = resolved.selector;
|
|
2434
|
+
healedFrom = resolved.healedFrom;
|
|
2435
|
+
}
|
|
2436
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2437
|
+
return {
|
|
2438
|
+
kind: "result",
|
|
2439
|
+
result: {
|
|
1772
2440
|
type: "click",
|
|
1773
2441
|
status: "pass",
|
|
1774
|
-
durationMs: Date.now() - stepStart,
|
|
2442
|
+
durationMs: Date.now() - h.stepStart,
|
|
1775
2443
|
selector,
|
|
1776
2444
|
...healedFrom ? { healedFrom } : {}
|
|
1777
|
-
};
|
|
1778
|
-
} else if ("fill" in step) {
|
|
1779
|
-
let selector;
|
|
1780
|
-
let value;
|
|
1781
|
-
let healedFrom;
|
|
1782
|
-
if (isExplicitFillStep(step.fill)) {
|
|
1783
|
-
const resolved = await resolveActionSelector(context, step.fill.selector);
|
|
1784
|
-
await fillElement(context.page, resolved.selector, step.fill.value);
|
|
1785
|
-
selector = resolved.selector;
|
|
1786
|
-
healedFrom = resolved.healedFrom;
|
|
1787
|
-
value = step.fill.value;
|
|
1788
|
-
} else {
|
|
1789
|
-
const [label, shorthandValue] = getSinglePair(step.fill, "fill");
|
|
1790
|
-
selector = await fillByLabelOrPlaceholder(
|
|
1791
|
-
context.page,
|
|
1792
|
-
label,
|
|
1793
|
-
shorthandValue,
|
|
1794
|
-
context.forbiddenSelectors
|
|
1795
|
-
);
|
|
1796
|
-
value = shorthandValue;
|
|
1797
2445
|
}
|
|
1798
|
-
|
|
1799
|
-
|
|
2446
|
+
};
|
|
2447
|
+
}
|
|
2448
|
+
},
|
|
2449
|
+
fill: {
|
|
2450
|
+
capabilities: ["interact", "query"],
|
|
2451
|
+
run: async (h) => {
|
|
2452
|
+
if (!("fill" in h.step)) unknownStep();
|
|
2453
|
+
let selector;
|
|
2454
|
+
let value;
|
|
2455
|
+
let healedFrom;
|
|
2456
|
+
if (isExplicitFillStep(h.step.fill)) {
|
|
2457
|
+
const resolved = await h.policy.resolveActionSelector(h.step.fill.selector);
|
|
2458
|
+
await h.driver.fill(resolved.selector, h.step.fill.value);
|
|
2459
|
+
selector = resolved.selector;
|
|
2460
|
+
healedFrom = resolved.healedFrom;
|
|
2461
|
+
value = h.step.fill.value;
|
|
2462
|
+
} else {
|
|
2463
|
+
const [label, shorthandValue] = getSinglePair(h.step.fill, "fill");
|
|
2464
|
+
selector = await fillByLabelOrPlaceholder(h.driver, h.policy, label, shorthandValue);
|
|
2465
|
+
value = shorthandValue;
|
|
2466
|
+
}
|
|
2467
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2468
|
+
return {
|
|
2469
|
+
kind: "result",
|
|
2470
|
+
result: {
|
|
1800
2471
|
type: "fill",
|
|
1801
2472
|
status: "pass",
|
|
1802
|
-
durationMs: Date.now() - stepStart,
|
|
2473
|
+
durationMs: Date.now() - h.stepStart,
|
|
1803
2474
|
selector,
|
|
1804
|
-
value: context.redactedFillSteps.has(
|
|
2475
|
+
value: h.context.redactedFillSteps.has(h.stepPath) ? "[REDACTED]" : value,
|
|
1805
2476
|
...healedFrom ? { healedFrom } : {}
|
|
1806
|
-
}
|
|
1807
|
-
}
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
2477
|
+
}
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
},
|
|
2481
|
+
type: {
|
|
2482
|
+
capabilities: ["interact"],
|
|
2483
|
+
run: async (h) => {
|
|
2484
|
+
if (!("type" in h.step)) unknownStep();
|
|
2485
|
+
h.policy.assertAllowedSelector(":focus");
|
|
2486
|
+
await h.driver.fill(":focus", h.step.type);
|
|
2487
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2488
|
+
return {
|
|
2489
|
+
kind: "result",
|
|
2490
|
+
result: {
|
|
1812
2491
|
type: "type",
|
|
1813
2492
|
status: "pass",
|
|
1814
|
-
durationMs: Date.now() - stepStart,
|
|
2493
|
+
durationMs: Date.now() - h.stepStart,
|
|
1815
2494
|
selector: ":focus",
|
|
1816
|
-
value: context.redactedFillSteps.has(
|
|
1817
|
-
}
|
|
1818
|
-
}
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
2495
|
+
value: h.context.redactedFillSteps.has(h.stepPath) ? "[REDACTED]" : h.step.type
|
|
2496
|
+
}
|
|
2497
|
+
};
|
|
2498
|
+
}
|
|
2499
|
+
},
|
|
2500
|
+
selectOption: {
|
|
2501
|
+
capabilities: ["navigate", "interact", "query"],
|
|
2502
|
+
run: async (h) => {
|
|
2503
|
+
if (!("selectOption" in h.step)) unknownStep();
|
|
2504
|
+
const resolved = await h.policy.resolveActionSelector(h.step.selectOption.selector);
|
|
2505
|
+
await h.driver.selectOption(resolved.selector, h.step.selectOption.value);
|
|
2506
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2507
|
+
return {
|
|
2508
|
+
kind: "result",
|
|
2509
|
+
result: {
|
|
1823
2510
|
type: "selectOption",
|
|
1824
2511
|
status: "pass",
|
|
1825
|
-
durationMs: Date.now() - stepStart,
|
|
2512
|
+
durationMs: Date.now() - h.stepStart,
|
|
1826
2513
|
selector: resolved.selector,
|
|
1827
|
-
value: step.selectOption.value,
|
|
2514
|
+
value: h.step.selectOption.value,
|
|
1828
2515
|
...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
|
|
1829
|
-
}
|
|
1830
|
-
}
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
2516
|
+
}
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
},
|
|
2520
|
+
select: {
|
|
2521
|
+
capabilities: ["navigate", "interact", "query"],
|
|
2522
|
+
run: async (h) => {
|
|
2523
|
+
if (!("select" in h.step)) unknownStep();
|
|
2524
|
+
const [label, value] = getSinglePair(h.step.select, "select");
|
|
2525
|
+
const selector = await selectByLabelOrFallback(h.driver, h.policy, label, value);
|
|
2526
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2527
|
+
return {
|
|
2528
|
+
kind: "result",
|
|
2529
|
+
result: {
|
|
1840
2530
|
type: "select",
|
|
1841
2531
|
status: "pass",
|
|
1842
|
-
durationMs: Date.now() - stepStart,
|
|
2532
|
+
durationMs: Date.now() - h.stepStart,
|
|
1843
2533
|
selector,
|
|
1844
2534
|
value
|
|
1845
|
-
}
|
|
1846
|
-
}
|
|
1847
|
-
|
|
1848
|
-
|
|
2535
|
+
}
|
|
2536
|
+
};
|
|
2537
|
+
}
|
|
2538
|
+
},
|
|
2539
|
+
onDialog: {
|
|
2540
|
+
capabilities: ["dialog"],
|
|
2541
|
+
run: async (h) => {
|
|
2542
|
+
if (!("onDialog" in h.step)) unknownStep();
|
|
2543
|
+
h.driver.onDialog(h.step.onDialog.action);
|
|
2544
|
+
return {
|
|
2545
|
+
kind: "result",
|
|
2546
|
+
result: {
|
|
1849
2547
|
type: "onDialog",
|
|
1850
2548
|
status: "pass",
|
|
1851
|
-
durationMs: Date.now() - stepStart,
|
|
1852
|
-
value: step.onDialog.action
|
|
1853
|
-
}
|
|
1854
|
-
}
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
2549
|
+
durationMs: Date.now() - h.stepStart,
|
|
2550
|
+
value: h.step.onDialog.action
|
|
2551
|
+
}
|
|
2552
|
+
};
|
|
2553
|
+
}
|
|
2554
|
+
},
|
|
2555
|
+
setInputFiles: {
|
|
2556
|
+
capabilities: ["navigate", "interact", "query", "files"],
|
|
2557
|
+
run: async (h) => {
|
|
2558
|
+
if (!("setInputFiles" in h.step)) unknownStep();
|
|
2559
|
+
const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
|
|
2560
|
+
const rawFiles = h.step.setInputFiles.files;
|
|
2561
|
+
const resolveFile = (f) => import_node_path6.default.isAbsolute(f) ? f : import_node_path6.default.join(h.context.configDir, f);
|
|
2562
|
+
const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
|
|
2563
|
+
await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
|
|
2564
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2565
|
+
const filesLabel = Array.isArray(rawFiles) ? rawFiles.join(", ") : rawFiles;
|
|
2566
|
+
return {
|
|
2567
|
+
kind: "result",
|
|
2568
|
+
result: {
|
|
1863
2569
|
type: "setInputFiles",
|
|
1864
2570
|
status: "pass",
|
|
1865
|
-
durationMs: Date.now() - stepStart,
|
|
2571
|
+
durationMs: Date.now() - h.stepStart,
|
|
1866
2572
|
selector: resolvedInput.selector,
|
|
1867
2573
|
...resolvedInput.healedFrom ? { healedFrom: resolvedInput.healedFrom } : {},
|
|
1868
2574
|
value: filesLabel
|
|
1869
|
-
};
|
|
1870
|
-
} else if ("runHunt" in step) {
|
|
1871
|
-
const huntName = typeof step.runHunt === "string" ? step.runHunt : step.runHunt.name;
|
|
1872
|
-
const overrideVars = typeof step.runHunt === "string" ? void 0 : step.runHunt.vars;
|
|
1873
|
-
const stack = context.huntStack ?? [];
|
|
1874
|
-
if (stack.includes(huntName)) {
|
|
1875
|
-
throw new Error(`Circular hunt dependency: ${[...stack, huntName].join(" \u2192 ")}`);
|
|
1876
|
-
}
|
|
1877
|
-
const subHunt = loadHunt(huntName, context.configDir);
|
|
1878
|
-
if (overrideVars) {
|
|
1879
|
-
subHunt.vars = { ...subHunt.vars, ...overrideVars };
|
|
1880
|
-
}
|
|
1881
|
-
const {
|
|
1882
|
-
hunt: interpolatedSubHunt,
|
|
1883
|
-
redactedFillSteps: subRedacted,
|
|
1884
|
-
randomVars
|
|
1885
|
-
} = interpolateHunt(
|
|
1886
|
-
subHunt,
|
|
1887
|
-
process.env,
|
|
1888
|
-
context.randomVars
|
|
1889
|
-
);
|
|
1890
|
-
assertWithinMaxSteps(interpolatedSubHunt.steps.length, context.maxSteps, huntName);
|
|
1891
|
-
const subResult = await executeNestedSteps(context, {
|
|
1892
|
-
steps: interpolatedSubHunt.steps,
|
|
1893
|
-
redactedFillSteps: subRedacted,
|
|
1894
|
-
randomVars,
|
|
1895
|
-
stepPathPrefix: void 0,
|
|
1896
|
-
huntStack: [...stack, huntName],
|
|
1897
|
-
onStep: context.onStep
|
|
1898
|
-
});
|
|
1899
|
-
for (const sr of subResult.results) {
|
|
1900
|
-
results.push({ ...sr, type: `${huntName} > ${sr.type}` });
|
|
1901
|
-
}
|
|
1902
|
-
screenshots.push(...subResult.screenshots);
|
|
1903
|
-
if (subResult.failed) {
|
|
1904
|
-
return {
|
|
1905
|
-
results,
|
|
1906
|
-
screenshots,
|
|
1907
|
-
failed: true,
|
|
1908
|
-
error: `Sub-hunt "${huntName}" failed: ${subResult.error}`
|
|
1909
|
-
};
|
|
1910
2575
|
}
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
},
|
|
2579
|
+
runHunt: {
|
|
2580
|
+
capabilities: [],
|
|
2581
|
+
run: async (h) => {
|
|
2582
|
+
if (!("runHunt" in h.step)) unknownStep();
|
|
2583
|
+
const huntName = typeof h.step.runHunt === "string" ? h.step.runHunt : h.step.runHunt.name;
|
|
2584
|
+
const overrideVars = typeof h.step.runHunt === "string" ? void 0 : h.step.runHunt.vars;
|
|
2585
|
+
const stack = h.context.huntStack ?? [];
|
|
2586
|
+
if (stack.includes(huntName)) {
|
|
2587
|
+
throw new Error(`Circular hunt dependency: ${[...stack, huntName].join(" \u2192 ")}`);
|
|
2588
|
+
}
|
|
2589
|
+
const subHunt = loadHunt(huntName, h.context.configDir);
|
|
2590
|
+
if (overrideVars) {
|
|
2591
|
+
subHunt.vars = { ...subHunt.vars, ...overrideVars };
|
|
2592
|
+
}
|
|
2593
|
+
const {
|
|
2594
|
+
hunt: interpolatedSubHunt,
|
|
2595
|
+
redactedFillSteps: subRedacted,
|
|
2596
|
+
randomVars
|
|
2597
|
+
} = interpolateHunt(subHunt, process.env, h.context.randomVars);
|
|
2598
|
+
const subTargetType = h.driver.capabilities.has("navigate") ? "web" : "macos";
|
|
2599
|
+
assertStepsSupportedByTarget(interpolatedSubHunt.steps, subTargetType);
|
|
2600
|
+
assertHuntAssertionsSupportedByTarget(interpolatedSubHunt.assertions, subTargetType);
|
|
2601
|
+
h.policy.assertWithinMaxSteps(interpolatedSubHunt.steps.length, huntName);
|
|
2602
|
+
const subResult = await h.executeNested({
|
|
2603
|
+
steps: interpolatedSubHunt.steps,
|
|
2604
|
+
redactedFillSteps: subRedacted,
|
|
2605
|
+
randomVars,
|
|
2606
|
+
stepPathPrefix: void 0,
|
|
2607
|
+
huntStack: [...stack, huntName],
|
|
2608
|
+
onStep: h.context.onStep
|
|
2609
|
+
});
|
|
2610
|
+
for (const sr of subResult.results) {
|
|
2611
|
+
h.results.push({ ...sr, type: `${huntName} > ${sr.type}` });
|
|
2612
|
+
}
|
|
2613
|
+
h.screenshots.push(...subResult.screenshots);
|
|
2614
|
+
if (subResult.failed) {
|
|
2615
|
+
return { kind: "abort", error: `Sub-hunt "${huntName}" failed: ${subResult.error}` };
|
|
2616
|
+
}
|
|
2617
|
+
return {
|
|
2618
|
+
kind: "result",
|
|
2619
|
+
result: { type: "runHunt", status: "pass", durationMs: Date.now() - h.stepStart, value: huntName }
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
},
|
|
2623
|
+
press: {
|
|
2624
|
+
capabilities: ["interact", "query"],
|
|
2625
|
+
run: async (h) => {
|
|
2626
|
+
if (!("press" in h.step)) unknownStep();
|
|
2627
|
+
const resolved = await h.policy.resolveActionSelector(h.step.press.selector);
|
|
2628
|
+
await h.driver.press(resolved.selector, h.step.press.key);
|
|
2629
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2630
|
+
return {
|
|
2631
|
+
kind: "result",
|
|
2632
|
+
result: {
|
|
1922
2633
|
type: "press",
|
|
1923
2634
|
status: "pass",
|
|
1924
|
-
durationMs: Date.now() - stepStart,
|
|
2635
|
+
durationMs: Date.now() - h.stepStart,
|
|
1925
2636
|
selector: resolved.selector,
|
|
1926
2637
|
...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
|
|
1927
|
-
}
|
|
1928
|
-
}
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
2638
|
+
}
|
|
2639
|
+
};
|
|
2640
|
+
}
|
|
2641
|
+
},
|
|
2642
|
+
assert: {
|
|
2643
|
+
capabilities: ["query"],
|
|
2644
|
+
run: async (h) => {
|
|
2645
|
+
if (!("assert" in h.step)) unknownStep();
|
|
2646
|
+
const value = await runInlineAssert(h.driver, h.policy, h.step.assert);
|
|
2647
|
+
return {
|
|
2648
|
+
kind: "result",
|
|
2649
|
+
result: { type: "assert", status: "pass", durationMs: Date.now() - h.stepStart, value }
|
|
2650
|
+
};
|
|
2651
|
+
}
|
|
2652
|
+
},
|
|
2653
|
+
wait: {
|
|
2654
|
+
capabilities: ["wait"],
|
|
2655
|
+
run: async (h) => {
|
|
2656
|
+
if (!("wait" in h.step)) unknownStep();
|
|
2657
|
+
const text = typeof h.step.wait === "string" ? h.step.wait : h.step.wait.for;
|
|
2658
|
+
const timeout = typeof h.step.wait === "string" ? void 0 : h.step.wait.timeout;
|
|
2659
|
+
const selector = `text=${escapeForText(text)}`;
|
|
2660
|
+
h.policy.assertAllowedSelector(selector);
|
|
2661
|
+
await h.driver.waitForSelector(selector, { timeout });
|
|
2662
|
+
return {
|
|
2663
|
+
kind: "result",
|
|
2664
|
+
result: { type: "wait", status: "pass", durationMs: Date.now() - h.stepStart, selector }
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
},
|
|
2668
|
+
waitForSelector: {
|
|
2669
|
+
capabilities: ["wait"],
|
|
2670
|
+
run: async (h) => {
|
|
2671
|
+
if (!("waitForSelector" in h.step)) unknownStep();
|
|
2672
|
+
h.policy.assertAllowedSelector(h.step.waitForSelector.selector);
|
|
2673
|
+
await h.driver.waitForSelector(h.step.waitForSelector.selector, {
|
|
2674
|
+
timeout: h.step.waitForSelector.timeout
|
|
2675
|
+
});
|
|
2676
|
+
return {
|
|
2677
|
+
kind: "result",
|
|
2678
|
+
result: {
|
|
1954
2679
|
type: "waitForSelector",
|
|
1955
2680
|
status: "pass",
|
|
1956
|
-
durationMs: Date.now() - stepStart,
|
|
1957
|
-
selector: step.waitForSelector.selector
|
|
1958
|
-
}
|
|
1959
|
-
}
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
2681
|
+
durationMs: Date.now() - h.stepStart,
|
|
2682
|
+
selector: h.step.waitForSelector.selector
|
|
2683
|
+
}
|
|
2684
|
+
};
|
|
2685
|
+
}
|
|
2686
|
+
},
|
|
2687
|
+
waitForUrl: {
|
|
2688
|
+
capabilities: ["navigate", "wait"],
|
|
2689
|
+
run: async (h) => {
|
|
2690
|
+
if (!("waitForUrl" in h.step)) unknownStep();
|
|
2691
|
+
const value = h.step.waitForUrl.value;
|
|
2692
|
+
await h.driver.waitForUrl((url) => url.includes(value), { timeout: h.step.waitForUrl.timeout });
|
|
2693
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2694
|
+
return {
|
|
2695
|
+
kind: "result",
|
|
2696
|
+
result: { type: "waitForUrl", status: "pass", durationMs: Date.now() - h.stepStart, value }
|
|
2697
|
+
};
|
|
2698
|
+
}
|
|
2699
|
+
},
|
|
2700
|
+
waitForNetworkIdle: {
|
|
2701
|
+
capabilities: ["wait"],
|
|
2702
|
+
run: async (h) => {
|
|
2703
|
+
if (!("waitForNetworkIdle" in h.step)) unknownStep();
|
|
2704
|
+
await h.driver.waitForNetworkIdle({ timeout: h.step.waitForNetworkIdle.timeout });
|
|
2705
|
+
return {
|
|
2706
|
+
kind: "result",
|
|
2707
|
+
result: { type: "waitForNetworkIdle", status: "pass", durationMs: Date.now() - h.stepStart }
|
|
2708
|
+
};
|
|
2709
|
+
}
|
|
2710
|
+
},
|
|
2711
|
+
hover: {
|
|
2712
|
+
capabilities: ["interact", "query"],
|
|
2713
|
+
run: async (h) => {
|
|
2714
|
+
if (!("hover" in h.step)) unknownStep();
|
|
2715
|
+
const resolved = await h.policy.resolveActionSelector(h.step.hover.selector);
|
|
2716
|
+
await h.driver.hover(resolved.selector);
|
|
2717
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2718
|
+
return {
|
|
2719
|
+
kind: "result",
|
|
2720
|
+
result: {
|
|
1985
2721
|
type: "hover",
|
|
1986
2722
|
status: "pass",
|
|
1987
|
-
durationMs: Date.now() - stepStart,
|
|
2723
|
+
durationMs: Date.now() - h.stepStart,
|
|
1988
2724
|
selector: resolved.selector,
|
|
1989
2725
|
...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
|
|
1990
|
-
}
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2726
|
+
}
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2729
|
+
},
|
|
2730
|
+
scroll: {
|
|
2731
|
+
capabilities: ["evaluate"],
|
|
2732
|
+
run: async (h) => {
|
|
2733
|
+
if (!("scroll" in h.step)) unknownStep();
|
|
2734
|
+
const amount = h.step.scroll.amount ?? 500;
|
|
2735
|
+
const scrollMap = {
|
|
2736
|
+
up: [0, -amount],
|
|
2737
|
+
down: [0, amount],
|
|
2738
|
+
left: [-amount, 0],
|
|
2739
|
+
right: [amount, 0]
|
|
2740
|
+
};
|
|
2741
|
+
const [x, y] = scrollMap[h.step.scroll.direction];
|
|
2742
|
+
await h.driver.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
|
|
2743
|
+
return {
|
|
2744
|
+
kind: "result",
|
|
2745
|
+
result: {
|
|
2002
2746
|
type: "scroll",
|
|
2003
2747
|
status: "pass",
|
|
2004
|
-
durationMs: Date.now() - stepStart,
|
|
2005
|
-
value: `${step.scroll.direction} ${amount}px`
|
|
2006
|
-
}
|
|
2007
|
-
}
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2748
|
+
durationMs: Date.now() - h.stepStart,
|
|
2749
|
+
value: `${h.step.scroll.direction} ${amount}px`
|
|
2750
|
+
}
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
},
|
|
2754
|
+
scrollTo: {
|
|
2755
|
+
capabilities: ["interact", "query"],
|
|
2756
|
+
run: async (h) => {
|
|
2757
|
+
if (!("scrollTo" in h.step)) unknownStep();
|
|
2758
|
+
const resolved = await h.policy.resolveActionSelector(h.step.scrollTo.selector);
|
|
2759
|
+
await h.driver.scrollIntoView(resolved.selector);
|
|
2760
|
+
return {
|
|
2761
|
+
kind: "result",
|
|
2762
|
+
result: {
|
|
2011
2763
|
type: "scrollTo",
|
|
2012
2764
|
status: "pass",
|
|
2013
|
-
durationMs: Date.now() - stepStart,
|
|
2765
|
+
durationMs: Date.now() - h.stepStart,
|
|
2014
2766
|
selector: resolved.selector,
|
|
2015
2767
|
...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
|
|
2016
|
-
};
|
|
2017
|
-
} else if ("screenshot" in step) {
|
|
2018
|
-
const name = step.screenshot.name ?? `manual_step_${index + 1}.png`;
|
|
2019
|
-
if (/[/\\]|\.\./.test(name)) {
|
|
2020
|
-
throw new Error(`Invalid screenshot name: "${name}" must not contain path separators or ".."`);
|
|
2021
2768
|
}
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2769
|
+
};
|
|
2770
|
+
}
|
|
2771
|
+
},
|
|
2772
|
+
screenshot: {
|
|
2773
|
+
capabilities: ["screenshot"],
|
|
2774
|
+
run: async (h) => {
|
|
2775
|
+
if (!("screenshot" in h.step)) unknownStep();
|
|
2776
|
+
const name = h.step.screenshot.name ?? `manual_step_${h.index + 1}.png`;
|
|
2777
|
+
if (/[/\\]|\.\./.test(name)) {
|
|
2778
|
+
throw new Error(`Invalid screenshot name: "${name}" must not contain path separators or ".."`);
|
|
2779
|
+
}
|
|
2780
|
+
const fileName = name.endsWith(".png") ? name : `${name}.png`;
|
|
2781
|
+
const relative = await h.addScreenshot(fileName);
|
|
2782
|
+
return {
|
|
2783
|
+
kind: "result",
|
|
2784
|
+
result: { type: "screenshot", status: "pass", durationMs: Date.now() - h.stepStart, screenshot: relative }
|
|
2785
|
+
};
|
|
2786
|
+
}
|
|
2787
|
+
},
|
|
2788
|
+
if: {
|
|
2789
|
+
capabilities: ["query"],
|
|
2790
|
+
run: async (h) => {
|
|
2791
|
+
if (!("if" in h.step)) unknownStep();
|
|
2792
|
+
const condition = h.step.if;
|
|
2793
|
+
const selector = condition.visible ?? condition.notVisible;
|
|
2794
|
+
h.policy.assertAllowedSelector(selector);
|
|
2795
|
+
const count = await h.driver.count(selector);
|
|
2796
|
+
const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
|
|
2797
|
+
if (conditionMet) {
|
|
2798
|
+
const subResult = await h.executeNested({
|
|
2799
|
+
steps: condition.then,
|
|
2800
|
+
stepPathPrefix: `${h.stepPath}.if.then`
|
|
2801
|
+
});
|
|
2802
|
+
for (const sr of subResult.results) {
|
|
2803
|
+
h.results.push({ ...sr, type: `if > ${sr.type}` });
|
|
2804
|
+
}
|
|
2805
|
+
h.screenshots.push(...subResult.screenshots);
|
|
2806
|
+
if (subResult.failed) {
|
|
2807
|
+
return { kind: "abort", error: subResult.error };
|
|
2808
|
+
}
|
|
2809
|
+
return {
|
|
2810
|
+
kind: "result",
|
|
2811
|
+
result: {
|
|
2054
2812
|
type: "if",
|
|
2055
2813
|
status: "pass",
|
|
2056
|
-
durationMs: Date.now() - stepStart,
|
|
2814
|
+
durationMs: Date.now() - h.stepStart,
|
|
2057
2815
|
value: `condition met, executed ${condition.then.length} steps`
|
|
2058
|
-
};
|
|
2059
|
-
} else {
|
|
2060
|
-
if (condition.else && condition.else.length > 0) {
|
|
2061
|
-
const subResult = await executeNestedSteps(context, {
|
|
2062
|
-
steps: condition.else,
|
|
2063
|
-
stepPathPrefix: `${currentStepPath}.if.else`
|
|
2064
|
-
});
|
|
2065
|
-
for (const sr of subResult.results) {
|
|
2066
|
-
results.push({ ...sr, type: `if > ${sr.type}` });
|
|
2067
|
-
}
|
|
2068
|
-
screenshots.push(...subResult.screenshots);
|
|
2069
|
-
if (subResult.failed) {
|
|
2070
|
-
return {
|
|
2071
|
-
results,
|
|
2072
|
-
screenshots,
|
|
2073
|
-
failed: true,
|
|
2074
|
-
error: subResult.error
|
|
2075
|
-
};
|
|
2076
|
-
}
|
|
2077
|
-
stepResult = {
|
|
2078
|
-
type: "if",
|
|
2079
|
-
status: "pass",
|
|
2080
|
-
durationMs: Date.now() - stepStart,
|
|
2081
|
-
value: `condition not met, executed ${condition.else.length} else steps`
|
|
2082
|
-
};
|
|
2083
|
-
} else {
|
|
2084
|
-
stepResult = {
|
|
2085
|
-
type: "if",
|
|
2086
|
-
status: "pass",
|
|
2087
|
-
durationMs: Date.now() - stepStart,
|
|
2088
|
-
value: "condition not met, skipped"
|
|
2089
|
-
};
|
|
2090
2816
|
}
|
|
2817
|
+
};
|
|
2818
|
+
}
|
|
2819
|
+
if (condition.else && condition.else.length > 0) {
|
|
2820
|
+
const subResult = await h.executeNested({
|
|
2821
|
+
steps: condition.else,
|
|
2822
|
+
stepPathPrefix: `${h.stepPath}.if.else`
|
|
2823
|
+
});
|
|
2824
|
+
for (const sr of subResult.results) {
|
|
2825
|
+
h.results.push({ ...sr, type: `if > ${sr.type}` });
|
|
2826
|
+
}
|
|
2827
|
+
h.screenshots.push(...subResult.screenshots);
|
|
2828
|
+
if (subResult.failed) {
|
|
2829
|
+
return { kind: "abort", error: subResult.error };
|
|
2091
2830
|
}
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2831
|
+
return {
|
|
2832
|
+
kind: "result",
|
|
2833
|
+
result: {
|
|
2834
|
+
type: "if",
|
|
2835
|
+
status: "pass",
|
|
2836
|
+
durationMs: Date.now() - h.stepStart,
|
|
2837
|
+
value: `condition not met, executed ${condition.else.length} else steps`
|
|
2099
2838
|
}
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2839
|
+
};
|
|
2840
|
+
}
|
|
2841
|
+
return {
|
|
2842
|
+
kind: "result",
|
|
2843
|
+
result: {
|
|
2844
|
+
type: "if",
|
|
2845
|
+
status: "pass",
|
|
2846
|
+
durationMs: Date.now() - h.stepStart,
|
|
2847
|
+
value: "condition not met, skipped"
|
|
2848
|
+
}
|
|
2849
|
+
};
|
|
2850
|
+
}
|
|
2851
|
+
},
|
|
2852
|
+
repeat: {
|
|
2853
|
+
capabilities: ["query"],
|
|
2854
|
+
run: async (h) => {
|
|
2855
|
+
if (!("repeat" in h.step)) unknownStep();
|
|
2856
|
+
const repeat = h.step.repeat;
|
|
2857
|
+
let totalSubSteps = 0;
|
|
2858
|
+
if (repeat.times !== void 0) {
|
|
2859
|
+
const totalPlanned = repeat.times * repeat.steps.length;
|
|
2860
|
+
if (totalPlanned + totalSubSteps > h.context.maxSteps) {
|
|
2861
|
+
throw new Error(`Repeat exceeded maxSteps guardrail (${h.context.maxSteps})`);
|
|
2862
|
+
}
|
|
2863
|
+
for (let i = 0; i < repeat.times; i++) {
|
|
2864
|
+
totalSubSteps += repeat.steps.length;
|
|
2865
|
+
const subResult = await h.executeNested({
|
|
2866
|
+
steps: repeat.steps,
|
|
2867
|
+
stepPathPrefix: `${h.stepPath}.repeat.steps`
|
|
2868
|
+
});
|
|
2869
|
+
for (const sr of subResult.results) {
|
|
2870
|
+
h.results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
|
|
2118
2871
|
}
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
assertAllowedSelector(whileSelector, context.forbiddenSelectors);
|
|
2123
|
-
for (let i = 0; i < maxIter; i++) {
|
|
2124
|
-
const whileCount = await context.page.locator(whileSelector).count();
|
|
2125
|
-
const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
|
|
2126
|
-
if (!shouldContinue) break;
|
|
2127
|
-
totalSubSteps += repeat.steps.length;
|
|
2128
|
-
if (totalSubSteps > context.maxSteps) {
|
|
2129
|
-
throw new Error(`Repeat exceeded maxSteps guardrail (${context.maxSteps})`);
|
|
2130
|
-
}
|
|
2131
|
-
const subResult = await executeNestedSteps(context, {
|
|
2132
|
-
steps: repeat.steps,
|
|
2133
|
-
stepPathPrefix: `${currentStepPath}.repeat.steps`
|
|
2134
|
-
});
|
|
2135
|
-
for (const sr of subResult.results) {
|
|
2136
|
-
results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
|
|
2137
|
-
}
|
|
2138
|
-
screenshots.push(...subResult.screenshots);
|
|
2139
|
-
if (subResult.failed) {
|
|
2140
|
-
return {
|
|
2141
|
-
results,
|
|
2142
|
-
screenshots,
|
|
2143
|
-
failed: true,
|
|
2144
|
-
error: subResult.error
|
|
2145
|
-
};
|
|
2146
|
-
}
|
|
2872
|
+
h.screenshots.push(...subResult.screenshots);
|
|
2873
|
+
if (subResult.failed) {
|
|
2874
|
+
return { kind: "abort", error: subResult.error };
|
|
2147
2875
|
}
|
|
2148
2876
|
}
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2877
|
+
} else if (repeat.while !== void 0) {
|
|
2878
|
+
const maxIter = repeat.maxIterations;
|
|
2879
|
+
const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
|
|
2880
|
+
h.policy.assertAllowedSelector(whileSelector);
|
|
2881
|
+
for (let i = 0; i < maxIter; i++) {
|
|
2882
|
+
const whileCount = await h.driver.count(whileSelector);
|
|
2883
|
+
const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
|
|
2884
|
+
if (!shouldContinue) break;
|
|
2885
|
+
totalSubSteps += repeat.steps.length;
|
|
2886
|
+
if (totalSubSteps > h.context.maxSteps) {
|
|
2887
|
+
throw new Error(`Repeat exceeded maxSteps guardrail (${h.context.maxSteps})`);
|
|
2888
|
+
}
|
|
2889
|
+
const subResult = await h.executeNested({
|
|
2890
|
+
steps: repeat.steps,
|
|
2891
|
+
stepPathPrefix: `${h.stepPath}.repeat.steps`
|
|
2892
|
+
});
|
|
2893
|
+
for (const sr of subResult.results) {
|
|
2894
|
+
h.results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
|
|
2165
2895
|
}
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
const relativePath = import_node_path4.default.relative(resolvedConfigDir, resolvedFilePath);
|
|
2170
|
-
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path4.default.sep}`) && !import_node_path4.default.isAbsolute(relativePath);
|
|
2171
|
-
if (!isWithinConfigDir) {
|
|
2172
|
-
throw new Error("mock.response.file must resolve within config directory");
|
|
2896
|
+
h.screenshots.push(...subResult.screenshots);
|
|
2897
|
+
if (subResult.failed) {
|
|
2898
|
+
return { kind: "abort", error: subResult.error };
|
|
2173
2899
|
}
|
|
2174
|
-
responseBody = await import_node_fs4.default.promises.readFile(resolvedFilePath, "utf-8");
|
|
2175
2900
|
}
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
if (!mocks || !mocks.has(url)) {
|
|
2198
|
-
throw new Error(`No active mock for URL: ${url}`);
|
|
2901
|
+
}
|
|
2902
|
+
return {
|
|
2903
|
+
kind: "result",
|
|
2904
|
+
result: { type: "repeat", status: "pass", durationMs: Date.now() - h.stepStart }
|
|
2905
|
+
};
|
|
2906
|
+
}
|
|
2907
|
+
},
|
|
2908
|
+
mockRoute: {
|
|
2909
|
+
capabilities: ["route"],
|
|
2910
|
+
run: async (h) => {
|
|
2911
|
+
if (!("mockRoute" in h.step)) unknownStep();
|
|
2912
|
+
const mock = h.step.mockRoute;
|
|
2913
|
+
const mocks = h.context.activeMocks ?? /* @__PURE__ */ new Map();
|
|
2914
|
+
h.context.activeMocks = mocks;
|
|
2915
|
+
let responseBody;
|
|
2916
|
+
if (mock.response.body !== void 0) {
|
|
2917
|
+
responseBody = mock.response.body;
|
|
2918
|
+
} else {
|
|
2919
|
+
const responseFile = mock.response.file;
|
|
2920
|
+
if (!responseFile) {
|
|
2921
|
+
throw new Error("mock.response must include either body or file");
|
|
2199
2922
|
}
|
|
2200
|
-
const
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
value: url
|
|
2208
|
-
};
|
|
2209
|
-
} else if ("evalScript" in step) {
|
|
2210
|
-
const expression = typeof step.evalScript === "string" ? step.evalScript : step.evalScript.expression;
|
|
2211
|
-
const result = await context.page.evaluate(expression);
|
|
2212
|
-
const resultStr = String(result);
|
|
2213
|
-
if (typeof step.evalScript !== "string" && step.evalScript.as) {
|
|
2214
|
-
runtimeVars.set(step.evalScript.as, resultStr);
|
|
2923
|
+
const candidateFilePath = import_node_path6.default.isAbsolute(responseFile) ? responseFile : import_node_path6.default.join(h.context.configDir, responseFile);
|
|
2924
|
+
const resolvedConfigDir = import_node_path6.default.resolve(h.context.configDir);
|
|
2925
|
+
const resolvedFilePath = import_node_path6.default.resolve(candidateFilePath);
|
|
2926
|
+
const relativePath = import_node_path6.default.relative(resolvedConfigDir, resolvedFilePath);
|
|
2927
|
+
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path6.default.sep}`) && !import_node_path6.default.isAbsolute(relativePath);
|
|
2928
|
+
if (!isWithinConfigDir) {
|
|
2929
|
+
throw new Error("mock.response.file must resolve within config directory");
|
|
2215
2930
|
}
|
|
2216
|
-
|
|
2931
|
+
responseBody = await import_node_fs6.default.promises.readFile(resolvedFilePath, "utf-8");
|
|
2932
|
+
}
|
|
2933
|
+
const contentType = mock.response.contentType ?? "application/json";
|
|
2934
|
+
const status = mock.response.status;
|
|
2935
|
+
await h.driver.route(mock.url, async (route) => {
|
|
2936
|
+
await route.fulfill({
|
|
2937
|
+
status,
|
|
2938
|
+
contentType,
|
|
2939
|
+
body: responseBody
|
|
2940
|
+
});
|
|
2941
|
+
});
|
|
2942
|
+
mocks.set(mock.url, async () => {
|
|
2943
|
+
await h.driver.unroute(mock.url);
|
|
2944
|
+
});
|
|
2945
|
+
return {
|
|
2946
|
+
kind: "result",
|
|
2947
|
+
result: { type: "mockRoute", status: "pass", durationMs: Date.now() - h.stepStart, value: mock.url }
|
|
2948
|
+
};
|
|
2949
|
+
}
|
|
2950
|
+
},
|
|
2951
|
+
unmockRoute: {
|
|
2952
|
+
capabilities: ["route"],
|
|
2953
|
+
run: async (h) => {
|
|
2954
|
+
if (!("unmockRoute" in h.step)) unknownStep();
|
|
2955
|
+
const url = typeof h.step.unmockRoute === "string" ? h.step.unmockRoute : h.step.unmockRoute.url;
|
|
2956
|
+
const mocks = h.context.activeMocks;
|
|
2957
|
+
if (!mocks || !mocks.has(url)) {
|
|
2958
|
+
throw new Error(`No active mock for URL: ${url}`);
|
|
2959
|
+
}
|
|
2960
|
+
const cleanup = mocks.get(url);
|
|
2961
|
+
await cleanup();
|
|
2962
|
+
mocks.delete(url);
|
|
2963
|
+
return {
|
|
2964
|
+
kind: "result",
|
|
2965
|
+
result: { type: "unmockRoute", status: "pass", durationMs: Date.now() - h.stepStart, value: url }
|
|
2966
|
+
};
|
|
2967
|
+
}
|
|
2968
|
+
},
|
|
2969
|
+
evalScript: {
|
|
2970
|
+
capabilities: ["evaluate"],
|
|
2971
|
+
run: async (h) => {
|
|
2972
|
+
if (!("evalScript" in h.step)) unknownStep();
|
|
2973
|
+
const expression = typeof h.step.evalScript === "string" ? h.step.evalScript : h.step.evalScript.expression;
|
|
2974
|
+
const result = await h.driver.evaluate(expression);
|
|
2975
|
+
const resultStr = String(result);
|
|
2976
|
+
if (typeof h.step.evalScript !== "string" && h.step.evalScript.as) {
|
|
2977
|
+
h.runtimeVars.set(h.step.evalScript.as, resultStr);
|
|
2978
|
+
}
|
|
2979
|
+
return {
|
|
2980
|
+
kind: "result",
|
|
2981
|
+
result: {
|
|
2217
2982
|
type: "evalScript",
|
|
2218
2983
|
status: "pass",
|
|
2219
|
-
durationMs: Date.now() - stepStart,
|
|
2984
|
+
durationMs: Date.now() - h.stepStart,
|
|
2220
2985
|
value: resultStr.length > 200 ? resultStr.slice(0, 200) + "\u2026" : resultStr
|
|
2221
|
-
}
|
|
2222
|
-
}
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2986
|
+
}
|
|
2987
|
+
};
|
|
2988
|
+
}
|
|
2989
|
+
},
|
|
2990
|
+
runScript: {
|
|
2991
|
+
capabilities: ["evaluate"],
|
|
2992
|
+
run: async (h) => {
|
|
2993
|
+
if (!("runScript" in h.step)) unknownStep();
|
|
2994
|
+
const filePath = import_node_path6.default.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : import_node_path6.default.join(h.context.configDir, h.step.runScript.file);
|
|
2995
|
+
const fileContents = import_node_fs6.default.readFileSync(filePath, "utf-8");
|
|
2996
|
+
await h.driver.evaluate(fileContents);
|
|
2997
|
+
return {
|
|
2998
|
+
kind: "result",
|
|
2999
|
+
result: {
|
|
2227
3000
|
type: "runScript",
|
|
2228
3001
|
status: "pass",
|
|
2229
|
-
durationMs: Date.now() - stepStart,
|
|
2230
|
-
value: step.runScript.file
|
|
3002
|
+
durationMs: Date.now() - h.stepStart,
|
|
3003
|
+
value: h.step.runScript.file
|
|
3004
|
+
}
|
|
3005
|
+
};
|
|
3006
|
+
}
|
|
3007
|
+
},
|
|
3008
|
+
assertScreenshot: {
|
|
3009
|
+
capabilities: ["screenshot"],
|
|
3010
|
+
run: async (h) => {
|
|
3011
|
+
if (!("assertScreenshot" in h.step)) unknownStep();
|
|
3012
|
+
const { compareScreenshots: compareScreenshots2, ensureBaselineDir: ensureBaselineDir2 } = await Promise.resolve().then(() => (init_visual(), visual_exports));
|
|
3013
|
+
const name = h.step.assertScreenshot.name;
|
|
3014
|
+
const threshold = h.step.assertScreenshot.threshold ?? 0.1;
|
|
3015
|
+
const baselineDir = ensureBaselineDir2(h.context.configDir);
|
|
3016
|
+
const baselinePath = import_node_path6.default.join(baselineDir, `${name}.png`);
|
|
3017
|
+
const currentScreenshotPath = import_node_path6.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
|
|
3018
|
+
import_node_fs6.default.mkdirSync(import_node_path6.default.dirname(currentScreenshotPath), { recursive: true });
|
|
3019
|
+
await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
|
|
3020
|
+
h.screenshots.push(import_node_path6.default.join("screenshots", `${name}-current.png`));
|
|
3021
|
+
if (!import_node_fs6.default.existsSync(baselinePath)) {
|
|
3022
|
+
import_node_fs6.default.copyFileSync(currentScreenshotPath, baselinePath);
|
|
3023
|
+
return {
|
|
3024
|
+
kind: "result",
|
|
3025
|
+
result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
|
|
2231
3026
|
};
|
|
2232
|
-
}
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
import_node_fs4.default.mkdirSync(import_node_path4.default.dirname(currentScreenshotPath), { recursive: true });
|
|
2240
|
-
await context.page.screenshot({ path: currentScreenshotPath, fullPage: true });
|
|
2241
|
-
screenshots.push(import_node_path4.default.join("screenshots", `${name}-current.png`));
|
|
2242
|
-
if (!import_node_fs4.default.existsSync(baselinePath)) {
|
|
2243
|
-
import_node_fs4.default.copyFileSync(currentScreenshotPath, baselinePath);
|
|
2244
|
-
stepResult = {
|
|
3027
|
+
}
|
|
3028
|
+
const diffPath = import_node_path6.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
|
|
3029
|
+
const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
|
|
3030
|
+
if (comparison.match) {
|
|
3031
|
+
return {
|
|
3032
|
+
kind: "result",
|
|
3033
|
+
result: {
|
|
2245
3034
|
type: "assertScreenshot",
|
|
2246
3035
|
status: "pass",
|
|
2247
|
-
durationMs: Date.now() - stepStart,
|
|
2248
|
-
value:
|
|
2249
|
-
};
|
|
2250
|
-
} else {
|
|
2251
|
-
const diffPath = import_node_path4.default.join(context.runDir, "screenshots", `${name}-diff.png`);
|
|
2252
|
-
const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
|
|
2253
|
-
if (comparison.match) {
|
|
2254
|
-
stepResult = {
|
|
2255
|
-
type: "assertScreenshot",
|
|
2256
|
-
status: "pass",
|
|
2257
|
-
durationMs: Date.now() - stepStart,
|
|
2258
|
-
value: `diff: ${(comparison.diffPercentage * 100).toFixed(2)}%`
|
|
2259
|
-
};
|
|
2260
|
-
} else {
|
|
2261
|
-
screenshots.push(import_node_path4.default.join("screenshots", `${name}-diff.png`));
|
|
2262
|
-
throw new Error(
|
|
2263
|
-
`Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
|
|
2264
|
-
);
|
|
3036
|
+
durationMs: Date.now() - h.stepStart,
|
|
3037
|
+
value: `diff: ${(comparison.diffPercentage * 100).toFixed(2)}%`
|
|
2265
3038
|
}
|
|
2266
|
-
}
|
|
2267
|
-
}
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
3039
|
+
};
|
|
3040
|
+
}
|
|
3041
|
+
h.screenshots.push(import_node_path6.default.join("screenshots", `${name}-diff.png`));
|
|
3042
|
+
throw new Error(
|
|
3043
|
+
`Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
|
|
3044
|
+
);
|
|
3045
|
+
}
|
|
3046
|
+
},
|
|
3047
|
+
copyText: {
|
|
3048
|
+
capabilities: ["query"],
|
|
3049
|
+
run: async (h) => {
|
|
3050
|
+
if (!("copyText" in h.step)) unknownStep();
|
|
3051
|
+
h.policy.assertAllowedSelector(h.step.copyText.selector);
|
|
3052
|
+
const text = await h.driver.textContent(h.step.copyText.selector);
|
|
3053
|
+
if (text === null) {
|
|
3054
|
+
throw new Error(`No text content found for selector: ${h.step.copyText.selector}`);
|
|
3055
|
+
}
|
|
3056
|
+
h.runtimeVars.set(h.step.copyText.as, text);
|
|
3057
|
+
return {
|
|
3058
|
+
kind: "result",
|
|
3059
|
+
result: {
|
|
2275
3060
|
type: "copyText",
|
|
2276
3061
|
status: "pass",
|
|
2277
|
-
durationMs: Date.now() - stepStart,
|
|
2278
|
-
selector: step.copyText.selector,
|
|
3062
|
+
durationMs: Date.now() - h.stepStart,
|
|
3063
|
+
selector: h.step.copyText.selector,
|
|
2279
3064
|
value: "[REDACTED]"
|
|
2280
|
-
};
|
|
2281
|
-
} else if ("waitForDownload" in step) {
|
|
2282
|
-
const opts = step.waitForDownload;
|
|
2283
|
-
const downloadPromise = context.pendingDownload ?? armDownloadListener(
|
|
2284
|
-
context.page,
|
|
2285
|
-
opts?.timeout ?? 3e4
|
|
2286
|
-
);
|
|
2287
|
-
context.pendingDownload = void 0;
|
|
2288
|
-
const download = await downloadPromise;
|
|
2289
|
-
const suggestedFilename = validateDownloadFilename(download.suggestedFilename());
|
|
2290
|
-
if (opts?.filename !== void 0 && suggestedFilename !== opts.filename) {
|
|
2291
|
-
throw new Error(
|
|
2292
|
-
`Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
|
|
2293
|
-
);
|
|
2294
3065
|
}
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
3066
|
+
};
|
|
3067
|
+
}
|
|
3068
|
+
},
|
|
3069
|
+
waitForDownload: {
|
|
3070
|
+
capabilities: ["download"],
|
|
3071
|
+
run: async (h) => {
|
|
3072
|
+
if (!("waitForDownload" in h.step)) unknownStep();
|
|
3073
|
+
const opts = h.step.waitForDownload;
|
|
3074
|
+
const downloadPromise = h.context.pendingDownload ?? armDownloadListener(h.driver, opts?.timeout ?? 3e4);
|
|
3075
|
+
h.context.pendingDownload = void 0;
|
|
3076
|
+
const download = await downloadPromise;
|
|
3077
|
+
const suggestedFilename = validateDownloadFilename(download.suggestedFilename());
|
|
3078
|
+
if (opts?.filename !== void 0 && suggestedFilename !== opts.filename) {
|
|
3079
|
+
throw new Error(
|
|
3080
|
+
`Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
|
|
3081
|
+
);
|
|
3082
|
+
}
|
|
3083
|
+
const savePath = import_node_path6.default.join(h.context.runDir, suggestedFilename);
|
|
3084
|
+
await download.saveAs(savePath);
|
|
3085
|
+
return {
|
|
3086
|
+
kind: "result",
|
|
3087
|
+
result: {
|
|
2298
3088
|
type: "waitForDownload",
|
|
2299
3089
|
status: "pass",
|
|
2300
|
-
durationMs: Date.now() - stepStart,
|
|
3090
|
+
durationMs: Date.now() - h.stepStart,
|
|
2301
3091
|
value: suggestedFilename
|
|
2302
|
-
}
|
|
2303
|
-
}
|
|
2304
|
-
|
|
3092
|
+
}
|
|
3093
|
+
};
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
};
|
|
3097
|
+
async function executeSteps(context) {
|
|
3098
|
+
let driver = context.driver;
|
|
3099
|
+
if (!driver) {
|
|
3100
|
+
if (!context.page) {
|
|
3101
|
+
throw new Error("executeSteps requires a driver or a Playwright page");
|
|
3102
|
+
}
|
|
3103
|
+
driver = createPlaywrightDriver(context.page);
|
|
3104
|
+
}
|
|
3105
|
+
const policy = createRunPolicy(driver, {
|
|
3106
|
+
forbiddenSelectors: context.forbiddenSelectors,
|
|
3107
|
+
allowedDomains: context.allowedDomains,
|
|
3108
|
+
allowedApps: context.allowedApps,
|
|
3109
|
+
maxSteps: context.maxSteps,
|
|
3110
|
+
selfHealing: context.selfHealing
|
|
3111
|
+
});
|
|
3112
|
+
const screenshotsDir = import_node_path6.default.join(context.runDir, "screenshots");
|
|
3113
|
+
import_node_fs6.default.mkdirSync(screenshotsDir, { recursive: true });
|
|
3114
|
+
const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
|
|
3115
|
+
policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
|
|
3116
|
+
const results = [];
|
|
3117
|
+
const screenshots = [];
|
|
3118
|
+
const runStartedAtMs = context.runStartedAtMs ?? Date.now();
|
|
3119
|
+
context.runStartedAtMs = runStartedAtMs;
|
|
3120
|
+
const addScreenshot = async (fileName) => {
|
|
3121
|
+
const fullPath = screenshotPath(screenshotsDir, fileName);
|
|
3122
|
+
await captureScreenshot(driver, fullPath);
|
|
3123
|
+
const relative = import_node_path6.default.join("screenshots", fileName);
|
|
3124
|
+
screenshots.push(relative);
|
|
3125
|
+
return relative;
|
|
3126
|
+
};
|
|
3127
|
+
const executeNested = (overrides) => executeNestedSteps(context, { driver, ...overrides });
|
|
3128
|
+
for (let index = 0; index < context.steps.length; index += 1) {
|
|
3129
|
+
const currentStepPath = stepPath(context.stepPathPrefix, index);
|
|
3130
|
+
if (Date.now() - runStartedAtMs > context.maxTotalTimeMs) {
|
|
3131
|
+
results.push({
|
|
3132
|
+
type: "timeout",
|
|
3133
|
+
status: "fail",
|
|
3134
|
+
durationMs: 0,
|
|
3135
|
+
error: `Max total time exceeded (${context.maxTotalTimeMs}ms)`
|
|
3136
|
+
});
|
|
3137
|
+
return { results, screenshots, failed: true, error: "Max total time exceeded" };
|
|
3138
|
+
}
|
|
3139
|
+
const runtimeVars = context.runtimeVars ?? /* @__PURE__ */ new Map();
|
|
3140
|
+
context.runtimeVars = runtimeVars;
|
|
3141
|
+
let step = context.steps[index];
|
|
3142
|
+
if (runtimeVars.size > 0) {
|
|
3143
|
+
step = applyRuntimeVars(step, runtimeVars);
|
|
3144
|
+
}
|
|
3145
|
+
const nextStep = context.steps[index + 1];
|
|
3146
|
+
if (!isWaitForDownloadStep(step) && context.pendingDownload === void 0 && isWaitForDownloadStep(nextStep)) {
|
|
3147
|
+
context.pendingDownload = armDownloadListener(
|
|
3148
|
+
driver,
|
|
3149
|
+
nextStep.waitForDownload?.timeout ?? 3e4
|
|
3150
|
+
);
|
|
3151
|
+
}
|
|
3152
|
+
const stepStart = Date.now();
|
|
3153
|
+
const stepType = getStepType(step);
|
|
3154
|
+
let stepResult = null;
|
|
3155
|
+
try {
|
|
3156
|
+
const handler = STEP_HANDLERS[stepType];
|
|
3157
|
+
if (!handler) {
|
|
2305
3158
|
throw new Error("Unknown step type");
|
|
2306
3159
|
}
|
|
3160
|
+
for (const capability of handler.capabilities) {
|
|
3161
|
+
if (!driver.capabilities.has(capability)) {
|
|
3162
|
+
throw new Error(
|
|
3163
|
+
`Driver does not support capability "${capability}" required by step "${stepType}"`
|
|
3164
|
+
);
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
const outcome = await handler.run({
|
|
3168
|
+
driver,
|
|
3169
|
+
policy,
|
|
3170
|
+
context,
|
|
3171
|
+
step,
|
|
3172
|
+
index,
|
|
3173
|
+
stepPath: currentStepPath,
|
|
3174
|
+
stepStart,
|
|
3175
|
+
runtimeVars,
|
|
3176
|
+
results,
|
|
3177
|
+
screenshots,
|
|
3178
|
+
addScreenshot,
|
|
3179
|
+
executeNested
|
|
3180
|
+
});
|
|
3181
|
+
if (outcome.kind === "abort") {
|
|
3182
|
+
return { results, screenshots, failed: true, error: outcome.error };
|
|
3183
|
+
}
|
|
3184
|
+
stepResult = outcome.result;
|
|
2307
3185
|
if (context.screenshotsMode === "all" && stepResult.type !== "screenshot") {
|
|
2308
3186
|
const fileName = `step_${index + 1}.png`;
|
|
2309
3187
|
await addScreenshot(fileName);
|
|
@@ -2330,12 +3208,12 @@ async function executeSteps(context) {
|
|
|
2330
3208
|
return { results, screenshots, failed: false };
|
|
2331
3209
|
}
|
|
2332
3210
|
async function captureFinalScreenshot(page, runDir) {
|
|
2333
|
-
const screenshotsDir =
|
|
2334
|
-
|
|
3211
|
+
const screenshotsDir = import_node_path6.default.join(runDir, "screenshots");
|
|
3212
|
+
import_node_fs6.default.mkdirSync(screenshotsDir, { recursive: true });
|
|
2335
3213
|
const fileName = "final.png";
|
|
2336
3214
|
const filePath = screenshotPath(screenshotsDir, fileName);
|
|
2337
3215
|
await captureScreenshot(page, filePath);
|
|
2338
|
-
return
|
|
3216
|
+
return import_node_path6.default.join("screenshots", fileName);
|
|
2339
3217
|
}
|
|
2340
3218
|
|
|
2341
3219
|
// src/runner/assertions.ts
|
|
@@ -2495,18 +3373,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
|
|
|
2495
3373
|
}
|
|
2496
3374
|
|
|
2497
3375
|
// src/reporter/result.ts
|
|
2498
|
-
var
|
|
2499
|
-
var
|
|
3376
|
+
var import_node_fs7 = __toESM(require("fs"), 1);
|
|
3377
|
+
var import_node_path7 = __toESM(require("path"), 1);
|
|
2500
3378
|
function writeResult(runDir, result) {
|
|
2501
3379
|
const fileName = "result.json";
|
|
2502
|
-
const fullPath =
|
|
2503
|
-
|
|
3380
|
+
const fullPath = import_node_path7.default.join(runDir, fileName);
|
|
3381
|
+
import_node_fs7.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
|
|
2504
3382
|
return fileName;
|
|
2505
3383
|
}
|
|
2506
3384
|
|
|
2507
3385
|
// src/reporter/summary.ts
|
|
2508
|
-
var
|
|
2509
|
-
var
|
|
3386
|
+
var import_node_fs8 = __toESM(require("fs"), 1);
|
|
3387
|
+
var import_node_path8 = __toESM(require("path"), 1);
|
|
2510
3388
|
function escapeMd(text) {
|
|
2511
3389
|
return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
|
|
2512
3390
|
}
|
|
@@ -2584,15 +3462,15 @@ function writeSummary(runDir, result) {
|
|
|
2584
3462
|
}
|
|
2585
3463
|
}
|
|
2586
3464
|
const fileName = "summary.md";
|
|
2587
|
-
const fullPath =
|
|
2588
|
-
|
|
3465
|
+
const fullPath = import_node_path8.default.join(runDir, fileName);
|
|
3466
|
+
import_node_fs8.default.writeFileSync(fullPath, `${lines.join("\n")}
|
|
2589
3467
|
`);
|
|
2590
3468
|
return fileName;
|
|
2591
3469
|
}
|
|
2592
3470
|
|
|
2593
3471
|
// src/reporter/junit.ts
|
|
2594
|
-
var
|
|
2595
|
-
var
|
|
3472
|
+
var import_node_fs9 = __toESM(require("fs"), 1);
|
|
3473
|
+
var import_node_path9 = __toESM(require("path"), 1);
|
|
2596
3474
|
function escapeXml(text) {
|
|
2597
3475
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2598
3476
|
}
|
|
@@ -2636,8 +3514,8 @@ function writeJunit(runDir, result) {
|
|
|
2636
3514
|
lines.push(" </testsuite>");
|
|
2637
3515
|
lines.push("</testsuites>");
|
|
2638
3516
|
const fileName = "junit.xml";
|
|
2639
|
-
const fullPath =
|
|
2640
|
-
|
|
3517
|
+
const fullPath = import_node_path9.default.join(runDir, fileName);
|
|
3518
|
+
import_node_fs9.default.writeFileSync(fullPath, `${lines.join("\n")}
|
|
2641
3519
|
`);
|
|
2642
3520
|
return fileName;
|
|
2643
3521
|
}
|
|
@@ -2671,15 +3549,15 @@ function timestamp(prefix) {
|
|
|
2671
3549
|
}
|
|
2672
3550
|
|
|
2673
3551
|
// src/runner/history.ts
|
|
2674
|
-
var
|
|
2675
|
-
var
|
|
3552
|
+
var import_node_fs10 = __toESM(require("fs"), 1);
|
|
3553
|
+
var import_node_path10 = __toESM(require("path"), 1);
|
|
2676
3554
|
var HISTORY_FILE = "history.json";
|
|
2677
3555
|
var LOCK_FILE_SUFFIX = ".lock";
|
|
2678
3556
|
var LOCK_RETRY_MS = 10;
|
|
2679
3557
|
var LOCK_TIMEOUT_MS = 5e3;
|
|
2680
3558
|
var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
2681
3559
|
function historyPath(configDir) {
|
|
2682
|
-
return
|
|
3560
|
+
return import_node_path10.default.join(configDir, HISTORY_FILE);
|
|
2683
3561
|
}
|
|
2684
3562
|
function isHistoryEntry(value) {
|
|
2685
3563
|
if (!value || typeof value !== "object") {
|
|
@@ -2690,11 +3568,11 @@ function isHistoryEntry(value) {
|
|
|
2690
3568
|
}
|
|
2691
3569
|
function readHistory(configDir) {
|
|
2692
3570
|
const filePath = historyPath(configDir);
|
|
2693
|
-
if (!
|
|
3571
|
+
if (!import_node_fs10.default.existsSync(filePath)) {
|
|
2694
3572
|
return { entries: [] };
|
|
2695
3573
|
}
|
|
2696
3574
|
try {
|
|
2697
|
-
const raw =
|
|
3575
|
+
const raw = import_node_fs10.default.readFileSync(filePath, "utf-8");
|
|
2698
3576
|
const parsed = JSON.parse(raw);
|
|
2699
3577
|
if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
|
|
2700
3578
|
const validatedEntries = parsed.entries.filter(isHistoryEntry);
|
|
@@ -2733,12 +3611,12 @@ function sleepSync(ms) {
|
|
|
2733
3611
|
function withHistoryLock(configDir, fn) {
|
|
2734
3612
|
const filePath = historyPath(configDir);
|
|
2735
3613
|
const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
|
|
2736
|
-
|
|
3614
|
+
import_node_fs10.default.mkdirSync(import_node_path10.default.dirname(filePath), { recursive: true });
|
|
2737
3615
|
const startedAt = Date.now();
|
|
2738
3616
|
while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
|
|
2739
3617
|
let fd;
|
|
2740
3618
|
try {
|
|
2741
|
-
fd =
|
|
3619
|
+
fd = import_node_fs10.default.openSync(lockPath, "wx");
|
|
2742
3620
|
} catch (error) {
|
|
2743
3621
|
if (error.code === "EEXIST") {
|
|
2744
3622
|
sleepSync(LOCK_RETRY_MS);
|
|
@@ -2750,10 +3628,10 @@ function withHistoryLock(configDir, fn) {
|
|
|
2750
3628
|
return fn();
|
|
2751
3629
|
} finally {
|
|
2752
3630
|
try {
|
|
2753
|
-
|
|
3631
|
+
import_node_fs10.default.closeSync(fd);
|
|
2754
3632
|
} catch {
|
|
2755
3633
|
}
|
|
2756
|
-
|
|
3634
|
+
import_node_fs10.default.rmSync(lockPath, { force: true });
|
|
2757
3635
|
}
|
|
2758
3636
|
}
|
|
2759
3637
|
throw new Error(
|
|
@@ -2766,9 +3644,9 @@ function appendEntry(configDir, entry, maxRuns) {
|
|
|
2766
3644
|
const current = readHistory(configDir);
|
|
2767
3645
|
const next = pruneEntries([...current.entries, entry], maxRuns);
|
|
2768
3646
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
2769
|
-
|
|
3647
|
+
import_node_fs10.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
|
|
2770
3648
|
`);
|
|
2771
|
-
|
|
3649
|
+
import_node_fs10.default.renameSync(tempPath, filePath);
|
|
2772
3650
|
});
|
|
2773
3651
|
}
|
|
2774
3652
|
|
|
@@ -2781,11 +3659,11 @@ function parseViewportFlag(value) {
|
|
|
2781
3659
|
return value;
|
|
2782
3660
|
}
|
|
2783
3661
|
function resolvePath(configDir, inputPath) {
|
|
2784
|
-
if (
|
|
3662
|
+
if (import_node_path11.default.isAbsolute(inputPath)) {
|
|
2785
3663
|
return inputPath;
|
|
2786
3664
|
}
|
|
2787
|
-
const projectRoot =
|
|
2788
|
-
return
|
|
3665
|
+
const projectRoot = import_node_path11.default.dirname(configDir);
|
|
3666
|
+
return import_node_path11.default.join(projectRoot, inputPath);
|
|
2789
3667
|
}
|
|
2790
3668
|
function buildRunResult(options) {
|
|
2791
3669
|
return {
|
|
@@ -2804,12 +3682,12 @@ function buildRunResult(options) {
|
|
|
2804
3682
|
}
|
|
2805
3683
|
function writeConsoleLog(runDir, entries) {
|
|
2806
3684
|
const fileName = "console.log";
|
|
2807
|
-
const filePath =
|
|
3685
|
+
const filePath = import_node_path11.default.join(runDir, fileName);
|
|
2808
3686
|
const lines = entries.map((entry) => {
|
|
2809
3687
|
const location = entry.location ? ` (${entry.location})` : "";
|
|
2810
3688
|
return `[${entry.type}] ${entry.text}${location}`;
|
|
2811
3689
|
});
|
|
2812
|
-
|
|
3690
|
+
import_node_fs11.default.writeFileSync(filePath, `${lines.join("\n")}
|
|
2813
3691
|
`);
|
|
2814
3692
|
return fileName;
|
|
2815
3693
|
}
|
|
@@ -2817,8 +3695,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
2817
3695
|
const headless = options.headed ? false : config.browser.headless;
|
|
2818
3696
|
const slowMo = options.slowMo ?? config.browser.slowMo;
|
|
2819
3697
|
const maxSteps = config.guardrails.maxSteps;
|
|
2820
|
-
const runDir =
|
|
2821
|
-
|
|
3698
|
+
const runDir = import_node_path11.default.join(configDir, "runs", timestamp());
|
|
3699
|
+
import_node_fs11.default.mkdirSync(runDir, { recursive: true });
|
|
2822
3700
|
const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
|
|
2823
3701
|
const engine = options.browser ?? config.browser.engine;
|
|
2824
3702
|
const channel = options.channel ?? config.browser.channel;
|
|
@@ -2837,6 +3715,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
2837
3715
|
});
|
|
2838
3716
|
let result;
|
|
2839
3717
|
try {
|
|
3718
|
+
const driver = createPlaywrightDriver(session.page);
|
|
2840
3719
|
const consoleEntries = [];
|
|
2841
3720
|
const networkEntries = [];
|
|
2842
3721
|
const traceCorrelations = [];
|
|
@@ -2848,7 +3727,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
2848
3727
|
location: message.location().url
|
|
2849
3728
|
});
|
|
2850
3729
|
});
|
|
2851
|
-
|
|
3730
|
+
driver.onResponse((response) => {
|
|
2852
3731
|
if (response.status() >= 400) {
|
|
2853
3732
|
networkEntries.push({ url: response.url(), status: response.status() });
|
|
2854
3733
|
captureTraceCorrelation(response, traceHeader, traceCorrelations, redactionValues);
|
|
@@ -2862,6 +3741,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
2862
3741
|
try {
|
|
2863
3742
|
const stepExecution = await executeSteps({
|
|
2864
3743
|
page: session.page,
|
|
3744
|
+
driver,
|
|
2865
3745
|
steps: interpolatedHunt.steps,
|
|
2866
3746
|
targetUrl,
|
|
2867
3747
|
runDir,
|
|
@@ -2894,7 +3774,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
2894
3774
|
}
|
|
2895
3775
|
let finalScreenshot;
|
|
2896
3776
|
try {
|
|
2897
|
-
finalScreenshot = await captureFinalScreenshot(
|
|
3777
|
+
finalScreenshot = await captureFinalScreenshot(driver, runDir);
|
|
2898
3778
|
} catch {
|
|
2899
3779
|
finalScreenshot = void 0;
|
|
2900
3780
|
}
|
|
@@ -2938,6 +3818,9 @@ function delay(ms) {
|
|
|
2938
3818
|
}
|
|
2939
3819
|
async function runHunt(options) {
|
|
2940
3820
|
const { config, configDir } = loadConfig(options.configPath);
|
|
3821
|
+
if (config.target.type === "macos") {
|
|
3822
|
+
return runMacHunt(options, config, configDir, config.target);
|
|
3823
|
+
}
|
|
2941
3824
|
const hunt = loadHunt(options.huntName, configDir);
|
|
2942
3825
|
const {
|
|
2943
3826
|
hunt: interpolatedHunt,
|
|
@@ -2988,9 +3871,124 @@ async function runHunt(options) {
|
|
|
2988
3871
|
}
|
|
2989
3872
|
return lastResult;
|
|
2990
3873
|
}
|
|
3874
|
+
async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
3875
|
+
const maxSteps = config.guardrails.maxSteps;
|
|
3876
|
+
const runDir = import_node_path11.default.join(configDir, "runs", timestamp());
|
|
3877
|
+
import_node_fs11.default.mkdirSync(runDir, { recursive: true });
|
|
3878
|
+
const session = await launchMacSession({
|
|
3879
|
+
app: target.app,
|
|
3880
|
+
timeoutMs: config.browser.timeout,
|
|
3881
|
+
clientFactory: options.macClientFactory
|
|
3882
|
+
});
|
|
3883
|
+
let result;
|
|
3884
|
+
try {
|
|
3885
|
+
const targetLabel = `macos:${session.bundleId}`;
|
|
3886
|
+
const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, target.app, session.bundleId])];
|
|
3887
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3888
|
+
const startTime = Date.now();
|
|
3889
|
+
let stepResults = [];
|
|
3890
|
+
let stepScreenshots = [];
|
|
3891
|
+
let stepFailed = false;
|
|
3892
|
+
try {
|
|
3893
|
+
const stepExecution = await executeSteps({
|
|
3894
|
+
driver: session.driver,
|
|
3895
|
+
steps: interpolatedHunt.steps,
|
|
3896
|
+
targetUrl: targetLabel,
|
|
3897
|
+
runDir,
|
|
3898
|
+
screenshotsMode: config.artifacts.screenshots,
|
|
3899
|
+
forbiddenSelectors: config.guardrails.forbiddenSelectors,
|
|
3900
|
+
allowedDomains: [],
|
|
3901
|
+
allowedApps: effectiveAllowedApps,
|
|
3902
|
+
maxSteps,
|
|
3903
|
+
maxTotalTimeMs: config.assertions.maxTotalTimeMs,
|
|
3904
|
+
selfHealing: config.guardrails.selfHealing,
|
|
3905
|
+
redactedFillSteps,
|
|
3906
|
+
randomVars,
|
|
3907
|
+
configDir,
|
|
3908
|
+
huntStack: [options.huntName],
|
|
3909
|
+
onStep: options.onStep
|
|
3910
|
+
});
|
|
3911
|
+
stepResults = stepExecution.results;
|
|
3912
|
+
stepScreenshots = stepExecution.screenshots;
|
|
3913
|
+
stepFailed = stepExecution.failed;
|
|
3914
|
+
} catch (error) {
|
|
3915
|
+
const message = error instanceof Error ? error.message : "Step execution failed";
|
|
3916
|
+
stepResults = [{ type: "steps", status: "fail", durationMs: 0, error: message }];
|
|
3917
|
+
stepFailed = true;
|
|
3918
|
+
}
|
|
3919
|
+
let finalScreenshot;
|
|
3920
|
+
try {
|
|
3921
|
+
finalScreenshot = await captureFinalScreenshot(session.driver, runDir);
|
|
3922
|
+
} catch {
|
|
3923
|
+
finalScreenshot = void 0;
|
|
3924
|
+
}
|
|
3925
|
+
const durationMs = Date.now() - startTime;
|
|
3926
|
+
const status = stepFailed ? "fail" : "pass";
|
|
3927
|
+
const artifacts = {
|
|
3928
|
+
screenshots: finalScreenshot ? [...stepScreenshots, finalScreenshot] : stepScreenshots
|
|
3929
|
+
};
|
|
3930
|
+
const runResult = buildRunResult({
|
|
3931
|
+
status,
|
|
3932
|
+
startedAt,
|
|
3933
|
+
durationMs,
|
|
3934
|
+
hunt: options.huntName,
|
|
3935
|
+
targetUrl: targetLabel,
|
|
3936
|
+
steps: stepResults,
|
|
3937
|
+
assertions: [],
|
|
3938
|
+
artifacts
|
|
3939
|
+
});
|
|
3940
|
+
result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
|
|
3941
|
+
} finally {
|
|
3942
|
+
await closeMacSession(session);
|
|
3943
|
+
}
|
|
3944
|
+
return { result, runDir, steps: interpolatedHunt.steps };
|
|
3945
|
+
}
|
|
3946
|
+
async function runMacHunt(options, config, configDir, target) {
|
|
3947
|
+
const hunt = loadHunt(options.huntName, configDir);
|
|
3948
|
+
const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
|
|
3949
|
+
assertStepsSupportedByTarget(interpolatedHunt.steps, "macos");
|
|
3950
|
+
assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, "macos");
|
|
3951
|
+
assertTargetAppAllowed(config.guardrails.allowedApps, target.app);
|
|
3952
|
+
const maxSteps = config.guardrails.maxSteps;
|
|
3953
|
+
if (interpolatedHunt.steps.length > maxSteps) {
|
|
3954
|
+
throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
|
|
3955
|
+
}
|
|
3956
|
+
const maxRetries = hunt.retry?.maxRetries ?? 0;
|
|
3957
|
+
const retryDelay = hunt.retry?.delay ?? 0;
|
|
3958
|
+
let lastResult;
|
|
3959
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3960
|
+
if (attempt > 0 && retryDelay > 0) {
|
|
3961
|
+
await delay(retryDelay);
|
|
3962
|
+
}
|
|
3963
|
+
lastResult = await executeMacHuntAttempt(
|
|
3964
|
+
options,
|
|
3965
|
+
config,
|
|
3966
|
+
configDir,
|
|
3967
|
+
target,
|
|
3968
|
+
interpolatedHunt,
|
|
3969
|
+
redactedFillSteps,
|
|
3970
|
+
randomVars,
|
|
3971
|
+
config.guardrails.allowedApps
|
|
3972
|
+
);
|
|
3973
|
+
if (lastResult.result.status === "pass") {
|
|
3974
|
+
if (attempt > 0) {
|
|
3975
|
+
lastResult.result.artifacts.summary = `Passed on attempt ${attempt + 1} of ${maxRetries + 1}`;
|
|
3976
|
+
}
|
|
3977
|
+
recordHistory(configDir, lastResult, config.history.maxRuns);
|
|
3978
|
+
return lastResult;
|
|
3979
|
+
}
|
|
3980
|
+
}
|
|
3981
|
+
if (maxRetries > 0 && lastResult) {
|
|
3982
|
+
lastResult.result.artifacts.summary = `Failed after ${maxRetries + 1} attempts`;
|
|
3983
|
+
}
|
|
3984
|
+
if (lastResult) {
|
|
3985
|
+
recordHistory(configDir, lastResult, config.history.maxRuns);
|
|
3986
|
+
}
|
|
3987
|
+
return lastResult;
|
|
3988
|
+
}
|
|
2991
3989
|
function recordHistory(configDir, outcome, maxRuns) {
|
|
2992
3990
|
try {
|
|
2993
|
-
const relativeRunDir =
|
|
3991
|
+
const relativeRunDir = import_node_path11.default.relative(configDir, outcome.runDir);
|
|
2994
3992
|
appendEntry(
|
|
2995
3993
|
configDir,
|
|
2996
3994
|
{
|
|
@@ -3215,37 +4213,37 @@ function buildRunCommand() {
|
|
|
3215
4213
|
}
|
|
3216
4214
|
|
|
3217
4215
|
// src/cli/commands/init.ts
|
|
3218
|
-
var
|
|
3219
|
-
var
|
|
3220
|
-
var
|
|
4216
|
+
var import_node_fs12 = __toESM(require("fs"), 1);
|
|
4217
|
+
var import_node_path12 = __toESM(require("path"), 1);
|
|
4218
|
+
var import_node_url2 = require("url");
|
|
3221
4219
|
var import_commander2 = require("commander");
|
|
3222
4220
|
var import_chalk4 = __toESM(require("chalk"), 1);
|
|
3223
4221
|
init_loader();
|
|
3224
|
-
var
|
|
3225
|
-
function
|
|
3226
|
-
const currentFile = (0,
|
|
3227
|
-
let dir =
|
|
3228
|
-
const root =
|
|
4222
|
+
var import_meta2 = {};
|
|
4223
|
+
function getPackageRoot2() {
|
|
4224
|
+
const currentFile = (0, import_node_url2.fileURLToPath)(import_meta2.url);
|
|
4225
|
+
let dir = import_node_path12.default.dirname(currentFile);
|
|
4226
|
+
const root = import_node_path12.default.parse(dir).root;
|
|
3229
4227
|
while (dir !== root) {
|
|
3230
|
-
if (
|
|
4228
|
+
if (import_node_fs12.default.existsSync(import_node_path12.default.join(dir, "package.json"))) {
|
|
3231
4229
|
return dir;
|
|
3232
4230
|
}
|
|
3233
|
-
dir =
|
|
4231
|
+
dir = import_node_path12.default.dirname(dir);
|
|
3234
4232
|
}
|
|
3235
|
-
if (
|
|
4233
|
+
if (import_node_fs12.default.existsSync(import_node_path12.default.join(root, "package.json"))) {
|
|
3236
4234
|
return root;
|
|
3237
4235
|
}
|
|
3238
4236
|
throw new Error("Cannot find package root. Reinstall prowl-tools.");
|
|
3239
4237
|
}
|
|
3240
4238
|
function copyFile(source, destination) {
|
|
3241
|
-
|
|
3242
|
-
|
|
4239
|
+
import_node_fs12.default.mkdirSync(import_node_path12.default.dirname(destination), { recursive: true });
|
|
4240
|
+
import_node_fs12.default.copyFileSync(source, destination);
|
|
3243
4241
|
}
|
|
3244
4242
|
function buildInitCommand() {
|
|
3245
4243
|
const command = new import_commander2.Command("init").option("--force", `Overwrite existing ${CONFIG_DIR} directory`).action((options) => {
|
|
3246
4244
|
const root = process.cwd();
|
|
3247
|
-
const prowlDir =
|
|
3248
|
-
if (
|
|
4245
|
+
const prowlDir = import_node_path12.default.join(root, CONFIG_DIR);
|
|
4246
|
+
if (import_node_fs12.default.existsSync(prowlDir) && !options.force) {
|
|
3249
4247
|
console.error(
|
|
3250
4248
|
import_chalk4.default.red(
|
|
3251
4249
|
`${CONFIG_DIR} already exists. Run with --force to reinitialize prowl configuration without deleting existing files.`
|
|
@@ -3254,21 +4252,21 @@ function buildInitCommand() {
|
|
|
3254
4252
|
process.exitCode = 1;
|
|
3255
4253
|
return;
|
|
3256
4254
|
}
|
|
3257
|
-
const packageRoot =
|
|
3258
|
-
const examplesDir =
|
|
3259
|
-
const exampleConfig =
|
|
3260
|
-
const exampleHuntsDir =
|
|
3261
|
-
if (!
|
|
4255
|
+
const packageRoot = getPackageRoot2();
|
|
4256
|
+
const examplesDir = import_node_path12.default.join(packageRoot, "examples");
|
|
4257
|
+
const exampleConfig = import_node_path12.default.join(examplesDir, "config.yml");
|
|
4258
|
+
const exampleHuntsDir = import_node_path12.default.join(examplesDir, "hunts");
|
|
4259
|
+
if (!import_node_fs12.default.existsSync(exampleConfig) || !import_node_fs12.default.existsSync(exampleHuntsDir)) {
|
|
3262
4260
|
console.error(import_chalk4.default.red("Examples not found in package. Reinstall prowl-tools."));
|
|
3263
4261
|
process.exitCode = 1;
|
|
3264
4262
|
return;
|
|
3265
4263
|
}
|
|
3266
|
-
copyFile(exampleConfig,
|
|
3267
|
-
const huntFiles =
|
|
4264
|
+
copyFile(exampleConfig, import_node_path12.default.join(prowlDir, "config.yml"));
|
|
4265
|
+
const huntFiles = import_node_fs12.default.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
|
|
3268
4266
|
for (const huntFile of huntFiles) {
|
|
3269
4267
|
copyFile(
|
|
3270
|
-
|
|
3271
|
-
|
|
4268
|
+
import_node_path12.default.join(exampleHuntsDir, huntFile),
|
|
4269
|
+
import_node_path12.default.join(prowlDir, "hunts", huntFile)
|
|
3272
4270
|
);
|
|
3273
4271
|
}
|
|
3274
4272
|
const gitignore = [
|
|
@@ -3282,7 +4280,7 @@ function buildInitCommand() {
|
|
|
3282
4280
|
".env",
|
|
3283
4281
|
""
|
|
3284
4282
|
].join("\n");
|
|
3285
|
-
|
|
4283
|
+
import_node_fs12.default.writeFileSync(import_node_path12.default.join(prowlDir, ".gitignore"), gitignore);
|
|
3286
4284
|
console.log(welcomeBanner());
|
|
3287
4285
|
console.log(import_chalk4.default.green(` Initialized ${CONFIG_DIR} directory.`));
|
|
3288
4286
|
console.log(import_chalk4.default.gray(" Run ") + import_chalk4.default.bold("prowl run hello") + import_chalk4.default.gray(" to get started."));
|
|
@@ -3292,18 +4290,17 @@ function buildInitCommand() {
|
|
|
3292
4290
|
}
|
|
3293
4291
|
|
|
3294
4292
|
// src/cli/commands/login.ts
|
|
3295
|
-
var
|
|
4293
|
+
var import_node_path13 = __toESM(require("path"), 1);
|
|
3296
4294
|
var import_node_readline = __toESM(require("readline"), 1);
|
|
3297
4295
|
var import_chalk5 = __toESM(require("chalk"), 1);
|
|
3298
4296
|
var import_commander3 = require("commander");
|
|
3299
|
-
var import_playwright2 = require("playwright");
|
|
3300
4297
|
init_loader();
|
|
3301
4298
|
function resolvePath2(configDir, inputPath) {
|
|
3302
|
-
if (
|
|
4299
|
+
if (import_node_path13.default.isAbsolute(inputPath)) {
|
|
3303
4300
|
return inputPath;
|
|
3304
4301
|
}
|
|
3305
|
-
const projectRoot =
|
|
3306
|
-
return
|
|
4302
|
+
const projectRoot = import_node_path13.default.dirname(configDir);
|
|
4303
|
+
return import_node_path13.default.join(projectRoot, inputPath);
|
|
3307
4304
|
}
|
|
3308
4305
|
function waitForEnter(prompt) {
|
|
3309
4306
|
return new Promise((resolve) => {
|
|
@@ -3316,30 +4313,35 @@ function waitForEnter(prompt) {
|
|
|
3316
4313
|
}
|
|
3317
4314
|
function buildLoginCommand() {
|
|
3318
4315
|
const command = new import_commander3.Command("login").option("--url <target>", "Override target URL").option("--config <path>", "Custom config path").action(async (options) => {
|
|
3319
|
-
let
|
|
3320
|
-
let context = null;
|
|
4316
|
+
let session = null;
|
|
3321
4317
|
try {
|
|
3322
4318
|
const { config, configDir } = loadConfig(options.config);
|
|
4319
|
+
if (config.target.type === "macos") {
|
|
4320
|
+
throw new Error("`prowl login` captures browser auth state and only applies to web targets.");
|
|
4321
|
+
}
|
|
3323
4322
|
const targetUrl = options.url ?? config.target.url;
|
|
3324
4323
|
const storageStatePath = config.auth.storageStatePath ? resolvePath2(configDir, config.auth.storageStatePath) : resolvePath2(configDir, ".prowl/auth-state.json");
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
4324
|
+
session = await launchBrowser({
|
|
4325
|
+
headless: false,
|
|
4326
|
+
slowMo: 0,
|
|
4327
|
+
timeout: config.browser.timeout,
|
|
4328
|
+
trace: false,
|
|
4329
|
+
recordHar: false,
|
|
4330
|
+
runDir: configDir
|
|
4331
|
+
});
|
|
4332
|
+
const driver = createPlaywrightDriver(session.page);
|
|
4333
|
+
await driver.goto(targetUrl);
|
|
3329
4334
|
console.log(import_chalk5.default.green("Browser opened. Log in manually."));
|
|
3330
4335
|
await waitForEnter("Press Enter to save auth state and close the browser... ");
|
|
3331
|
-
await
|
|
4336
|
+
await saveStorageState(session, storageStatePath);
|
|
3332
4337
|
console.log(import_chalk5.default.green(`Saved auth state to ${storageStatePath}`));
|
|
3333
4338
|
} catch (error) {
|
|
3334
4339
|
const message = error instanceof Error ? error.message : "Login failed";
|
|
3335
4340
|
console.error(import_chalk5.default.red(`Error: ${message}`));
|
|
3336
4341
|
process.exitCode = 1;
|
|
3337
4342
|
} finally {
|
|
3338
|
-
if (
|
|
3339
|
-
await
|
|
3340
|
-
}
|
|
3341
|
-
if (browser) {
|
|
3342
|
-
await browser.close();
|
|
4343
|
+
if (session) {
|
|
4344
|
+
await closeBrowser(session);
|
|
3343
4345
|
}
|
|
3344
4346
|
}
|
|
3345
4347
|
});
|
|
@@ -3385,18 +4387,18 @@ function buildListCommand() {
|
|
|
3385
4387
|
}
|
|
3386
4388
|
|
|
3387
4389
|
// src/cli/commands/watch.ts
|
|
3388
|
-
var
|
|
4390
|
+
var import_node_fs13 = __toESM(require("fs"), 1);
|
|
3389
4391
|
var import_chalk7 = __toESM(require("chalk"), 1);
|
|
3390
4392
|
var import_commander5 = require("commander");
|
|
3391
4393
|
init_loader();
|
|
3392
4394
|
|
|
3393
4395
|
// src/cli/watch-utils.ts
|
|
3394
|
-
var
|
|
4396
|
+
var import_node_path14 = __toESM(require("path"), 1);
|
|
3395
4397
|
function getWatchTargets(configDir, huntName) {
|
|
3396
4398
|
return [
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
4399
|
+
import_node_path14.default.join(configDir, "hunts", `${huntName}.yml`),
|
|
4400
|
+
import_node_path14.default.join(configDir, "config.yml"),
|
|
4401
|
+
import_node_path14.default.join(configDir, ".env")
|
|
3400
4402
|
];
|
|
3401
4403
|
}
|
|
3402
4404
|
function createDebouncer(delayMs, fn) {
|
|
@@ -3465,14 +4467,14 @@ function buildWatchCommand() {
|
|
|
3465
4467
|
});
|
|
3466
4468
|
const unwatch = [];
|
|
3467
4469
|
for (const target of watchTargets) {
|
|
3468
|
-
|
|
4470
|
+
import_node_fs13.default.watchFile(target, { interval: 150 }, (curr, prev) => {
|
|
3469
4471
|
if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) {
|
|
3470
4472
|
return;
|
|
3471
4473
|
}
|
|
3472
4474
|
console.log(import_chalk7.default.gray(`Change detected: ${target}`));
|
|
3473
4475
|
debounced.trigger();
|
|
3474
4476
|
});
|
|
3475
|
-
unwatch.push(() =>
|
|
4477
|
+
unwatch.push(() => import_node_fs13.default.unwatchFile(target));
|
|
3476
4478
|
}
|
|
3477
4479
|
const stop = () => {
|
|
3478
4480
|
if (stopped) {
|
|
@@ -3498,12 +4500,12 @@ var import_commander6 = require("commander");
|
|
|
3498
4500
|
var import_chalk9 = __toESM(require("chalk"), 1);
|
|
3499
4501
|
|
|
3500
4502
|
// src/runner/suite.ts
|
|
3501
|
-
var
|
|
4503
|
+
var import_node_path17 = __toESM(require("path"), 1);
|
|
3502
4504
|
init_loader();
|
|
3503
4505
|
|
|
3504
4506
|
// src/reporter/ci-summary.ts
|
|
3505
|
-
var
|
|
3506
|
-
var
|
|
4507
|
+
var import_node_fs14 = __toESM(require("fs"), 1);
|
|
4508
|
+
var import_node_path15 = __toESM(require("path"), 1);
|
|
3507
4509
|
var import_chalk8 = __toESM(require("chalk"), 1);
|
|
3508
4510
|
function countCiResults(results) {
|
|
3509
4511
|
return {
|
|
@@ -3567,9 +4569,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
|
|
|
3567
4569
|
...flaky.length > 0 ? { flaky } : {},
|
|
3568
4570
|
...clusters.length > 0 ? { clusters } : {}
|
|
3569
4571
|
};
|
|
3570
|
-
|
|
3571
|
-
const filePath =
|
|
3572
|
-
|
|
4572
|
+
import_node_fs14.default.mkdirSync(ciRunDir, { recursive: true });
|
|
4573
|
+
const filePath = import_node_path15.default.join(ciRunDir, "ci-result.json");
|
|
4574
|
+
import_node_fs14.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
|
|
3573
4575
|
return filePath;
|
|
3574
4576
|
}
|
|
3575
4577
|
|
|
@@ -3703,8 +4705,8 @@ function clusterFailures(failures) {
|
|
|
3703
4705
|
}
|
|
3704
4706
|
|
|
3705
4707
|
// src/backlog/index.ts
|
|
3706
|
-
var
|
|
3707
|
-
var
|
|
4708
|
+
var import_node_fs15 = __toESM(require("fs"), 1);
|
|
4709
|
+
var import_node_path16 = __toESM(require("path"), 1);
|
|
3708
4710
|
|
|
3709
4711
|
// src/backlog/parse.ts
|
|
3710
4712
|
var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
|
|
@@ -3794,7 +4796,7 @@ ${after}`;
|
|
|
3794
4796
|
// src/backlog/index.ts
|
|
3795
4797
|
function readFileOrEmpty(filePath) {
|
|
3796
4798
|
try {
|
|
3797
|
-
return
|
|
4799
|
+
return import_node_fs15.default.readFileSync(filePath, "utf-8");
|
|
3798
4800
|
} catch (error) {
|
|
3799
4801
|
const err = error;
|
|
3800
4802
|
if (err.code === "ENOENT") return "";
|
|
@@ -3810,7 +4812,7 @@ function buildFailure(hunt) {
|
|
|
3810
4812
|
if (!hunt.runDir) return failure;
|
|
3811
4813
|
let run;
|
|
3812
4814
|
try {
|
|
3813
|
-
const resultJson = readFileOrEmpty(
|
|
4815
|
+
const resultJson = readFileOrEmpty(import_node_path16.default.join(hunt.runDir, "result.json"));
|
|
3814
4816
|
if (!resultJson) return failure;
|
|
3815
4817
|
run = JSON.parse(resultJson);
|
|
3816
4818
|
} catch (error) {
|
|
@@ -3839,8 +4841,8 @@ function extractFailures(suiteResult) {
|
|
|
3839
4841
|
}
|
|
3840
4842
|
function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
3841
4843
|
const projectRoot = options.projectRoot ?? process.cwd();
|
|
3842
|
-
const backlogPath = options.backlogPath ??
|
|
3843
|
-
const resolvedPath = options.resolvedPath ??
|
|
4844
|
+
const backlogPath = options.backlogPath ?? import_node_path16.default.join(projectRoot, "docs", "backlog.md");
|
|
4845
|
+
const resolvedPath = options.resolvedPath ?? import_node_path16.default.join(projectRoot, "docs", "resolved.md");
|
|
3844
4846
|
const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3845
4847
|
const summary = { created: [], regressions: [], skipped: [], backlogPath };
|
|
3846
4848
|
const failures = extractFailures(suiteResult);
|
|
@@ -3872,8 +4874,8 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
|
3872
4874
|
}
|
|
3873
4875
|
}
|
|
3874
4876
|
if (ticketsToAdd.length > 0) {
|
|
3875
|
-
|
|
3876
|
-
|
|
4877
|
+
import_node_fs15.default.mkdirSync(import_node_path16.default.dirname(backlogPath), { recursive: true });
|
|
4878
|
+
import_node_fs15.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
|
|
3877
4879
|
}
|
|
3878
4880
|
return summary;
|
|
3879
4881
|
}
|
|
@@ -4034,7 +5036,7 @@ async function runSuite(options = {}) {
|
|
|
4034
5036
|
const clusters = clusterFailures(
|
|
4035
5037
|
extractFailures({ result: { hunts: results }, resultPath: null })
|
|
4036
5038
|
).filter((cluster) => cluster.count > 1);
|
|
4037
|
-
const ciRunDir =
|
|
5039
|
+
const ciRunDir = import_node_path17.default.join(configDir, "runs", timestamp("ci"));
|
|
4038
5040
|
const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
|
|
4039
5041
|
const { passed, failed, skipped } = countCiResults(results);
|
|
4040
5042
|
return {
|
|
@@ -4152,8 +5154,8 @@ function buildCiCommand() {
|
|
|
4152
5154
|
}
|
|
4153
5155
|
|
|
4154
5156
|
// src/cli/commands/update-baselines.ts
|
|
4155
|
-
var
|
|
4156
|
-
var
|
|
5157
|
+
var import_node_fs16 = __toESM(require("fs"), 1);
|
|
5158
|
+
var import_node_path18 = __toESM(require("path"), 1);
|
|
4157
5159
|
var import_commander7 = require("commander");
|
|
4158
5160
|
var import_chalk10 = __toESM(require("chalk"), 1);
|
|
4159
5161
|
init_loader();
|
|
@@ -4161,33 +5163,33 @@ function buildUpdateBaselinesCommand() {
|
|
|
4161
5163
|
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) => {
|
|
4162
5164
|
try {
|
|
4163
5165
|
const { configDir } = loadConfig(options.config);
|
|
4164
|
-
const baselinesDir =
|
|
4165
|
-
|
|
5166
|
+
const baselinesDir = import_node_path18.default.join(configDir, "baselines");
|
|
5167
|
+
import_node_fs16.default.mkdirSync(baselinesDir, { recursive: true });
|
|
4166
5168
|
let runDir;
|
|
4167
5169
|
if (options.run) {
|
|
4168
|
-
runDir =
|
|
5170
|
+
runDir = import_node_path18.default.isAbsolute(options.run) ? options.run : import_node_path18.default.resolve(options.run);
|
|
4169
5171
|
} else {
|
|
4170
|
-
const runsDir =
|
|
4171
|
-
if (!
|
|
5172
|
+
const runsDir = import_node_path18.default.join(configDir, "runs");
|
|
5173
|
+
if (!import_node_fs16.default.existsSync(runsDir)) {
|
|
4172
5174
|
console.error(import_chalk10.default.red(" No runs directory found. Run a hunt first."));
|
|
4173
5175
|
process.exitCode = 1;
|
|
4174
5176
|
return;
|
|
4175
5177
|
}
|
|
4176
|
-
const entries =
|
|
5178
|
+
const entries = import_node_fs16.default.readdirSync(runsDir).filter((e) => import_node_fs16.default.statSync(import_node_path18.default.join(runsDir, e)).isDirectory()).sort().reverse();
|
|
4177
5179
|
if (entries.length === 0) {
|
|
4178
5180
|
console.error(import_chalk10.default.red(" No run directories found. Run a hunt first."));
|
|
4179
5181
|
process.exitCode = 1;
|
|
4180
5182
|
return;
|
|
4181
5183
|
}
|
|
4182
|
-
runDir =
|
|
5184
|
+
runDir = import_node_path18.default.join(runsDir, entries[0]);
|
|
4183
5185
|
}
|
|
4184
|
-
const screenshotsDir =
|
|
4185
|
-
if (!
|
|
5186
|
+
const screenshotsDir = import_node_path18.default.join(runDir, "screenshots");
|
|
5187
|
+
if (!import_node_fs16.default.existsSync(screenshotsDir)) {
|
|
4186
5188
|
console.error(import_chalk10.default.red(` No screenshots found in ${runDir}`));
|
|
4187
5189
|
process.exitCode = 1;
|
|
4188
5190
|
return;
|
|
4189
5191
|
}
|
|
4190
|
-
const screenshots =
|
|
5192
|
+
const screenshots = import_node_fs16.default.readdirSync(screenshotsDir).filter((f) => f.endsWith("-current.png"));
|
|
4191
5193
|
if (screenshots.length === 0) {
|
|
4192
5194
|
console.log(import_chalk10.default.yellow(" No assertScreenshot results found in this run."));
|
|
4193
5195
|
return;
|
|
@@ -4201,10 +5203,10 @@ function buildUpdateBaselinesCommand() {
|
|
|
4201
5203
|
let updated = 0;
|
|
4202
5204
|
for (const file of filtered) {
|
|
4203
5205
|
const baselineName = file.replace("-current.png", ".png");
|
|
4204
|
-
const sourcePath =
|
|
4205
|
-
const destPath =
|
|
4206
|
-
const exists =
|
|
4207
|
-
|
|
5206
|
+
const sourcePath = import_node_path18.default.join(screenshotsDir, file);
|
|
5207
|
+
const destPath = import_node_path18.default.join(baselinesDir, baselineName);
|
|
5208
|
+
const exists = import_node_fs16.default.existsSync(destPath);
|
|
5209
|
+
import_node_fs16.default.copyFileSync(sourcePath, destPath);
|
|
4208
5210
|
updated++;
|
|
4209
5211
|
const status = exists ? import_chalk10.default.yellow("updated") : import_chalk10.default.green("created");
|
|
4210
5212
|
console.log(` ${status} ${baselineName}`);
|
|
@@ -4226,7 +5228,21 @@ function buildUpdateBaselinesCommand() {
|
|
|
4226
5228
|
// src/cli/commands/analyze.ts
|
|
4227
5229
|
var import_commander8 = require("commander");
|
|
4228
5230
|
var import_chalk11 = __toESM(require("chalk"), 1);
|
|
4229
|
-
|
|
5231
|
+
|
|
5232
|
+
// src/browser/engines.ts
|
|
5233
|
+
init_types();
|
|
5234
|
+
function formatSupportedBrowserEngines() {
|
|
5235
|
+
return SUPPORTED_BROWSER_ENGINES.join(", ");
|
|
5236
|
+
}
|
|
5237
|
+
function parseBrowserEngine(value, fallback = "chromium") {
|
|
5238
|
+
if (value === void 0 || value.length === 0) {
|
|
5239
|
+
return fallback;
|
|
5240
|
+
}
|
|
5241
|
+
if (SUPPORTED_BROWSER_ENGINES.includes(value)) {
|
|
5242
|
+
return value;
|
|
5243
|
+
}
|
|
5244
|
+
throw new Error(`Unsupported browser engine "${value}". Use ${formatSupportedBrowserEngines()}.`);
|
|
5245
|
+
}
|
|
4230
5246
|
|
|
4231
5247
|
// src/analyzer/index.ts
|
|
4232
5248
|
async function analyzePage(page) {
|
|
@@ -4345,7 +5361,6 @@ async function analyzePage(page) {
|
|
|
4345
5361
|
|
|
4346
5362
|
// src/cli/commands/analyze.ts
|
|
4347
5363
|
init_loader();
|
|
4348
|
-
var ENGINES2 = { chromium: import_playwright3.chromium, firefox: import_playwright3.firefox, webkit: import_playwright3.webkit };
|
|
4349
5364
|
function parseViewportFlag2(value) {
|
|
4350
5365
|
const match = /^(\d+)x(\d+)$/i.exec(value);
|
|
4351
5366
|
if (match) {
|
|
@@ -4356,19 +5371,24 @@ function parseViewportFlag2(value) {
|
|
|
4356
5371
|
function buildAnalyzeCommand() {
|
|
4357
5372
|
const command = new import_commander8.Command("analyze").argument("<url>", "URL to analyze").description("Analyze a page to discover interactive elements and selectors").option("--json", "Output as JSON").option("--browser <engine>", "Browser engine: chromium, firefox, or webkit").option("--channel <name>", "Browser channel: chrome, msedge, etc.").option("--viewport <size>", "Viewport size: WxH or preset (mobile, tablet, desktop)").option("--headed", "Show browser window").option("--config <path>", "Custom config path").action(async (url, options) => {
|
|
4358
5373
|
try {
|
|
4359
|
-
const engine = options.browser
|
|
5374
|
+
const engine = parseBrowserEngine(options.browser);
|
|
4360
5375
|
const channel = options.channel;
|
|
4361
5376
|
const viewport = options.viewport ? resolveViewport(parseViewportFlag2(options.viewport)) : { width: 1280, height: 720 };
|
|
4362
|
-
const
|
|
4363
|
-
const browser = await browserEngine.launch({
|
|
5377
|
+
const session = await launchBrowser({
|
|
4364
5378
|
headless: !options.headed,
|
|
4365
|
-
|
|
5379
|
+
slowMo: 0,
|
|
5380
|
+
timeout: 3e4,
|
|
5381
|
+
trace: false,
|
|
5382
|
+
recordHar: false,
|
|
5383
|
+
runDir: process.cwd(),
|
|
5384
|
+
engine,
|
|
5385
|
+
channel,
|
|
5386
|
+
viewport
|
|
4366
5387
|
});
|
|
4367
|
-
const
|
|
4368
|
-
const page = await context.newPage();
|
|
5388
|
+
const driver = createPlaywrightDriver(session.page);
|
|
4369
5389
|
try {
|
|
4370
|
-
await
|
|
4371
|
-
const result = await analyzePage(
|
|
5390
|
+
await driver.goto(url, { waitUntil: "networkidle" });
|
|
5391
|
+
const result = await analyzePage(driver);
|
|
4372
5392
|
if (options.json) {
|
|
4373
5393
|
console.log(JSON.stringify(result, null, 2));
|
|
4374
5394
|
} else {
|
|
@@ -4413,8 +5433,7 @@ function buildAnalyzeCommand() {
|
|
|
4413
5433
|
`));
|
|
4414
5434
|
}
|
|
4415
5435
|
} finally {
|
|
4416
|
-
await
|
|
4417
|
-
await browser.close();
|
|
5436
|
+
await closeBrowser(session);
|
|
4418
5437
|
}
|
|
4419
5438
|
} catch (error) {
|
|
4420
5439
|
const message = error instanceof Error ? error.message : "Analysis failed";
|
|
@@ -4432,15 +5451,14 @@ function buildAnalyzeCommand() {
|
|
|
4432
5451
|
}
|
|
4433
5452
|
|
|
4434
5453
|
// src/cli/commands/generate.ts
|
|
4435
|
-
var
|
|
4436
|
-
var
|
|
5454
|
+
var import_node_fs17 = __toESM(require("fs"), 1);
|
|
5455
|
+
var import_node_path19 = __toESM(require("path"), 1);
|
|
4437
5456
|
var import_commander9 = require("commander");
|
|
4438
5457
|
var import_chalk12 = __toESM(require("chalk"), 1);
|
|
4439
5458
|
var import_ora = __toESM(require("ora"), 1);
|
|
4440
5459
|
|
|
4441
5460
|
// src/generator/index.ts
|
|
4442
5461
|
var import_yaml2 = __toESM(require("yaml"), 1);
|
|
4443
|
-
var import_playwright4 = require("playwright");
|
|
4444
5462
|
|
|
4445
5463
|
// src/generator/prompt.ts
|
|
4446
5464
|
var STEP_REFERENCE = `
|
|
@@ -4602,19 +5620,36 @@ async function generateWithOpenAi(prompt, config) {
|
|
|
4602
5620
|
}
|
|
4603
5621
|
|
|
4604
5622
|
// src/generator/index.ts
|
|
5623
|
+
init_loader();
|
|
4605
5624
|
init_schema();
|
|
5625
|
+
function parseViewportFlag3(value) {
|
|
5626
|
+
const match = /^(\d+)x(\d+)$/i.exec(value);
|
|
5627
|
+
if (match) {
|
|
5628
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
5629
|
+
}
|
|
5630
|
+
return value;
|
|
5631
|
+
}
|
|
4606
5632
|
async function generateHunt(options) {
|
|
4607
5633
|
let analysis = options.analysis;
|
|
4608
5634
|
if (!analysis && options.url) {
|
|
4609
|
-
const
|
|
4610
|
-
const
|
|
4611
|
-
const
|
|
5635
|
+
const engine = parseBrowserEngine(options.browser);
|
|
5636
|
+
const viewport = options.viewport ? resolveViewport(parseViewportFlag3(options.viewport)) : resolveViewport(void 0);
|
|
5637
|
+
const session = await launchBrowser({
|
|
5638
|
+
headless: true,
|
|
5639
|
+
slowMo: 0,
|
|
5640
|
+
timeout: 3e4,
|
|
5641
|
+
trace: false,
|
|
5642
|
+
recordHar: false,
|
|
5643
|
+
runDir: process.cwd(),
|
|
5644
|
+
engine,
|
|
5645
|
+
viewport
|
|
5646
|
+
});
|
|
5647
|
+
const driver = createPlaywrightDriver(session.page);
|
|
4612
5648
|
try {
|
|
4613
|
-
await
|
|
4614
|
-
analysis = await analyzePage(
|
|
5649
|
+
await driver.goto(options.url, { waitUntil: "networkidle" });
|
|
5650
|
+
analysis = await analyzePage(driver);
|
|
4615
5651
|
} finally {
|
|
4616
|
-
await
|
|
4617
|
-
await browser.close();
|
|
5652
|
+
await closeBrowser(session);
|
|
4618
5653
|
}
|
|
4619
5654
|
}
|
|
4620
5655
|
if (!analysis) {
|
|
@@ -4686,13 +5721,13 @@ function buildGenerateCommand() {
|
|
|
4686
5721
|
const result = loadConfig2(options.config);
|
|
4687
5722
|
configDir = result.configDir;
|
|
4688
5723
|
} catch {
|
|
4689
|
-
configDir =
|
|
5724
|
+
configDir = import_node_path19.default.join(process.cwd(), ".prowl");
|
|
4690
5725
|
}
|
|
4691
|
-
const huntsDir =
|
|
4692
|
-
|
|
5726
|
+
const huntsDir = import_node_path19.default.join(configDir, "hunts");
|
|
5727
|
+
import_node_fs17.default.mkdirSync(huntsDir, { recursive: true });
|
|
4693
5728
|
const fileName = options.output.endsWith(".yml") ? options.output : `${options.output}.yml`;
|
|
4694
|
-
const filePath =
|
|
4695
|
-
|
|
5729
|
+
const filePath = import_node_path19.default.join(huntsDir, fileName);
|
|
5730
|
+
import_node_fs17.default.writeFileSync(filePath, yamlStr + "\n", "utf-8");
|
|
4696
5731
|
console.log(import_chalk12.default.green(` Saved to ${filePath}`));
|
|
4697
5732
|
} else {
|
|
4698
5733
|
console.log(yamlStr);
|
|
@@ -4889,7 +5924,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
|
4889
5924
|
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
4890
5925
|
|
|
4891
5926
|
// src/mcp/tools.ts
|
|
4892
|
-
var
|
|
5927
|
+
var import_node_path20 = __toESM(require("path"), 1);
|
|
4893
5928
|
init_loader();
|
|
4894
5929
|
function listHuntsTool(configPath) {
|
|
4895
5930
|
const { configDir } = loadConfig(configPath);
|
|
@@ -4897,7 +5932,7 @@ function listHuntsTool(configPath) {
|
|
|
4897
5932
|
}
|
|
4898
5933
|
async function runSuiteTool(args = {}, options = {}) {
|
|
4899
5934
|
const { configPath: resolvedConfigPath, configDir, config } = loadConfig(options.configPath);
|
|
4900
|
-
const projectRoot = options.projectRoot ??
|
|
5935
|
+
const projectRoot = options.projectRoot ?? import_node_path20.default.dirname(configDir);
|
|
4901
5936
|
const suite = await runSuite({
|
|
4902
5937
|
configPath: resolvedConfigPath,
|
|
4903
5938
|
includeTags: args.includeTags,
|
|
@@ -4906,8 +5941,8 @@ async function runSuiteTool(args = {}, options = {}) {
|
|
|
4906
5941
|
});
|
|
4907
5942
|
const bugLogCfg = config.bugLog ?? {};
|
|
4908
5943
|
const logBugs = args.logBugs ?? bugLogCfg.enabled ?? true;
|
|
4909
|
-
const backlogPath = bugLogCfg.backlogPath ?
|
|
4910
|
-
const resolvedPath = bugLogCfg.resolvedPath ?
|
|
5944
|
+
const backlogPath = bugLogCfg.backlogPath ? import_node_path20.default.resolve(projectRoot, bugLogCfg.backlogPath) : void 0;
|
|
5945
|
+
const resolvedPath = bugLogCfg.resolvedPath ? import_node_path20.default.resolve(projectRoot, bugLogCfg.resolvedPath) : backlogPath ? import_node_path20.default.join(import_node_path20.default.dirname(backlogPath), "resolved.md") : void 0;
|
|
4911
5946
|
const bugs = logBugs ? updateBacklogFromSuite(suite, { projectRoot, backlogPath, resolvedPath }) : { created: [], regressions: [], skipped: [], backlogPath: null };
|
|
4912
5947
|
const { status, totalHunts, passed, failed, skipped } = suite.result;
|
|
4913
5948
|
return {
|
|
@@ -4934,9 +5969,9 @@ async function runHuntTool(args, configPath) {
|
|
|
4934
5969
|
}
|
|
4935
5970
|
|
|
4936
5971
|
// src/mcp/projects.ts
|
|
4937
|
-
var
|
|
5972
|
+
var import_node_fs18 = __toESM(require("fs"), 1);
|
|
4938
5973
|
var import_node_os = __toESM(require("os"), 1);
|
|
4939
|
-
var
|
|
5974
|
+
var import_node_path21 = __toESM(require("path"), 1);
|
|
4940
5975
|
var import_yaml3 = __toESM(require("yaml"), 1);
|
|
4941
5976
|
var import_zod2 = require("zod");
|
|
4942
5977
|
var projectEntrySchema = import_zod2.z.object({
|
|
@@ -4948,37 +5983,37 @@ var projectRegistrySchema = import_zod2.z.object({
|
|
|
4948
5983
|
projects: import_zod2.z.record(import_zod2.z.string().min(1), projectEntrySchema)
|
|
4949
5984
|
}).strict();
|
|
4950
5985
|
function defaultRegistryPath() {
|
|
4951
|
-
return
|
|
5986
|
+
return import_node_path21.default.join(import_node_os.default.homedir(), ".prowl", "projects.yml");
|
|
4952
5987
|
}
|
|
4953
5988
|
function legacyRegistryPath() {
|
|
4954
|
-
return
|
|
5989
|
+
return import_node_path21.default.join(import_node_os.default.homedir(), ".prowlqa", "projects.yml");
|
|
4955
5990
|
}
|
|
4956
5991
|
function resolveProjectConfigPath(root) {
|
|
4957
|
-
const preferred =
|
|
4958
|
-
if (
|
|
4959
|
-
const legacy =
|
|
4960
|
-
if (
|
|
5992
|
+
const preferred = import_node_path21.default.join(root, ".prowl", "config.yml");
|
|
5993
|
+
if (import_node_fs18.default.existsSync(preferred)) return preferred;
|
|
5994
|
+
const legacy = import_node_path21.default.join(root, ".prowlqa", "config.yml");
|
|
5995
|
+
if (import_node_fs18.default.existsSync(legacy)) return legacy;
|
|
4961
5996
|
return preferred;
|
|
4962
5997
|
}
|
|
4963
5998
|
function resolveRegistryRelativePath(registry, inputPath) {
|
|
4964
|
-
return
|
|
5999
|
+
return import_node_path21.default.isAbsolute(inputPath) ? inputPath : import_node_path21.default.resolve(import_node_path21.default.dirname(registry.registryPath), inputPath);
|
|
4965
6000
|
}
|
|
4966
6001
|
function resolveRegistryPath(explicitPath) {
|
|
4967
|
-
if (explicitPath) return
|
|
6002
|
+
if (explicitPath) return import_node_path21.default.resolve(explicitPath);
|
|
4968
6003
|
const envPath = process.env.PROWL_PROJECTS ?? process.env.PROWLQA_PROJECTS;
|
|
4969
|
-
if (envPath) return
|
|
6004
|
+
if (envPath) return import_node_path21.default.resolve(envPath);
|
|
4970
6005
|
const fallback = defaultRegistryPath();
|
|
4971
|
-
if (
|
|
6006
|
+
if (import_node_fs18.default.existsSync(fallback)) return fallback;
|
|
4972
6007
|
const legacy = legacyRegistryPath();
|
|
4973
|
-
return
|
|
6008
|
+
return import_node_fs18.default.existsSync(legacy) ? legacy : null;
|
|
4974
6009
|
}
|
|
4975
6010
|
function loadProjectRegistry(explicitPath) {
|
|
4976
6011
|
const registryPath = resolveRegistryPath(explicitPath);
|
|
4977
6012
|
if (!registryPath) return null;
|
|
4978
|
-
if (!
|
|
6013
|
+
if (!import_node_fs18.default.existsSync(registryPath)) {
|
|
4979
6014
|
throw new Error(`Project registry not found at ${registryPath}`);
|
|
4980
6015
|
}
|
|
4981
|
-
const raw =
|
|
6016
|
+
const raw = import_node_fs18.default.readFileSync(registryPath, "utf-8");
|
|
4982
6017
|
const parsed = import_yaml3.default.parse(raw) ?? {};
|
|
4983
6018
|
const validated = projectRegistrySchema.parse(parsed);
|
|
4984
6019
|
return { projects: validated.projects, registryPath };
|