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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
SUPPORTED_BROWSER_ENGINES,
|
|
2
3
|
ensureAllowedDomain,
|
|
3
4
|
huntSchema,
|
|
4
5
|
listHunts,
|
|
@@ -6,7 +7,7 @@ import {
|
|
|
6
7
|
loadHunt,
|
|
7
8
|
loadHuntTags,
|
|
8
9
|
resolveViewport
|
|
9
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-MBAIGNVO.js";
|
|
10
11
|
|
|
11
12
|
// src/config/interpolate.ts
|
|
12
13
|
import crypto from "crypto";
|
|
@@ -53,7 +54,7 @@ function generateRandomVars(randomSource) {
|
|
|
53
54
|
const hex = randomBytes(4).toString("hex");
|
|
54
55
|
const firstIndex = Math.floor(random() * RANDOM_FIRST_NAMES.length);
|
|
55
56
|
const lastIndex = Math.floor(random() * RANDOM_LAST_NAMES.length);
|
|
56
|
-
const
|
|
57
|
+
const num2 = Math.floor(random() * 9e3) + 1e3;
|
|
57
58
|
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
58
59
|
let text = "";
|
|
59
60
|
for (let i = 0; i < 8; i++) {
|
|
@@ -62,7 +63,7 @@ function generateRandomVars(randomSource) {
|
|
|
62
63
|
return {
|
|
63
64
|
RANDOM_EMAIL: `prowl_${hex}@test.com`,
|
|
64
65
|
RANDOM_NAME: `${RANDOM_FIRST_NAMES[firstIndex]} ${RANDOM_LAST_NAMES[lastIndex]}`,
|
|
65
|
-
RANDOM_NUMBER: String(
|
|
66
|
+
RANDOM_NUMBER: String(num2),
|
|
66
67
|
RANDOM_UUID: randomUUID(),
|
|
67
68
|
RANDOM_TEXT: text
|
|
68
69
|
};
|
|
@@ -374,6 +375,518 @@ function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
|
|
|
374
375
|
};
|
|
375
376
|
}
|
|
376
377
|
|
|
378
|
+
// src/config/target.ts
|
|
379
|
+
import { execFileSync } from "child_process";
|
|
380
|
+
import fs from "fs";
|
|
381
|
+
import path from "path";
|
|
382
|
+
var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
|
|
383
|
+
"navigate",
|
|
384
|
+
"waitForUrl",
|
|
385
|
+
"waitForNetworkIdle",
|
|
386
|
+
"mockRoute",
|
|
387
|
+
"unmockRoute",
|
|
388
|
+
"evalScript",
|
|
389
|
+
"runScript",
|
|
390
|
+
"onDialog",
|
|
391
|
+
"select",
|
|
392
|
+
"selectOption",
|
|
393
|
+
"setInputFiles",
|
|
394
|
+
"waitForDownload",
|
|
395
|
+
"scroll"
|
|
396
|
+
// directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
|
|
397
|
+
]);
|
|
398
|
+
function webOnlyReason(step) {
|
|
399
|
+
for (const type of WEB_ONLY_STEP_TYPES) {
|
|
400
|
+
if (type in step) {
|
|
401
|
+
return type;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if ("assert" in step) {
|
|
405
|
+
const assertion = step.assert;
|
|
406
|
+
if (assertion.urlIncludes !== void 0 || assertion.urlEquals !== void 0) {
|
|
407
|
+
return "assert (url)";
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
function assertStepsSupportedByTarget(steps, target) {
|
|
413
|
+
if (target !== "macos") {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
for (const step of steps) {
|
|
417
|
+
const reason = webOnlyReason(step);
|
|
418
|
+
if (reason) {
|
|
419
|
+
throw new Error(
|
|
420
|
+
`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.).`
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
if ("if" in step) {
|
|
424
|
+
assertStepsSupportedByTarget(step.if.then, target);
|
|
425
|
+
if (step.if.else) {
|
|
426
|
+
assertStepsSupportedByTarget(step.if.else, target);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if ("repeat" in step) {
|
|
430
|
+
assertStepsSupportedByTarget(step.repeat.steps, target);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function assertHuntAssertionsSupportedByTarget(assertions, target) {
|
|
435
|
+
if (target !== "macos" || !assertions || assertions.length === 0) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
throw new Error(
|
|
439
|
+
"Hunt-level assertions are not supported by the macOS target. Use inline assert visible/notVisible steps instead."
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
function trimTrailingPathSeparators(value) {
|
|
443
|
+
return value.replace(/[\\/]+$/g, "");
|
|
444
|
+
}
|
|
445
|
+
function looksLikeMacosAppPath(app) {
|
|
446
|
+
const trimmed = trimTrailingPathSeparators(app);
|
|
447
|
+
return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
|
|
448
|
+
}
|
|
449
|
+
function normalizeAppPath(app) {
|
|
450
|
+
return path.resolve(trimTrailingPathSeparators(app));
|
|
451
|
+
}
|
|
452
|
+
function parseBundleIdentifier(plist) {
|
|
453
|
+
const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
|
|
454
|
+
return match?.[1]?.trim() || null;
|
|
455
|
+
}
|
|
456
|
+
function readBundleIdentifier(appPath) {
|
|
457
|
+
const infoPlistPath = path.join(normalizeAppPath(appPath), "Contents", "Info.plist");
|
|
458
|
+
if (!fs.existsSync(infoPlistPath)) {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
const parsed = parseBundleIdentifier(fs.readFileSync(infoPlistPath, "utf-8"));
|
|
463
|
+
if (parsed) {
|
|
464
|
+
return parsed;
|
|
465
|
+
}
|
|
466
|
+
} catch {
|
|
467
|
+
}
|
|
468
|
+
if (process.platform !== "darwin" || !fs.existsSync("/usr/bin/plutil")) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
const output = execFileSync(
|
|
473
|
+
"/usr/bin/plutil",
|
|
474
|
+
["-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlistPath],
|
|
475
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1e3 }
|
|
476
|
+
);
|
|
477
|
+
return output.trim() || null;
|
|
478
|
+
} catch {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function macosAppAllowedIdentities(app) {
|
|
483
|
+
const identities = /* @__PURE__ */ new Set([app]);
|
|
484
|
+
if (looksLikeMacosAppPath(app)) {
|
|
485
|
+
const normalizedPath = normalizeAppPath(app);
|
|
486
|
+
identities.add(trimTrailingPathSeparators(app));
|
|
487
|
+
identities.add(normalizedPath);
|
|
488
|
+
const bundleName = path.basename(normalizedPath).replace(/\.app$/i, "");
|
|
489
|
+
if (bundleName) {
|
|
490
|
+
identities.add(bundleName);
|
|
491
|
+
}
|
|
492
|
+
const bundleId = readBundleIdentifier(app);
|
|
493
|
+
if (bundleId) {
|
|
494
|
+
identities.add(bundleId);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return [...identities];
|
|
498
|
+
}
|
|
499
|
+
function assertTargetAppAllowed(allowedApps, app) {
|
|
500
|
+
if (allowedApps.length === 0) {
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => macosAppAllowedIdentities(allowedApp)));
|
|
504
|
+
if (macosAppAllowedIdentities(app).some((identity) => allowedIdentities.has(identity))) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
throw new Error(
|
|
508
|
+
`Target app "${app}" is not in guardrails.allowedApps (${allowedApps.join(", ")}).`
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// src/browser/mac-driver.ts
|
|
513
|
+
var MAC_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
514
|
+
"query",
|
|
515
|
+
"interact",
|
|
516
|
+
"wait",
|
|
517
|
+
"screenshot"
|
|
518
|
+
]);
|
|
519
|
+
var STATUS_ITEM_SELECTOR = "statusitem";
|
|
520
|
+
var MENU_PREFIX = "menu=";
|
|
521
|
+
function unquote(value) {
|
|
522
|
+
const trimmed = value.trim();
|
|
523
|
+
const first = trimmed[0];
|
|
524
|
+
if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
|
|
525
|
+
return trimmed.slice(1, -1);
|
|
526
|
+
}
|
|
527
|
+
return trimmed;
|
|
528
|
+
}
|
|
529
|
+
function parseMacSelector(selector) {
|
|
530
|
+
const trimmed = selector.trim();
|
|
531
|
+
const idMatch = /^id=(.+)$/s.exec(trimmed);
|
|
532
|
+
if (idMatch) {
|
|
533
|
+
return { by: "id", value: unquote(idMatch[1]) };
|
|
534
|
+
}
|
|
535
|
+
const roleMatch = /^role=([A-Za-z][\w-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
|
|
536
|
+
if (roleMatch) {
|
|
537
|
+
const name = roleMatch[2] !== void 0 ? unquote(roleMatch[2]) : void 0;
|
|
538
|
+
return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
|
|
539
|
+
}
|
|
540
|
+
const labelMatch = /^label=(.+)$/s.exec(trimmed);
|
|
541
|
+
if (labelMatch) {
|
|
542
|
+
return { by: "label", value: unquote(labelMatch[1]) };
|
|
543
|
+
}
|
|
544
|
+
const textMatch = /^text=(.+)$/s.exec(trimmed);
|
|
545
|
+
if (textMatch) {
|
|
546
|
+
return { by: "text", value: unquote(textMatch[1]) };
|
|
547
|
+
}
|
|
548
|
+
return { by: "text", value: trimmed };
|
|
549
|
+
}
|
|
550
|
+
function unwrapMacTextSelector(selector) {
|
|
551
|
+
const trimmed = selector.trim();
|
|
552
|
+
if (!trimmed.startsWith("text=")) {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
return unquote(trimmed.slice(5));
|
|
556
|
+
}
|
|
557
|
+
function num(value) {
|
|
558
|
+
return typeof value === "number" ? value : Number(value ?? 0);
|
|
559
|
+
}
|
|
560
|
+
function createMacDriver(client, options = {}) {
|
|
561
|
+
const unsupported = (verb) => new Error(`${verb} is not supported by the macOS target`);
|
|
562
|
+
const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
|
|
563
|
+
async function query(cmd, selector, extra) {
|
|
564
|
+
return client.request(cmd, { query: parseMacSelector(selector), ...extra });
|
|
565
|
+
}
|
|
566
|
+
async function clickSelector(selector) {
|
|
567
|
+
const trimmed = selector.trim();
|
|
568
|
+
if (trimmed.toLowerCase() === STATUS_ITEM_SELECTOR) {
|
|
569
|
+
await client.request("openMenu");
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (trimmed.toLowerCase().startsWith(MENU_PREFIX)) {
|
|
573
|
+
await client.request("clickMenu", { title: trimmed.slice(MENU_PREFIX.length).trim() });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
await query("click", selector);
|
|
577
|
+
}
|
|
578
|
+
async function fillSelector(selector, value) {
|
|
579
|
+
if (selector.trim() === ":focus") {
|
|
580
|
+
await client.request("fill", { query: { by: "focused" }, value });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
await query("fill", selector, { value });
|
|
584
|
+
}
|
|
585
|
+
return {
|
|
586
|
+
capabilities: MAC_CAPABILITIES,
|
|
587
|
+
// navigation -----------------------------------------------------------
|
|
588
|
+
goto(_url, _options) {
|
|
589
|
+
return rejectUnsupported("navigate");
|
|
590
|
+
},
|
|
591
|
+
currentUrl() {
|
|
592
|
+
return `macos:${options.appLabel ?? ""}`;
|
|
593
|
+
},
|
|
594
|
+
// queries --------------------------------------------------------------
|
|
595
|
+
async count(selector) {
|
|
596
|
+
const result = await query("count", selector);
|
|
597
|
+
return num(result.count);
|
|
598
|
+
},
|
|
599
|
+
async textContent(selector) {
|
|
600
|
+
const result = await query("text", selector);
|
|
601
|
+
return result.text === void 0 || result.text === null ? null : String(result.text);
|
|
602
|
+
},
|
|
603
|
+
// interactions ---------------------------------------------------------
|
|
604
|
+
click: clickSelector,
|
|
605
|
+
clickFirst: clickSelector,
|
|
606
|
+
fill: fillSelector,
|
|
607
|
+
fillFirst: fillSelector,
|
|
608
|
+
async press(selector, key) {
|
|
609
|
+
await query("press", selector, { key });
|
|
610
|
+
},
|
|
611
|
+
selectOption() {
|
|
612
|
+
return rejectUnsupported("select");
|
|
613
|
+
},
|
|
614
|
+
selectOptionFirst() {
|
|
615
|
+
return rejectUnsupported("select");
|
|
616
|
+
},
|
|
617
|
+
async hover(selector) {
|
|
618
|
+
await query("hover", selector);
|
|
619
|
+
},
|
|
620
|
+
async scrollIntoView(selector) {
|
|
621
|
+
await query("scrollTo", selector);
|
|
622
|
+
},
|
|
623
|
+
setInputFiles() {
|
|
624
|
+
return rejectUnsupported("setInputFiles");
|
|
625
|
+
},
|
|
626
|
+
// semantic locators ----------------------------------------------------
|
|
627
|
+
async countByRole(role, name) {
|
|
628
|
+
const result = await client.request("count", { query: { by: "role", role, name } });
|
|
629
|
+
return num(result.count);
|
|
630
|
+
},
|
|
631
|
+
async clickFirstByRole(role, name) {
|
|
632
|
+
await client.request("click", { query: { by: "role", role, name } });
|
|
633
|
+
},
|
|
634
|
+
async countByLabel(label) {
|
|
635
|
+
const result = await client.request("count", { query: { by: "label", value: label } });
|
|
636
|
+
return num(result.count);
|
|
637
|
+
},
|
|
638
|
+
async fillFirstByLabel(label, value) {
|
|
639
|
+
await client.request("fill", { query: { by: "label", value: label }, value });
|
|
640
|
+
},
|
|
641
|
+
selectOptionFirstByLabel() {
|
|
642
|
+
return rejectUnsupported("select");
|
|
643
|
+
},
|
|
644
|
+
// waiting --------------------------------------------------------------
|
|
645
|
+
async waitForSelector(selector, waitOptions) {
|
|
646
|
+
const extra = waitOptions?.timeout !== void 0 ? { timeout: waitOptions.timeout / 1e3 } : void 0;
|
|
647
|
+
await query("waitFor", selector, extra);
|
|
648
|
+
},
|
|
649
|
+
waitForUrl() {
|
|
650
|
+
return rejectUnsupported("waitForUrl");
|
|
651
|
+
},
|
|
652
|
+
waitForNetworkIdle() {
|
|
653
|
+
return rejectUnsupported("waitForNetworkIdle");
|
|
654
|
+
},
|
|
655
|
+
// scripting & artifacts ------------------------------------------------
|
|
656
|
+
evaluate() {
|
|
657
|
+
return rejectUnsupported("evalScript");
|
|
658
|
+
},
|
|
659
|
+
async screenshot(screenshotOptions) {
|
|
660
|
+
await client.request("screenshot", { path: screenshotOptions.path });
|
|
661
|
+
},
|
|
662
|
+
// network / dialogs / downloads (all web-only) -------------------------
|
|
663
|
+
onResponse(_handler) {
|
|
664
|
+
throw unsupported("onResponse");
|
|
665
|
+
},
|
|
666
|
+
route(_url, _handler) {
|
|
667
|
+
return rejectUnsupported("mockRoute");
|
|
668
|
+
},
|
|
669
|
+
unroute() {
|
|
670
|
+
return rejectUnsupported("unmockRoute");
|
|
671
|
+
},
|
|
672
|
+
onDialog(_action) {
|
|
673
|
+
throw unsupported("onDialog");
|
|
674
|
+
},
|
|
675
|
+
waitForDownloadEvent() {
|
|
676
|
+
return rejectUnsupported("waitForDownload");
|
|
677
|
+
},
|
|
678
|
+
parseTextSelector(selector) {
|
|
679
|
+
return unwrapMacTextSelector(selector);
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// src/browser/mac-helper.ts
|
|
685
|
+
import { spawn } from "child_process";
|
|
686
|
+
import fs2 from "fs";
|
|
687
|
+
import path2 from "path";
|
|
688
|
+
import { fileURLToPath } from "url";
|
|
689
|
+
var HELPER_BINARY = "prowl-macdriver";
|
|
690
|
+
function macdriverBuildInstructions() {
|
|
691
|
+
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.";
|
|
692
|
+
}
|
|
693
|
+
function getPackageRoot() {
|
|
694
|
+
let dir = path2.dirname(fileURLToPath(import.meta.url));
|
|
695
|
+
const root = path2.parse(dir).root;
|
|
696
|
+
while (dir !== root) {
|
|
697
|
+
if (fs2.existsSync(path2.join(dir, "package.json"))) {
|
|
698
|
+
return dir;
|
|
699
|
+
}
|
|
700
|
+
dir = path2.dirname(dir);
|
|
701
|
+
}
|
|
702
|
+
return root;
|
|
703
|
+
}
|
|
704
|
+
function resolveHelperBinary(env = process.env) {
|
|
705
|
+
const override = env.PROWL_MACDRIVER_BIN;
|
|
706
|
+
if (override) {
|
|
707
|
+
if (!fs2.existsSync(override)) {
|
|
708
|
+
throw new Error(
|
|
709
|
+
`PROWL_MACDRIVER_BIN points at a missing file: ${override}
|
|
710
|
+
${macdriverBuildInstructions()}`
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
return override;
|
|
714
|
+
}
|
|
715
|
+
const root = getPackageRoot();
|
|
716
|
+
const candidates = [
|
|
717
|
+
path2.join(root, "macdriver", ".build", "release", HELPER_BINARY),
|
|
718
|
+
path2.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
|
|
719
|
+
];
|
|
720
|
+
for (const candidate of candidates) {
|
|
721
|
+
if (fs2.existsSync(candidate)) {
|
|
722
|
+
return candidate;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
throw new Error(`Could not find the ${HELPER_BINARY} helper binary.
|
|
726
|
+
${macdriverBuildInstructions()}`);
|
|
727
|
+
}
|
|
728
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
729
|
+
var SpawnMacHelperClient = class {
|
|
730
|
+
child;
|
|
731
|
+
pending = /* @__PURE__ */ new Map();
|
|
732
|
+
requestTimeoutMs;
|
|
733
|
+
stdoutBuffer = "";
|
|
734
|
+
stderrBuffer = "";
|
|
735
|
+
nextId = 1;
|
|
736
|
+
closed = false;
|
|
737
|
+
terminalError;
|
|
738
|
+
constructor(binaryPath, options = {}) {
|
|
739
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
740
|
+
this.child = spawn(binaryPath, ["serve"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
741
|
+
this.child.stdout?.setEncoding("utf-8");
|
|
742
|
+
this.child.stderr?.setEncoding("utf-8");
|
|
743
|
+
this.child.stdout?.on("data", (chunk) => this.onStdout(chunk));
|
|
744
|
+
this.child.stderr?.on("data", (chunk) => {
|
|
745
|
+
this.stderrBuffer = (this.stderrBuffer + chunk).slice(-4e3);
|
|
746
|
+
});
|
|
747
|
+
this.child.on("error", (error) => this.recordTerminalFailure(error));
|
|
748
|
+
this.child.on("exit", (code) => {
|
|
749
|
+
if (!this.closed) {
|
|
750
|
+
const detail = this.stderrBuffer.trim();
|
|
751
|
+
this.recordTerminalFailure(
|
|
752
|
+
new Error(`prowl-macdriver exited unexpectedly (code ${code ?? "null"})${detail ? `: ${detail}` : ""}`)
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
onStdout(chunk) {
|
|
758
|
+
this.stdoutBuffer += chunk;
|
|
759
|
+
let newlineIndex = this.stdoutBuffer.indexOf("\n");
|
|
760
|
+
while (newlineIndex !== -1) {
|
|
761
|
+
const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
|
|
762
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
|
763
|
+
if (line.length > 0) {
|
|
764
|
+
this.dispatch(line);
|
|
765
|
+
}
|
|
766
|
+
newlineIndex = this.stdoutBuffer.indexOf("\n");
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
dispatch(line) {
|
|
770
|
+
let message;
|
|
771
|
+
try {
|
|
772
|
+
message = JSON.parse(line);
|
|
773
|
+
} catch {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const id = typeof message.id === "number" ? message.id : void 0;
|
|
777
|
+
if (id === void 0) {
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const pending = this.pending.get(id);
|
|
781
|
+
if (!pending) {
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
this.pending.delete(id);
|
|
785
|
+
clearTimeout(pending.timer);
|
|
786
|
+
if (message.ok === true) {
|
|
787
|
+
pending.resolve(message.result ?? {});
|
|
788
|
+
} else {
|
|
789
|
+
pending.reject(new Error(typeof message.error === "string" ? message.error : "prowl-macdriver error"));
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
failAll(error) {
|
|
793
|
+
for (const pending of this.pending.values()) {
|
|
794
|
+
clearTimeout(pending.timer);
|
|
795
|
+
pending.reject(error);
|
|
796
|
+
}
|
|
797
|
+
this.pending.clear();
|
|
798
|
+
}
|
|
799
|
+
recordTerminalFailure(error) {
|
|
800
|
+
this.terminalError ??= error;
|
|
801
|
+
this.closed = true;
|
|
802
|
+
this.failAll(this.terminalError);
|
|
803
|
+
}
|
|
804
|
+
/** Number of in-flight requests awaiting a response (for teardown/tests). */
|
|
805
|
+
get pendingCount() {
|
|
806
|
+
return this.pending.size;
|
|
807
|
+
}
|
|
808
|
+
request(cmd, params = {}) {
|
|
809
|
+
if (this.terminalError) {
|
|
810
|
+
return Promise.reject(this.terminalError);
|
|
811
|
+
}
|
|
812
|
+
if (this.closed) {
|
|
813
|
+
return Promise.reject(new Error("prowl-macdriver client is closed"));
|
|
814
|
+
}
|
|
815
|
+
const id = this.nextId++;
|
|
816
|
+
const payload = JSON.stringify({ id, cmd, ...params });
|
|
817
|
+
return new Promise((resolve, reject) => {
|
|
818
|
+
const timer = setTimeout(() => {
|
|
819
|
+
if (this.pending.delete(id)) {
|
|
820
|
+
const shown = this.requestTimeoutMs >= 1e3 ? `${Math.round(this.requestTimeoutMs / 1e3)}s` : `${this.requestTimeoutMs}ms`;
|
|
821
|
+
reject(new Error(`prowl-macdriver request "${cmd}" timed out after ${shown}`));
|
|
822
|
+
}
|
|
823
|
+
}, this.requestTimeoutMs);
|
|
824
|
+
timer.unref?.();
|
|
825
|
+
this.pending.set(id, { cmd, resolve, reject, timer });
|
|
826
|
+
this.child.stdin?.write(payload + "\n", (error) => {
|
|
827
|
+
if (error && this.pending.delete(id)) {
|
|
828
|
+
clearTimeout(timer);
|
|
829
|
+
reject(error);
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
async close() {
|
|
835
|
+
if (this.closed) {
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
this.closed = true;
|
|
839
|
+
try {
|
|
840
|
+
this.child.stdin?.write(JSON.stringify({ cmd: "shutdown" }) + "\n");
|
|
841
|
+
this.child.stdin?.end();
|
|
842
|
+
} catch {
|
|
843
|
+
}
|
|
844
|
+
await new Promise((resolve) => {
|
|
845
|
+
if (this.child.exitCode !== null || this.child.signalCode !== null) {
|
|
846
|
+
resolve();
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
const timer = setTimeout(() => {
|
|
850
|
+
this.child.kill("SIGKILL");
|
|
851
|
+
resolve();
|
|
852
|
+
}, 2e3);
|
|
853
|
+
this.child.once("exit", () => {
|
|
854
|
+
clearTimeout(timer);
|
|
855
|
+
resolve();
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
this.failAll(new Error("prowl-macdriver client is closed"));
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
async function launchMacSession(options) {
|
|
862
|
+
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_REQUEST_TIMEOUT_MS) + 5e3;
|
|
863
|
+
const client = options.clientFactory ? options.clientFactory() : new SpawnMacHelperClient(resolveHelperBinary(), { requestTimeoutMs });
|
|
864
|
+
const timeoutSeconds = (options.timeoutMs ?? 1e4) / 1e3;
|
|
865
|
+
try {
|
|
866
|
+
const trust = await client.request("check");
|
|
867
|
+
if (trust.trusted !== true) {
|
|
868
|
+
throw new Error(
|
|
869
|
+
"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."
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
const launched = await client.request("launch", { app: options.app, timeout: timeoutSeconds });
|
|
873
|
+
const bundleId = String(launched.bundleId ?? options.app);
|
|
874
|
+
const driver = createMacDriver(client, { appLabel: bundleId });
|
|
875
|
+
return { client, driver, bundleId };
|
|
876
|
+
} catch (error) {
|
|
877
|
+
await client.close().catch(() => void 0);
|
|
878
|
+
throw error;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
async function closeMacSession(session) {
|
|
882
|
+
try {
|
|
883
|
+
await session.client.request("quit");
|
|
884
|
+
} catch {
|
|
885
|
+
} finally {
|
|
886
|
+
await session.client.close();
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
377
890
|
// src/runner/healing.ts
|
|
378
891
|
var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
|
|
379
892
|
function extractSelectorIntent(selector) {
|
|
@@ -411,12 +924,12 @@ function buildHealCandidates(selector) {
|
|
|
411
924
|
}
|
|
412
925
|
return candidates;
|
|
413
926
|
}
|
|
414
|
-
async function healSelector(
|
|
927
|
+
async function healSelector(probe, selector, options) {
|
|
415
928
|
if (!options.enabled) return null;
|
|
416
929
|
for (const candidate of buildHealCandidates(selector)) {
|
|
417
930
|
let count;
|
|
418
931
|
try {
|
|
419
|
-
const locator =
|
|
932
|
+
const locator = probe.locator(candidate.selector);
|
|
420
933
|
count = await locator.count();
|
|
421
934
|
} catch {
|
|
422
935
|
continue;
|
|
@@ -429,15 +942,15 @@ async function healSelector(page, selector, options) {
|
|
|
429
942
|
}
|
|
430
943
|
|
|
431
944
|
// src/runner/history.ts
|
|
432
|
-
import
|
|
433
|
-
import
|
|
945
|
+
import fs3 from "fs";
|
|
946
|
+
import path3 from "path";
|
|
434
947
|
var HISTORY_FILE = "history.json";
|
|
435
948
|
var LOCK_FILE_SUFFIX = ".lock";
|
|
436
949
|
var LOCK_RETRY_MS = 10;
|
|
437
950
|
var LOCK_TIMEOUT_MS = 5e3;
|
|
438
951
|
var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
439
952
|
function historyPath(configDir) {
|
|
440
|
-
return
|
|
953
|
+
return path3.join(configDir, HISTORY_FILE);
|
|
441
954
|
}
|
|
442
955
|
function isHistoryEntry(value) {
|
|
443
956
|
if (!value || typeof value !== "object") {
|
|
@@ -448,11 +961,11 @@ function isHistoryEntry(value) {
|
|
|
448
961
|
}
|
|
449
962
|
function readHistory(configDir) {
|
|
450
963
|
const filePath = historyPath(configDir);
|
|
451
|
-
if (!
|
|
964
|
+
if (!fs3.existsSync(filePath)) {
|
|
452
965
|
return { entries: [] };
|
|
453
966
|
}
|
|
454
967
|
try {
|
|
455
|
-
const raw =
|
|
968
|
+
const raw = fs3.readFileSync(filePath, "utf-8");
|
|
456
969
|
const parsed = JSON.parse(raw);
|
|
457
970
|
if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
|
|
458
971
|
const validatedEntries = parsed.entries.filter(isHistoryEntry);
|
|
@@ -491,12 +1004,12 @@ function sleepSync(ms) {
|
|
|
491
1004
|
function withHistoryLock(configDir, fn) {
|
|
492
1005
|
const filePath = historyPath(configDir);
|
|
493
1006
|
const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
|
|
494
|
-
|
|
1007
|
+
fs3.mkdirSync(path3.dirname(filePath), { recursive: true });
|
|
495
1008
|
const startedAt = Date.now();
|
|
496
1009
|
while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
|
|
497
1010
|
let fd;
|
|
498
1011
|
try {
|
|
499
|
-
fd =
|
|
1012
|
+
fd = fs3.openSync(lockPath, "wx");
|
|
500
1013
|
} catch (error) {
|
|
501
1014
|
if (error.code === "EEXIST") {
|
|
502
1015
|
sleepSync(LOCK_RETRY_MS);
|
|
@@ -508,10 +1021,10 @@ function withHistoryLock(configDir, fn) {
|
|
|
508
1021
|
return fn();
|
|
509
1022
|
} finally {
|
|
510
1023
|
try {
|
|
511
|
-
|
|
1024
|
+
fs3.closeSync(fd);
|
|
512
1025
|
} catch {
|
|
513
1026
|
}
|
|
514
|
-
|
|
1027
|
+
fs3.rmSync(lockPath, { force: true });
|
|
515
1028
|
}
|
|
516
1029
|
}
|
|
517
1030
|
throw new Error(
|
|
@@ -524,159 +1037,338 @@ function appendEntry(configDir, entry, maxRuns) {
|
|
|
524
1037
|
const current = readHistory(configDir);
|
|
525
1038
|
const next = pruneEntries([...current.entries, entry], maxRuns);
|
|
526
1039
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
527
|
-
|
|
1040
|
+
fs3.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
|
|
528
1041
|
`);
|
|
529
|
-
|
|
1042
|
+
fs3.renameSync(tempPath, filePath);
|
|
530
1043
|
});
|
|
531
1044
|
}
|
|
532
1045
|
|
|
533
1046
|
// src/runner/index.ts
|
|
534
|
-
import
|
|
535
|
-
import
|
|
1047
|
+
import fs9 from "fs";
|
|
1048
|
+
import path9 from "path";
|
|
536
1049
|
|
|
537
|
-
// src/browser/
|
|
538
|
-
import
|
|
539
|
-
import
|
|
540
|
-
import {
|
|
1050
|
+
// src/browser/playwright-driver.ts
|
|
1051
|
+
import fs4 from "fs";
|
|
1052
|
+
import path4 from "path";
|
|
1053
|
+
import {
|
|
1054
|
+
chromium,
|
|
1055
|
+
firefox,
|
|
1056
|
+
webkit
|
|
1057
|
+
} from "playwright";
|
|
541
1058
|
var ENGINES = { chromium, firefox, webkit };
|
|
542
1059
|
async function launchBrowser(options) {
|
|
543
|
-
const
|
|
1060
|
+
const engineName = options.engine ?? "chromium";
|
|
1061
|
+
const engine = Object.prototype.hasOwnProperty.call(ENGINES, engineName) ? ENGINES[engineName] : void 0;
|
|
1062
|
+
if (!engine) {
|
|
1063
|
+
throw new Error(
|
|
1064
|
+
`Unsupported browser engine "${String(engineName)}". Available engines: ${Object.keys(ENGINES).join(", ")}.`
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
544
1067
|
const browser = await engine.launch({
|
|
545
1068
|
headless: options.headless,
|
|
546
1069
|
slowMo: options.slowMo,
|
|
547
1070
|
channel: options.channel
|
|
548
1071
|
});
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
if (options.storageStatePath) {
|
|
554
|
-
if (fs2.existsSync(options.storageStatePath)) {
|
|
555
|
-
contextOptions.storageState = options.storageStatePath;
|
|
556
|
-
} else {
|
|
557
|
-
console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
|
|
1072
|
+
try {
|
|
1073
|
+
const contextOptions = {};
|
|
1074
|
+
if (options.viewport) {
|
|
1075
|
+
contextOptions.viewport = options.viewport;
|
|
558
1076
|
}
|
|
1077
|
+
if (options.storageStatePath) {
|
|
1078
|
+
if (fs4.existsSync(options.storageStatePath)) {
|
|
1079
|
+
contextOptions.storageState = options.storageStatePath;
|
|
1080
|
+
} else {
|
|
1081
|
+
console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
if (options.recordHar) {
|
|
1085
|
+
contextOptions.recordHar = { path: path4.join(options.runDir, "network.har") };
|
|
1086
|
+
}
|
|
1087
|
+
const context = await browser.newContext(contextOptions);
|
|
1088
|
+
const page = await context.newPage();
|
|
1089
|
+
page.setDefaultTimeout(options.timeout);
|
|
1090
|
+
page.setDefaultNavigationTimeout(options.timeout);
|
|
1091
|
+
let tracePath;
|
|
1092
|
+
if (options.trace) {
|
|
1093
|
+
tracePath = path4.join(options.runDir, "trace.zip");
|
|
1094
|
+
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
1095
|
+
}
|
|
1096
|
+
return { browser, context, page, tracePath };
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
try {
|
|
1099
|
+
await browser.close();
|
|
1100
|
+
} catch (closeError) {
|
|
1101
|
+
console.warn(`Failed to close browser after setup error: ${formatError(closeError)}`);
|
|
1102
|
+
}
|
|
1103
|
+
throw error;
|
|
559
1104
|
}
|
|
560
|
-
if (options.recordHar) {
|
|
561
|
-
contextOptions.recordHar = { path: path2.join(options.runDir, "network.har") };
|
|
562
|
-
}
|
|
563
|
-
const context = await browser.newContext(contextOptions);
|
|
564
|
-
const page = await context.newPage();
|
|
565
|
-
page.setDefaultTimeout(options.timeout);
|
|
566
|
-
page.setDefaultNavigationTimeout(options.timeout);
|
|
567
|
-
let tracePath;
|
|
568
|
-
if (options.trace) {
|
|
569
|
-
tracePath = path2.join(options.runDir, "trace.zip");
|
|
570
|
-
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
571
|
-
}
|
|
572
|
-
return { browser, context, page, tracePath };
|
|
573
1105
|
}
|
|
574
1106
|
async function closeBrowser(session) {
|
|
575
|
-
|
|
576
|
-
|
|
1107
|
+
try {
|
|
1108
|
+
if (session.tracePath) {
|
|
1109
|
+
await session.context.tracing.stop({ path: session.tracePath });
|
|
1110
|
+
}
|
|
1111
|
+
await session.context.close();
|
|
1112
|
+
} finally {
|
|
1113
|
+
await session.browser.close();
|
|
577
1114
|
}
|
|
578
|
-
await session.context.close();
|
|
579
|
-
await session.browser.close();
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
// src/runner/steps.ts
|
|
583
|
-
import fs3 from "fs";
|
|
584
|
-
import path3 from "path";
|
|
585
|
-
|
|
586
|
-
// src/browser/actions.ts
|
|
587
|
-
async function clickElement(page, selector) {
|
|
588
|
-
await page.locator(selector).click();
|
|
589
1115
|
}
|
|
590
|
-
async function
|
|
591
|
-
await
|
|
1116
|
+
async function saveStorageState(session, storageStatePath) {
|
|
1117
|
+
await session.context.storageState({ path: storageStatePath });
|
|
592
1118
|
}
|
|
593
|
-
|
|
594
|
-
|
|
1119
|
+
var ALL_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
1120
|
+
"navigate",
|
|
1121
|
+
"query",
|
|
1122
|
+
"interact",
|
|
1123
|
+
"wait",
|
|
1124
|
+
"screenshot",
|
|
1125
|
+
"evaluate",
|
|
1126
|
+
"response",
|
|
1127
|
+
"route",
|
|
1128
|
+
"dialog",
|
|
1129
|
+
"files",
|
|
1130
|
+
"download"
|
|
1131
|
+
]);
|
|
1132
|
+
function formatError(error) {
|
|
1133
|
+
return error instanceof Error ? error.message : String(error);
|
|
595
1134
|
}
|
|
596
|
-
|
|
597
|
-
|
|
1135
|
+
function unwrapTextSelector(value) {
|
|
1136
|
+
const trimmed = value.trim();
|
|
1137
|
+
if (!trimmed.startsWith("text=")) {
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
const raw = trimmed.slice(5);
|
|
1141
|
+
const first = raw[0];
|
|
1142
|
+
if (first === '"' || first === "'") {
|
|
1143
|
+
const unquoted = raw.slice(1);
|
|
1144
|
+
return unquoted.endsWith(first) ? unquoted.slice(0, -1) : unquoted;
|
|
1145
|
+
}
|
|
1146
|
+
return raw;
|
|
598
1147
|
}
|
|
599
|
-
function
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
1148
|
+
function createPlaywrightDriver(page) {
|
|
1149
|
+
return {
|
|
1150
|
+
capabilities: ALL_CAPABILITIES,
|
|
1151
|
+
async goto(url, options) {
|
|
1152
|
+
if (options?.waitUntil !== void 0) {
|
|
1153
|
+
await page.goto(url, { waitUntil: options.waitUntil });
|
|
1154
|
+
} else {
|
|
1155
|
+
await page.goto(url);
|
|
1156
|
+
}
|
|
1157
|
+
},
|
|
1158
|
+
currentUrl() {
|
|
1159
|
+
return page.url();
|
|
1160
|
+
},
|
|
1161
|
+
count(selector) {
|
|
1162
|
+
return page.locator(selector).count();
|
|
1163
|
+
},
|
|
1164
|
+
textContent(selector) {
|
|
1165
|
+
return page.locator(selector).textContent();
|
|
1166
|
+
},
|
|
1167
|
+
async click(selector) {
|
|
1168
|
+
await page.locator(selector).click();
|
|
1169
|
+
},
|
|
1170
|
+
async clickFirst(selector) {
|
|
1171
|
+
await page.locator(selector).first().click();
|
|
1172
|
+
},
|
|
1173
|
+
async fill(selector, value) {
|
|
1174
|
+
await page.locator(selector).fill(value);
|
|
1175
|
+
},
|
|
1176
|
+
async fillFirst(selector, value) {
|
|
1177
|
+
await page.locator(selector).first().fill(value);
|
|
1178
|
+
},
|
|
1179
|
+
async press(selector, key) {
|
|
1180
|
+
await page.locator(selector).press(key);
|
|
1181
|
+
},
|
|
1182
|
+
async selectOption(selector, value) {
|
|
1183
|
+
await page.locator(selector).selectOption(value);
|
|
1184
|
+
},
|
|
1185
|
+
async selectOptionFirst(selector, value) {
|
|
1186
|
+
await page.locator(selector).first().selectOption(value);
|
|
1187
|
+
},
|
|
1188
|
+
async hover(selector) {
|
|
1189
|
+
await page.locator(selector).hover();
|
|
1190
|
+
},
|
|
1191
|
+
async scrollIntoView(selector) {
|
|
1192
|
+
await page.locator(selector).scrollIntoViewIfNeeded();
|
|
1193
|
+
},
|
|
1194
|
+
async setInputFiles(selector, files) {
|
|
1195
|
+
await page.locator(selector).setInputFiles(files);
|
|
1196
|
+
},
|
|
1197
|
+
countByRole(role, name) {
|
|
1198
|
+
return page.getByRole(role, { name }).count();
|
|
1199
|
+
},
|
|
1200
|
+
async clickFirstByRole(role, name) {
|
|
1201
|
+
await page.getByRole(role, { name }).first().click();
|
|
1202
|
+
},
|
|
1203
|
+
countByLabel(label) {
|
|
1204
|
+
return page.getByLabel(label, { exact: true }).count();
|
|
1205
|
+
},
|
|
1206
|
+
async fillFirstByLabel(label, value) {
|
|
1207
|
+
await page.getByLabel(label, { exact: true }).first().fill(value);
|
|
1208
|
+
},
|
|
1209
|
+
async selectOptionFirstByLabel(label, value) {
|
|
1210
|
+
await page.getByLabel(label, { exact: true }).first().selectOption(value);
|
|
1211
|
+
},
|
|
1212
|
+
async waitForSelector(selector, options) {
|
|
1213
|
+
await page.waitForSelector(selector, { timeout: options?.timeout });
|
|
1214
|
+
},
|
|
1215
|
+
async waitForUrl(predicate, options) {
|
|
1216
|
+
await page.waitForURL((url) => predicate(url.toString()), { timeout: options?.timeout });
|
|
1217
|
+
},
|
|
1218
|
+
async waitForNetworkIdle(options) {
|
|
1219
|
+
await page.waitForLoadState("networkidle", { timeout: options?.timeout });
|
|
1220
|
+
},
|
|
1221
|
+
evaluate(pageFunction, arg) {
|
|
1222
|
+
const raw = page.evaluate;
|
|
1223
|
+
const result = arg === void 0 ? raw.call(page, pageFunction) : raw.call(page, pageFunction, arg);
|
|
1224
|
+
return result;
|
|
1225
|
+
},
|
|
1226
|
+
async screenshot(options) {
|
|
1227
|
+
await page.screenshot({ path: options.path, fullPage: options.fullPage });
|
|
1228
|
+
},
|
|
1229
|
+
onResponse(handler) {
|
|
1230
|
+
page.on("response", handler);
|
|
1231
|
+
},
|
|
1232
|
+
async route(url, handler) {
|
|
1233
|
+
await page.route(url, async (pwRoute) => {
|
|
1234
|
+
try {
|
|
1235
|
+
await handler({
|
|
1236
|
+
fulfill: (response) => pwRoute.fulfill(response)
|
|
1237
|
+
});
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
try {
|
|
1240
|
+
await pwRoute.abort("failed");
|
|
1241
|
+
} catch (abortError) {
|
|
1242
|
+
throw new Error(
|
|
1243
|
+
`Route handler failed for ${url}: ${formatError(error)}. Route abort also failed: ${formatError(abortError)}`
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
throw new Error(`Route handler failed for ${url}: ${formatError(error)}`);
|
|
1247
|
+
}
|
|
1248
|
+
});
|
|
1249
|
+
},
|
|
1250
|
+
async unroute(url) {
|
|
1251
|
+
await page.unroute(url);
|
|
1252
|
+
},
|
|
1253
|
+
onDialog(action) {
|
|
1254
|
+
page.once("dialog", (dialog) => {
|
|
1255
|
+
const response = action === "accept" ? dialog.accept() : dialog.dismiss();
|
|
1256
|
+
response.catch((error) => {
|
|
1257
|
+
console.warn(`Failed to ${action} dialog: ${formatError(error)}`);
|
|
1258
|
+
});
|
|
1259
|
+
});
|
|
1260
|
+
},
|
|
1261
|
+
waitForDownloadEvent(options) {
|
|
1262
|
+
return page.waitForEvent("download", { timeout: options?.timeout });
|
|
1263
|
+
},
|
|
1264
|
+
parseTextSelector(selector) {
|
|
1265
|
+
return unwrapTextSelector(selector);
|
|
605
1266
|
}
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
async function setInputFiles(page, selector, files) {
|
|
609
|
-
await page.locator(selector).setInputFiles(files);
|
|
1267
|
+
};
|
|
610
1268
|
}
|
|
611
1269
|
|
|
612
1270
|
// src/runner/steps.ts
|
|
1271
|
+
import fs5 from "fs";
|
|
1272
|
+
import path5 from "path";
|
|
1273
|
+
|
|
1274
|
+
// src/runner/policy.ts
|
|
613
1275
|
var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
|
|
614
|
-
function
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
function matchesForbiddenPattern(selector, forbidden) {
|
|
628
|
-
const selectorText = unwrapTextSelector(selector);
|
|
629
|
-
if (selectorText === null) {
|
|
630
|
-
return false;
|
|
1276
|
+
function createRunPolicy(driver, options) {
|
|
1277
|
+
const { forbiddenSelectors, allowedDomains, maxSteps, selfHealing } = options;
|
|
1278
|
+
const allowedApps = options.allowedApps ?? [];
|
|
1279
|
+
function matchesForbiddenPattern(selector, forbidden) {
|
|
1280
|
+
const selectorText = driver.parseTextSelector(selector);
|
|
1281
|
+
if (selectorText === null) {
|
|
1282
|
+
return false;
|
|
1283
|
+
}
|
|
1284
|
+
const forbiddenText = driver.parseTextSelector(forbidden);
|
|
1285
|
+
if (forbiddenText !== null) {
|
|
1286
|
+
return selectorText.includes(forbiddenText);
|
|
1287
|
+
}
|
|
1288
|
+
return selectorText.includes(forbidden);
|
|
631
1289
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
1290
|
+
function isForbiddenSelector(selector) {
|
|
1291
|
+
return forbiddenSelectors.some(
|
|
1292
|
+
(forbidden) => selector.includes(forbidden) || matchesForbiddenPattern(selector, forbidden)
|
|
1293
|
+
);
|
|
635
1294
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
(forbidden) => selector.includes(forbidden) || matchesForbiddenPattern(selector, forbidden)
|
|
641
|
-
);
|
|
642
|
-
}
|
|
643
|
-
function assertAllowedSelector(selector, forbiddenSelectors) {
|
|
644
|
-
if (isForbiddenSelector(selector, forbiddenSelectors)) {
|
|
645
|
-
throw new Error(`Forbidden selector: ${selector}`);
|
|
1295
|
+
function assertAllowedSelector(selector) {
|
|
1296
|
+
if (isForbiddenSelector(selector)) {
|
|
1297
|
+
throw new Error(`Forbidden selector: ${selector}`);
|
|
1298
|
+
}
|
|
646
1299
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
1300
|
+
function assertWithinMaxSteps(stepCount, huntName) {
|
|
1301
|
+
if (stepCount > maxSteps) {
|
|
1302
|
+
if (huntName) {
|
|
1303
|
+
throw new Error(`Hunt "${huntName}" has ${stepCount} steps. Max allowed is ${maxSteps}.`);
|
|
1304
|
+
}
|
|
1305
|
+
throw new Error(`Hunt has ${stepCount} steps. Max allowed is ${maxSteps}.`);
|
|
1306
|
+
}
|
|
652
1307
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
1308
|
+
function ensureUrlAllowed(urlValue) {
|
|
1309
|
+
for (const protocol of ALWAYS_ALLOWED_PROTOCOLS) {
|
|
1310
|
+
if (urlValue.startsWith(protocol)) {
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
let url;
|
|
1315
|
+
try {
|
|
1316
|
+
url = new URL(urlValue);
|
|
1317
|
+
} catch {
|
|
1318
|
+
throw new Error(`Navigation target is not a valid absolute URL: ${urlValue}`);
|
|
1319
|
+
}
|
|
1320
|
+
if (!allowedDomains.includes(url.hostname)) {
|
|
1321
|
+
throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
|
|
1322
|
+
}
|
|
658
1323
|
}
|
|
659
|
-
|
|
660
|
-
|
|
1324
|
+
function ensureAppAllowed(app) {
|
|
1325
|
+
if (!allowedApps.includes(app)) {
|
|
1326
|
+
throw new Error(`Interaction with disallowed app: ${app}`);
|
|
1327
|
+
}
|
|
661
1328
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
1329
|
+
function ensureLocationAllowed(activeDriver) {
|
|
1330
|
+
if (activeDriver.capabilities.has("navigate")) {
|
|
1331
|
+
ensureUrlAllowed(activeDriver.currentUrl());
|
|
1332
|
+
}
|
|
665
1333
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
)
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
1334
|
+
const healProbe = {
|
|
1335
|
+
locator: (selector) => ({ count: () => driver.count(selector) })
|
|
1336
|
+
};
|
|
1337
|
+
async function resolveActionSelector(selector) {
|
|
1338
|
+
assertAllowedSelector(selector);
|
|
1339
|
+
if (!selfHealing) {
|
|
1340
|
+
return { selector };
|
|
1341
|
+
}
|
|
1342
|
+
let matched = false;
|
|
1343
|
+
try {
|
|
1344
|
+
matched = await driver.count(selector) > 0;
|
|
1345
|
+
} catch {
|
|
1346
|
+
return { selector };
|
|
1347
|
+
}
|
|
1348
|
+
if (matched) {
|
|
1349
|
+
return { selector };
|
|
676
1350
|
}
|
|
677
|
-
|
|
1351
|
+
const healed = await healSelector(healProbe, selector, { enabled: true });
|
|
1352
|
+
if (!healed) {
|
|
1353
|
+
return { selector };
|
|
1354
|
+
}
|
|
1355
|
+
assertAllowedSelector(healed.selector);
|
|
1356
|
+
console.warn(
|
|
1357
|
+
`Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
|
|
1358
|
+
);
|
|
1359
|
+
return { selector: healed.selector, healedFrom: healed.healedFrom };
|
|
678
1360
|
}
|
|
1361
|
+
return {
|
|
1362
|
+
assertWithinMaxSteps,
|
|
1363
|
+
ensureUrlAllowed,
|
|
1364
|
+
ensureAppAllowed,
|
|
1365
|
+
ensureLocationAllowed,
|
|
1366
|
+
assertAllowedSelector,
|
|
1367
|
+
resolveActionSelector
|
|
1368
|
+
};
|
|
679
1369
|
}
|
|
1370
|
+
|
|
1371
|
+
// src/runner/steps.ts
|
|
680
1372
|
function getStepType(step) {
|
|
681
1373
|
if ("navigate" in step) return "navigate";
|
|
682
1374
|
if ("click" in step) return "click";
|
|
@@ -780,17 +1472,6 @@ function applyRuntimeVars(step, vars) {
|
|
|
780
1472
|
function isExplicitFillStep(value) {
|
|
781
1473
|
return typeof value.selector === "string" && typeof value.value === "string";
|
|
782
1474
|
}
|
|
783
|
-
function ensureAllowedUrl(urlValue, allowedDomains) {
|
|
784
|
-
for (const protocol of ALWAYS_ALLOWED_PROTOCOLS) {
|
|
785
|
-
if (urlValue.startsWith(protocol)) {
|
|
786
|
-
return;
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
const url = new URL(urlValue);
|
|
790
|
-
if (!allowedDomains.includes(url.hostname)) {
|
|
791
|
-
throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
1475
|
function resolveNavigationTarget(targetUrl, value) {
|
|
795
1476
|
try {
|
|
796
1477
|
return new URL(value, targetUrl).toString();
|
|
@@ -817,56 +1498,50 @@ function getSinglePair(value, stepType) {
|
|
|
817
1498
|
}
|
|
818
1499
|
return entries[0];
|
|
819
1500
|
}
|
|
820
|
-
async function clickByTextWithFallback(
|
|
1501
|
+
async function clickByTextWithFallback(driver, policy, text) {
|
|
821
1502
|
const roleSelector = `role=button[name="${escapeForAttribute(text)}"]`;
|
|
822
|
-
assertAllowedSelector(roleSelector
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
await button.first().click();
|
|
1503
|
+
policy.assertAllowedSelector(roleSelector);
|
|
1504
|
+
if (await driver.countByRole("button", text)) {
|
|
1505
|
+
await driver.clickFirstByRole("button", text);
|
|
826
1506
|
return roleSelector;
|
|
827
1507
|
}
|
|
828
1508
|
const selector = exactTextSelector(text);
|
|
829
|
-
assertAllowedSelector(selector
|
|
830
|
-
await
|
|
1509
|
+
policy.assertAllowedSelector(selector);
|
|
1510
|
+
await driver.clickFirst(selector);
|
|
831
1511
|
return selector;
|
|
832
1512
|
}
|
|
833
|
-
async function fillByLabelOrPlaceholder(
|
|
1513
|
+
async function fillByLabelOrPlaceholder(driver, policy, label, value) {
|
|
834
1514
|
const labelSelector = `label="${escapeForAttribute(label)}"`;
|
|
835
|
-
assertAllowedSelector(labelSelector
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
await byLabel.first().fill(value);
|
|
1515
|
+
policy.assertAllowedSelector(labelSelector);
|
|
1516
|
+
if (await driver.countByLabel(label)) {
|
|
1517
|
+
await driver.fillFirstByLabel(label, value);
|
|
839
1518
|
return labelSelector;
|
|
840
1519
|
}
|
|
841
1520
|
const placeholder = `input[placeholder="${escapeForAttribute(label)}"], textarea[placeholder="${escapeForAttribute(label)}"]`;
|
|
842
|
-
assertAllowedSelector(placeholder
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
await byPlaceholder.first().fill(value);
|
|
1521
|
+
policy.assertAllowedSelector(placeholder);
|
|
1522
|
+
if (await driver.count(placeholder)) {
|
|
1523
|
+
await driver.fillFirst(placeholder, value);
|
|
846
1524
|
return placeholder;
|
|
847
1525
|
}
|
|
848
1526
|
throw new Error(`Could not resolve fill shorthand for "${label}"`);
|
|
849
1527
|
}
|
|
850
|
-
async function selectByLabelOrFallback(
|
|
1528
|
+
async function selectByLabelOrFallback(driver, policy, label, value) {
|
|
851
1529
|
const labelSelector = `label="${escapeForAttribute(label)}"`;
|
|
852
|
-
assertAllowedSelector(labelSelector
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
await byLabel.first().selectOption(value);
|
|
1530
|
+
policy.assertAllowedSelector(labelSelector);
|
|
1531
|
+
if (await driver.countByLabel(label)) {
|
|
1532
|
+
await driver.selectOptionFirstByLabel(label, value);
|
|
856
1533
|
return labelSelector;
|
|
857
1534
|
}
|
|
858
1535
|
const ariaSelector = `select[aria-label="${escapeForAttribute(label)}"]`;
|
|
859
|
-
assertAllowedSelector(ariaSelector
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
await byAria.first().selectOption(value);
|
|
1536
|
+
policy.assertAllowedSelector(ariaSelector);
|
|
1537
|
+
if (await driver.count(ariaSelector)) {
|
|
1538
|
+
await driver.selectOptionFirst(ariaSelector, value);
|
|
863
1539
|
return ariaSelector;
|
|
864
1540
|
}
|
|
865
1541
|
const placeholderSelector = `select[placeholder="${escapeForAttribute(label)}"]`;
|
|
866
|
-
assertAllowedSelector(placeholderSelector
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
await byPlaceholder.first().selectOption(value);
|
|
1542
|
+
policy.assertAllowedSelector(placeholderSelector);
|
|
1543
|
+
if (await driver.count(placeholderSelector)) {
|
|
1544
|
+
await driver.selectOptionFirst(placeholderSelector, value);
|
|
870
1545
|
return placeholderSelector;
|
|
871
1546
|
}
|
|
872
1547
|
throw new Error(`Could not resolve select shorthand for "${label}"`);
|
|
@@ -994,11 +1669,11 @@ function toVisibilitySelector(value) {
|
|
|
994
1669
|
if (looksLikeSelector(value)) return value;
|
|
995
1670
|
return textContainsSelector(value);
|
|
996
1671
|
}
|
|
997
|
-
async function runInlineAssert(
|
|
1672
|
+
async function runInlineAssert(driver, policy, assertion) {
|
|
998
1673
|
if (assertion.visible !== void 0) {
|
|
999
1674
|
const selector = toVisibilitySelector(assertion.visible);
|
|
1000
|
-
assertAllowedSelector(selector
|
|
1001
|
-
const count = await
|
|
1675
|
+
policy.assertAllowedSelector(selector);
|
|
1676
|
+
const count = await driver.count(selector);
|
|
1002
1677
|
if (count === 0) {
|
|
1003
1678
|
throw new Error(`Expected visible: ${assertion.visible}`);
|
|
1004
1679
|
}
|
|
@@ -1006,22 +1681,22 @@ async function runInlineAssert(page, assertion, forbiddenSelectors) {
|
|
|
1006
1681
|
}
|
|
1007
1682
|
if (assertion.notVisible !== void 0) {
|
|
1008
1683
|
const selector = toVisibilitySelector(assertion.notVisible);
|
|
1009
|
-
assertAllowedSelector(selector
|
|
1010
|
-
const count = await
|
|
1684
|
+
policy.assertAllowedSelector(selector);
|
|
1685
|
+
const count = await driver.count(selector);
|
|
1011
1686
|
if (count > 0) {
|
|
1012
1687
|
throw new Error(`Expected not visible: ${assertion.notVisible}`);
|
|
1013
1688
|
}
|
|
1014
1689
|
return `notVisible:${assertion.notVisible}`;
|
|
1015
1690
|
}
|
|
1016
1691
|
if (assertion.urlIncludes !== void 0) {
|
|
1017
|
-
const current =
|
|
1692
|
+
const current = driver.currentUrl();
|
|
1018
1693
|
if (!current.includes(assertion.urlIncludes)) {
|
|
1019
1694
|
throw new Error(`URL did not include ${assertion.urlIncludes}`);
|
|
1020
1695
|
}
|
|
1021
1696
|
return `urlIncludes:${assertion.urlIncludes}`;
|
|
1022
1697
|
}
|
|
1023
1698
|
if (assertion.urlEquals !== void 0) {
|
|
1024
|
-
const current =
|
|
1699
|
+
const current = driver.currentUrl();
|
|
1025
1700
|
if (current !== assertion.urlEquals) {
|
|
1026
1701
|
throw new Error(`URL did not equal ${assertion.urlEquals}`);
|
|
1027
1702
|
}
|
|
@@ -1030,7 +1705,7 @@ async function runInlineAssert(page, assertion, forbiddenSelectors) {
|
|
|
1030
1705
|
throw new Error("assert step is missing an assertion type");
|
|
1031
1706
|
}
|
|
1032
1707
|
function screenshotPath(screenshotsDir, fileName) {
|
|
1033
|
-
return
|
|
1708
|
+
return path5.join(screenshotsDir, fileName);
|
|
1034
1709
|
}
|
|
1035
1710
|
function stepPath(prefix, index) {
|
|
1036
1711
|
return prefix ? `${prefix}.${index}` : `${index}`;
|
|
@@ -1038,8 +1713,8 @@ function stepPath(prefix, index) {
|
|
|
1038
1713
|
function isWaitForDownloadStep(step) {
|
|
1039
1714
|
return step !== void 0 && "waitForDownload" in step;
|
|
1040
1715
|
}
|
|
1041
|
-
function armDownloadListener(
|
|
1042
|
-
const downloadPromise =
|
|
1716
|
+
function armDownloadListener(driver, timeout) {
|
|
1717
|
+
const downloadPromise = driver.waitForDownloadEvent({ timeout });
|
|
1043
1718
|
void downloadPromise.catch(() => void 0);
|
|
1044
1719
|
return downloadPromise;
|
|
1045
1720
|
}
|
|
@@ -1047,14 +1722,14 @@ function validateDownloadFilename(suggestedFilename) {
|
|
|
1047
1722
|
const safeFilename = suggestedFilename.trim();
|
|
1048
1723
|
const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
|
|
1049
1724
|
const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
|
|
1050
|
-
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !==
|
|
1725
|
+
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== path5.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
|
|
1051
1726
|
throw new Error(`Invalid download filename: "${suggestedFilename}"`);
|
|
1052
1727
|
}
|
|
1053
1728
|
return safeFilename;
|
|
1054
1729
|
}
|
|
1055
|
-
async function captureScreenshot(
|
|
1730
|
+
async function captureScreenshot(taker, filePath) {
|
|
1056
1731
|
try {
|
|
1057
|
-
await
|
|
1732
|
+
await taker.screenshot({ path: filePath, fullPage: true });
|
|
1058
1733
|
} catch (error) {
|
|
1059
1734
|
const message = error instanceof Error ? error.message : "Screenshot failed";
|
|
1060
1735
|
throw new Error(`Failed to capture screenshot at ${filePath}: ${message}`);
|
|
@@ -1073,608 +1748,784 @@ async function executeNestedSteps(context, overrides) {
|
|
|
1073
1748
|
}
|
|
1074
1749
|
return result;
|
|
1075
1750
|
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
screenshots.push(relative);
|
|
1090
|
-
return relative;
|
|
1091
|
-
};
|
|
1092
|
-
for (let index = 0; index < context.steps.length; index += 1) {
|
|
1093
|
-
const currentStepPath = stepPath(context.stepPathPrefix, index);
|
|
1094
|
-
if (Date.now() - runStartedAtMs > context.maxTotalTimeMs) {
|
|
1095
|
-
results.push({
|
|
1096
|
-
type: "timeout",
|
|
1097
|
-
status: "fail",
|
|
1098
|
-
durationMs: 0,
|
|
1099
|
-
error: `Max total time exceeded (${context.maxTotalTimeMs}ms)`
|
|
1100
|
-
});
|
|
1101
|
-
return { results, screenshots, failed: true, error: "Max total time exceeded" };
|
|
1102
|
-
}
|
|
1103
|
-
const runtimeVars = context.runtimeVars ?? /* @__PURE__ */ new Map();
|
|
1104
|
-
context.runtimeVars = runtimeVars;
|
|
1105
|
-
let step = context.steps[index];
|
|
1106
|
-
if (runtimeVars.size > 0) {
|
|
1107
|
-
step = applyRuntimeVars(step, runtimeVars);
|
|
1108
|
-
}
|
|
1109
|
-
const nextStep = context.steps[index + 1];
|
|
1110
|
-
if (!isWaitForDownloadStep(step) && context.pendingDownload === void 0 && isWaitForDownloadStep(nextStep)) {
|
|
1111
|
-
context.pendingDownload = armDownloadListener(
|
|
1112
|
-
context.page,
|
|
1113
|
-
nextStep.waitForDownload?.timeout ?? 3e4
|
|
1114
|
-
);
|
|
1751
|
+
function unknownStep() {
|
|
1752
|
+
throw new Error("Unknown step type");
|
|
1753
|
+
}
|
|
1754
|
+
var STEP_HANDLERS = {
|
|
1755
|
+
navigate: {
|
|
1756
|
+
capabilities: ["navigate"],
|
|
1757
|
+
run: async (h) => {
|
|
1758
|
+
if (!("navigate" in h.step)) unknownStep();
|
|
1759
|
+
const destination = resolveNavigationTarget(h.context.targetUrl, h.step.navigate);
|
|
1760
|
+
h.policy.ensureUrlAllowed(destination);
|
|
1761
|
+
await h.driver.goto(destination);
|
|
1762
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
1763
|
+
return { kind: "result", result: { type: "navigate", status: "pass", durationMs: Date.now() - h.stepStart } };
|
|
1115
1764
|
}
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
if ("
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
} else {
|
|
1136
|
-
const resolved = await resolveActionSelector(context, step.click.selector);
|
|
1137
|
-
await clickElement(context.page, resolved.selector);
|
|
1138
|
-
selector = resolved.selector;
|
|
1139
|
-
healedFrom = resolved.healedFrom;
|
|
1140
|
-
}
|
|
1141
|
-
ensureAllowedUrl(context.page.url(), context.allowedDomains);
|
|
1142
|
-
stepResult = {
|
|
1765
|
+
},
|
|
1766
|
+
click: {
|
|
1767
|
+
capabilities: ["interact", "query"],
|
|
1768
|
+
run: async (h) => {
|
|
1769
|
+
if (!("click" in h.step)) unknownStep();
|
|
1770
|
+
let selector;
|
|
1771
|
+
let healedFrom;
|
|
1772
|
+
if (typeof h.step.click === "string") {
|
|
1773
|
+
selector = await clickByTextWithFallback(h.driver, h.policy, h.step.click);
|
|
1774
|
+
} else {
|
|
1775
|
+
const resolved = await h.policy.resolveActionSelector(h.step.click.selector);
|
|
1776
|
+
await h.driver.click(resolved.selector);
|
|
1777
|
+
selector = resolved.selector;
|
|
1778
|
+
healedFrom = resolved.healedFrom;
|
|
1779
|
+
}
|
|
1780
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
1781
|
+
return {
|
|
1782
|
+
kind: "result",
|
|
1783
|
+
result: {
|
|
1143
1784
|
type: "click",
|
|
1144
1785
|
status: "pass",
|
|
1145
|
-
durationMs: Date.now() - stepStart,
|
|
1786
|
+
durationMs: Date.now() - h.stepStart,
|
|
1146
1787
|
selector,
|
|
1147
1788
|
...healedFrom ? { healedFrom } : {}
|
|
1148
|
-
};
|
|
1149
|
-
} else if ("fill" in step) {
|
|
1150
|
-
let selector;
|
|
1151
|
-
let value;
|
|
1152
|
-
let healedFrom;
|
|
1153
|
-
if (isExplicitFillStep(step.fill)) {
|
|
1154
|
-
const resolved = await resolveActionSelector(context, step.fill.selector);
|
|
1155
|
-
await fillElement(context.page, resolved.selector, step.fill.value);
|
|
1156
|
-
selector = resolved.selector;
|
|
1157
|
-
healedFrom = resolved.healedFrom;
|
|
1158
|
-
value = step.fill.value;
|
|
1159
|
-
} else {
|
|
1160
|
-
const [label, shorthandValue] = getSinglePair(step.fill, "fill");
|
|
1161
|
-
selector = await fillByLabelOrPlaceholder(
|
|
1162
|
-
context.page,
|
|
1163
|
-
label,
|
|
1164
|
-
shorthandValue,
|
|
1165
|
-
context.forbiddenSelectors
|
|
1166
|
-
);
|
|
1167
|
-
value = shorthandValue;
|
|
1168
1789
|
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
},
|
|
1793
|
+
fill: {
|
|
1794
|
+
capabilities: ["interact", "query"],
|
|
1795
|
+
run: async (h) => {
|
|
1796
|
+
if (!("fill" in h.step)) unknownStep();
|
|
1797
|
+
let selector;
|
|
1798
|
+
let value;
|
|
1799
|
+
let healedFrom;
|
|
1800
|
+
if (isExplicitFillStep(h.step.fill)) {
|
|
1801
|
+
const resolved = await h.policy.resolveActionSelector(h.step.fill.selector);
|
|
1802
|
+
await h.driver.fill(resolved.selector, h.step.fill.value);
|
|
1803
|
+
selector = resolved.selector;
|
|
1804
|
+
healedFrom = resolved.healedFrom;
|
|
1805
|
+
value = h.step.fill.value;
|
|
1806
|
+
} else {
|
|
1807
|
+
const [label, shorthandValue] = getSinglePair(h.step.fill, "fill");
|
|
1808
|
+
selector = await fillByLabelOrPlaceholder(h.driver, h.policy, label, shorthandValue);
|
|
1809
|
+
value = shorthandValue;
|
|
1810
|
+
}
|
|
1811
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
1812
|
+
return {
|
|
1813
|
+
kind: "result",
|
|
1814
|
+
result: {
|
|
1171
1815
|
type: "fill",
|
|
1172
1816
|
status: "pass",
|
|
1173
|
-
durationMs: Date.now() - stepStart,
|
|
1817
|
+
durationMs: Date.now() - h.stepStart,
|
|
1174
1818
|
selector,
|
|
1175
|
-
value: context.redactedFillSteps.has(
|
|
1819
|
+
value: h.context.redactedFillSteps.has(h.stepPath) ? "[REDACTED]" : value,
|
|
1176
1820
|
...healedFrom ? { healedFrom } : {}
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
},
|
|
1825
|
+
type: {
|
|
1826
|
+
capabilities: ["interact"],
|
|
1827
|
+
run: async (h) => {
|
|
1828
|
+
if (!("type" in h.step)) unknownStep();
|
|
1829
|
+
h.policy.assertAllowedSelector(":focus");
|
|
1830
|
+
await h.driver.fill(":focus", h.step.type);
|
|
1831
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
1832
|
+
return {
|
|
1833
|
+
kind: "result",
|
|
1834
|
+
result: {
|
|
1183
1835
|
type: "type",
|
|
1184
1836
|
status: "pass",
|
|
1185
|
-
durationMs: Date.now() - stepStart,
|
|
1837
|
+
durationMs: Date.now() - h.stepStart,
|
|
1186
1838
|
selector: ":focus",
|
|
1187
|
-
value: context.redactedFillSteps.has(
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1839
|
+
value: h.context.redactedFillSteps.has(h.stepPath) ? "[REDACTED]" : h.step.type
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
},
|
|
1844
|
+
selectOption: {
|
|
1845
|
+
capabilities: ["navigate", "interact", "query"],
|
|
1846
|
+
run: async (h) => {
|
|
1847
|
+
if (!("selectOption" in h.step)) unknownStep();
|
|
1848
|
+
const resolved = await h.policy.resolveActionSelector(h.step.selectOption.selector);
|
|
1849
|
+
await h.driver.selectOption(resolved.selector, h.step.selectOption.value);
|
|
1850
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
1851
|
+
return {
|
|
1852
|
+
kind: "result",
|
|
1853
|
+
result: {
|
|
1194
1854
|
type: "selectOption",
|
|
1195
1855
|
status: "pass",
|
|
1196
|
-
durationMs: Date.now() - stepStart,
|
|
1856
|
+
durationMs: Date.now() - h.stepStart,
|
|
1197
1857
|
selector: resolved.selector,
|
|
1198
|
-
value: step.selectOption.value,
|
|
1858
|
+
value: h.step.selectOption.value,
|
|
1199
1859
|
...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
},
|
|
1864
|
+
select: {
|
|
1865
|
+
capabilities: ["navigate", "interact", "query"],
|
|
1866
|
+
run: async (h) => {
|
|
1867
|
+
if (!("select" in h.step)) unknownStep();
|
|
1868
|
+
const [label, value] = getSinglePair(h.step.select, "select");
|
|
1869
|
+
const selector = await selectByLabelOrFallback(h.driver, h.policy, label, value);
|
|
1870
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
1871
|
+
return {
|
|
1872
|
+
kind: "result",
|
|
1873
|
+
result: {
|
|
1211
1874
|
type: "select",
|
|
1212
1875
|
status: "pass",
|
|
1213
|
-
durationMs: Date.now() - stepStart,
|
|
1876
|
+
durationMs: Date.now() - h.stepStart,
|
|
1214
1877
|
selector,
|
|
1215
1878
|
value
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
|
|
1879
|
+
}
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
},
|
|
1883
|
+
onDialog: {
|
|
1884
|
+
capabilities: ["dialog"],
|
|
1885
|
+
run: async (h) => {
|
|
1886
|
+
if (!("onDialog" in h.step)) unknownStep();
|
|
1887
|
+
h.driver.onDialog(h.step.onDialog.action);
|
|
1888
|
+
return {
|
|
1889
|
+
kind: "result",
|
|
1890
|
+
result: {
|
|
1220
1891
|
type: "onDialog",
|
|
1221
1892
|
status: "pass",
|
|
1222
|
-
durationMs: Date.now() - stepStart,
|
|
1223
|
-
value: step.onDialog.action
|
|
1224
|
-
}
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1893
|
+
durationMs: Date.now() - h.stepStart,
|
|
1894
|
+
value: h.step.onDialog.action
|
|
1895
|
+
}
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
},
|
|
1899
|
+
setInputFiles: {
|
|
1900
|
+
capabilities: ["navigate", "interact", "query", "files"],
|
|
1901
|
+
run: async (h) => {
|
|
1902
|
+
if (!("setInputFiles" in h.step)) unknownStep();
|
|
1903
|
+
const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
|
|
1904
|
+
const rawFiles = h.step.setInputFiles.files;
|
|
1905
|
+
const resolveFile = (f) => path5.isAbsolute(f) ? f : path5.join(h.context.configDir, f);
|
|
1906
|
+
const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
|
|
1907
|
+
await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
|
|
1908
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
1909
|
+
const filesLabel = Array.isArray(rawFiles) ? rawFiles.join(", ") : rawFiles;
|
|
1910
|
+
return {
|
|
1911
|
+
kind: "result",
|
|
1912
|
+
result: {
|
|
1234
1913
|
type: "setInputFiles",
|
|
1235
1914
|
status: "pass",
|
|
1236
|
-
durationMs: Date.now() - stepStart,
|
|
1915
|
+
durationMs: Date.now() - h.stepStart,
|
|
1237
1916
|
selector: resolvedInput.selector,
|
|
1238
1917
|
...resolvedInput.healedFrom ? { healedFrom: resolvedInput.healedFrom } : {},
|
|
1239
1918
|
value: filesLabel
|
|
1240
|
-
};
|
|
1241
|
-
} else if ("runHunt" in step) {
|
|
1242
|
-
const huntName = typeof step.runHunt === "string" ? step.runHunt : step.runHunt.name;
|
|
1243
|
-
const overrideVars = typeof step.runHunt === "string" ? void 0 : step.runHunt.vars;
|
|
1244
|
-
const stack = context.huntStack ?? [];
|
|
1245
|
-
if (stack.includes(huntName)) {
|
|
1246
|
-
throw new Error(`Circular hunt dependency: ${[...stack, huntName].join(" \u2192 ")}`);
|
|
1247
|
-
}
|
|
1248
|
-
const subHunt = loadHunt(huntName, context.configDir);
|
|
1249
|
-
if (overrideVars) {
|
|
1250
|
-
subHunt.vars = { ...subHunt.vars, ...overrideVars };
|
|
1251
|
-
}
|
|
1252
|
-
const {
|
|
1253
|
-
hunt: interpolatedSubHunt,
|
|
1254
|
-
redactedFillSteps: subRedacted,
|
|
1255
|
-
randomVars
|
|
1256
|
-
} = interpolateHunt(
|
|
1257
|
-
subHunt,
|
|
1258
|
-
process.env,
|
|
1259
|
-
context.randomVars
|
|
1260
|
-
);
|
|
1261
|
-
assertWithinMaxSteps(interpolatedSubHunt.steps.length, context.maxSteps, huntName);
|
|
1262
|
-
const subResult = await executeNestedSteps(context, {
|
|
1263
|
-
steps: interpolatedSubHunt.steps,
|
|
1264
|
-
redactedFillSteps: subRedacted,
|
|
1265
|
-
randomVars,
|
|
1266
|
-
stepPathPrefix: void 0,
|
|
1267
|
-
huntStack: [...stack, huntName],
|
|
1268
|
-
onStep: context.onStep
|
|
1269
|
-
});
|
|
1270
|
-
for (const sr of subResult.results) {
|
|
1271
|
-
results.push({ ...sr, type: `${huntName} > ${sr.type}` });
|
|
1272
1919
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
},
|
|
1923
|
+
runHunt: {
|
|
1924
|
+
capabilities: [],
|
|
1925
|
+
run: async (h) => {
|
|
1926
|
+
if (!("runHunt" in h.step)) unknownStep();
|
|
1927
|
+
const huntName = typeof h.step.runHunt === "string" ? h.step.runHunt : h.step.runHunt.name;
|
|
1928
|
+
const overrideVars = typeof h.step.runHunt === "string" ? void 0 : h.step.runHunt.vars;
|
|
1929
|
+
const stack = h.context.huntStack ?? [];
|
|
1930
|
+
if (stack.includes(huntName)) {
|
|
1931
|
+
throw new Error(`Circular hunt dependency: ${[...stack, huntName].join(" \u2192 ")}`);
|
|
1932
|
+
}
|
|
1933
|
+
const subHunt = loadHunt(huntName, h.context.configDir);
|
|
1934
|
+
if (overrideVars) {
|
|
1935
|
+
subHunt.vars = { ...subHunt.vars, ...overrideVars };
|
|
1936
|
+
}
|
|
1937
|
+
const {
|
|
1938
|
+
hunt: interpolatedSubHunt,
|
|
1939
|
+
redactedFillSteps: subRedacted,
|
|
1940
|
+
randomVars
|
|
1941
|
+
} = interpolateHunt(subHunt, process.env, h.context.randomVars);
|
|
1942
|
+
const subTargetType = h.driver.capabilities.has("navigate") ? "web" : "macos";
|
|
1943
|
+
assertStepsSupportedByTarget(interpolatedSubHunt.steps, subTargetType);
|
|
1944
|
+
assertHuntAssertionsSupportedByTarget(interpolatedSubHunt.assertions, subTargetType);
|
|
1945
|
+
h.policy.assertWithinMaxSteps(interpolatedSubHunt.steps.length, huntName);
|
|
1946
|
+
const subResult = await h.executeNested({
|
|
1947
|
+
steps: interpolatedSubHunt.steps,
|
|
1948
|
+
redactedFillSteps: subRedacted,
|
|
1949
|
+
randomVars,
|
|
1950
|
+
stepPathPrefix: void 0,
|
|
1951
|
+
huntStack: [...stack, huntName],
|
|
1952
|
+
onStep: h.context.onStep
|
|
1953
|
+
});
|
|
1954
|
+
for (const sr of subResult.results) {
|
|
1955
|
+
h.results.push({ ...sr, type: `${huntName} > ${sr.type}` });
|
|
1956
|
+
}
|
|
1957
|
+
h.screenshots.push(...subResult.screenshots);
|
|
1958
|
+
if (subResult.failed) {
|
|
1959
|
+
return { kind: "abort", error: `Sub-hunt "${huntName}" failed: ${subResult.error}` };
|
|
1960
|
+
}
|
|
1961
|
+
return {
|
|
1962
|
+
kind: "result",
|
|
1963
|
+
result: { type: "runHunt", status: "pass", durationMs: Date.now() - h.stepStart, value: huntName }
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
},
|
|
1967
|
+
press: {
|
|
1968
|
+
capabilities: ["interact", "query"],
|
|
1969
|
+
run: async (h) => {
|
|
1970
|
+
if (!("press" in h.step)) unknownStep();
|
|
1971
|
+
const resolved = await h.policy.resolveActionSelector(h.step.press.selector);
|
|
1972
|
+
await h.driver.press(resolved.selector, h.step.press.key);
|
|
1973
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
1974
|
+
return {
|
|
1975
|
+
kind: "result",
|
|
1976
|
+
result: {
|
|
1293
1977
|
type: "press",
|
|
1294
1978
|
status: "pass",
|
|
1295
|
-
durationMs: Date.now() - stepStart,
|
|
1979
|
+
durationMs: Date.now() - h.stepStart,
|
|
1296
1980
|
selector: resolved.selector,
|
|
1297
1981
|
...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1982
|
+
}
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
},
|
|
1986
|
+
assert: {
|
|
1987
|
+
capabilities: ["query"],
|
|
1988
|
+
run: async (h) => {
|
|
1989
|
+
if (!("assert" in h.step)) unknownStep();
|
|
1990
|
+
const value = await runInlineAssert(h.driver, h.policy, h.step.assert);
|
|
1991
|
+
return {
|
|
1992
|
+
kind: "result",
|
|
1993
|
+
result: { type: "assert", status: "pass", durationMs: Date.now() - h.stepStart, value }
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
},
|
|
1997
|
+
wait: {
|
|
1998
|
+
capabilities: ["wait"],
|
|
1999
|
+
run: async (h) => {
|
|
2000
|
+
if (!("wait" in h.step)) unknownStep();
|
|
2001
|
+
const text = typeof h.step.wait === "string" ? h.step.wait : h.step.wait.for;
|
|
2002
|
+
const timeout = typeof h.step.wait === "string" ? void 0 : h.step.wait.timeout;
|
|
2003
|
+
const selector = `text=${escapeForText(text)}`;
|
|
2004
|
+
h.policy.assertAllowedSelector(selector);
|
|
2005
|
+
await h.driver.waitForSelector(selector, { timeout });
|
|
2006
|
+
return {
|
|
2007
|
+
kind: "result",
|
|
2008
|
+
result: { type: "wait", status: "pass", durationMs: Date.now() - h.stepStart, selector }
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
},
|
|
2012
|
+
waitForSelector: {
|
|
2013
|
+
capabilities: ["wait"],
|
|
2014
|
+
run: async (h) => {
|
|
2015
|
+
if (!("waitForSelector" in h.step)) unknownStep();
|
|
2016
|
+
h.policy.assertAllowedSelector(h.step.waitForSelector.selector);
|
|
2017
|
+
await h.driver.waitForSelector(h.step.waitForSelector.selector, {
|
|
2018
|
+
timeout: h.step.waitForSelector.timeout
|
|
2019
|
+
});
|
|
2020
|
+
return {
|
|
2021
|
+
kind: "result",
|
|
2022
|
+
result: {
|
|
1325
2023
|
type: "waitForSelector",
|
|
1326
2024
|
status: "pass",
|
|
1327
|
-
durationMs: Date.now() - stepStart,
|
|
1328
|
-
selector: step.waitForSelector.selector
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
2025
|
+
durationMs: Date.now() - h.stepStart,
|
|
2026
|
+
selector: h.step.waitForSelector.selector
|
|
2027
|
+
}
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
2030
|
+
},
|
|
2031
|
+
waitForUrl: {
|
|
2032
|
+
capabilities: ["navigate", "wait"],
|
|
2033
|
+
run: async (h) => {
|
|
2034
|
+
if (!("waitForUrl" in h.step)) unknownStep();
|
|
2035
|
+
const value = h.step.waitForUrl.value;
|
|
2036
|
+
await h.driver.waitForUrl((url) => url.includes(value), { timeout: h.step.waitForUrl.timeout });
|
|
2037
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2038
|
+
return {
|
|
2039
|
+
kind: "result",
|
|
2040
|
+
result: { type: "waitForUrl", status: "pass", durationMs: Date.now() - h.stepStart, value }
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
},
|
|
2044
|
+
waitForNetworkIdle: {
|
|
2045
|
+
capabilities: ["wait"],
|
|
2046
|
+
run: async (h) => {
|
|
2047
|
+
if (!("waitForNetworkIdle" in h.step)) unknownStep();
|
|
2048
|
+
await h.driver.waitForNetworkIdle({ timeout: h.step.waitForNetworkIdle.timeout });
|
|
2049
|
+
return {
|
|
2050
|
+
kind: "result",
|
|
2051
|
+
result: { type: "waitForNetworkIdle", status: "pass", durationMs: Date.now() - h.stepStart }
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
},
|
|
2055
|
+
hover: {
|
|
2056
|
+
capabilities: ["interact", "query"],
|
|
2057
|
+
run: async (h) => {
|
|
2058
|
+
if (!("hover" in h.step)) unknownStep();
|
|
2059
|
+
const resolved = await h.policy.resolveActionSelector(h.step.hover.selector);
|
|
2060
|
+
await h.driver.hover(resolved.selector);
|
|
2061
|
+
h.policy.ensureLocationAllowed(h.driver);
|
|
2062
|
+
return {
|
|
2063
|
+
kind: "result",
|
|
2064
|
+
result: {
|
|
1356
2065
|
type: "hover",
|
|
1357
2066
|
status: "pass",
|
|
1358
|
-
durationMs: Date.now() - stepStart,
|
|
2067
|
+
durationMs: Date.now() - h.stepStart,
|
|
1359
2068
|
selector: resolved.selector,
|
|
1360
2069
|
...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
2070
|
+
}
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
},
|
|
2074
|
+
scroll: {
|
|
2075
|
+
capabilities: ["evaluate"],
|
|
2076
|
+
run: async (h) => {
|
|
2077
|
+
if (!("scroll" in h.step)) unknownStep();
|
|
2078
|
+
const amount = h.step.scroll.amount ?? 500;
|
|
2079
|
+
const scrollMap = {
|
|
2080
|
+
up: [0, -amount],
|
|
2081
|
+
down: [0, amount],
|
|
2082
|
+
left: [-amount, 0],
|
|
2083
|
+
right: [amount, 0]
|
|
2084
|
+
};
|
|
2085
|
+
const [x, y] = scrollMap[h.step.scroll.direction];
|
|
2086
|
+
await h.driver.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
|
|
2087
|
+
return {
|
|
2088
|
+
kind: "result",
|
|
2089
|
+
result: {
|
|
1373
2090
|
type: "scroll",
|
|
1374
2091
|
status: "pass",
|
|
1375
|
-
durationMs: Date.now() - stepStart,
|
|
1376
|
-
value: `${step.scroll.direction} ${amount}px`
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
2092
|
+
durationMs: Date.now() - h.stepStart,
|
|
2093
|
+
value: `${h.step.scroll.direction} ${amount}px`
|
|
2094
|
+
}
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
},
|
|
2098
|
+
scrollTo: {
|
|
2099
|
+
capabilities: ["interact", "query"],
|
|
2100
|
+
run: async (h) => {
|
|
2101
|
+
if (!("scrollTo" in h.step)) unknownStep();
|
|
2102
|
+
const resolved = await h.policy.resolveActionSelector(h.step.scrollTo.selector);
|
|
2103
|
+
await h.driver.scrollIntoView(resolved.selector);
|
|
2104
|
+
return {
|
|
2105
|
+
kind: "result",
|
|
2106
|
+
result: {
|
|
1382
2107
|
type: "scrollTo",
|
|
1383
2108
|
status: "pass",
|
|
1384
|
-
durationMs: Date.now() - stepStart,
|
|
2109
|
+
durationMs: Date.now() - h.stepStart,
|
|
1385
2110
|
selector: resolved.selector,
|
|
1386
2111
|
...resolved.healedFrom ? { healedFrom: resolved.healedFrom } : {}
|
|
2112
|
+
}
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
},
|
|
2116
|
+
screenshot: {
|
|
2117
|
+
capabilities: ["screenshot"],
|
|
2118
|
+
run: async (h) => {
|
|
2119
|
+
if (!("screenshot" in h.step)) unknownStep();
|
|
2120
|
+
const name = h.step.screenshot.name ?? `manual_step_${h.index + 1}.png`;
|
|
2121
|
+
if (/[/\\]|\.\./.test(name)) {
|
|
2122
|
+
throw new Error(`Invalid screenshot name: "${name}" must not contain path separators or ".."`);
|
|
2123
|
+
}
|
|
2124
|
+
const fileName = name.endsWith(".png") ? name : `${name}.png`;
|
|
2125
|
+
const relative = await h.addScreenshot(fileName);
|
|
2126
|
+
return {
|
|
2127
|
+
kind: "result",
|
|
2128
|
+
result: { type: "screenshot", status: "pass", durationMs: Date.now() - h.stepStart, screenshot: relative }
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
},
|
|
2132
|
+
if: {
|
|
2133
|
+
capabilities: ["query"],
|
|
2134
|
+
run: async (h) => {
|
|
2135
|
+
if (!("if" in h.step)) unknownStep();
|
|
2136
|
+
const condition = h.step.if;
|
|
2137
|
+
const selector = condition.visible ?? condition.notVisible;
|
|
2138
|
+
h.policy.assertAllowedSelector(selector);
|
|
2139
|
+
const count = await h.driver.count(selector);
|
|
2140
|
+
const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
|
|
2141
|
+
if (conditionMet) {
|
|
2142
|
+
const subResult = await h.executeNested({
|
|
2143
|
+
steps: condition.then,
|
|
2144
|
+
stepPathPrefix: `${h.stepPath}.if.then`
|
|
2145
|
+
});
|
|
2146
|
+
for (const sr of subResult.results) {
|
|
2147
|
+
h.results.push({ ...sr, type: `if > ${sr.type}` });
|
|
2148
|
+
}
|
|
2149
|
+
h.screenshots.push(...subResult.screenshots);
|
|
2150
|
+
if (subResult.failed) {
|
|
2151
|
+
return { kind: "abort", error: subResult.error };
|
|
2152
|
+
}
|
|
2153
|
+
return {
|
|
2154
|
+
kind: "result",
|
|
2155
|
+
result: {
|
|
2156
|
+
type: "if",
|
|
2157
|
+
status: "pass",
|
|
2158
|
+
durationMs: Date.now() - h.stepStart,
|
|
2159
|
+
value: `condition met, executed ${condition.then.length} steps`
|
|
2160
|
+
}
|
|
1387
2161
|
};
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
2162
|
+
}
|
|
2163
|
+
if (condition.else && condition.else.length > 0) {
|
|
2164
|
+
const subResult = await h.executeNested({
|
|
2165
|
+
steps: condition.else,
|
|
2166
|
+
stepPathPrefix: `${h.stepPath}.if.else`
|
|
2167
|
+
});
|
|
2168
|
+
for (const sr of subResult.results) {
|
|
2169
|
+
h.results.push({ ...sr, type: `if > ${sr.type}` });
|
|
1392
2170
|
}
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
2171
|
+
h.screenshots.push(...subResult.screenshots);
|
|
2172
|
+
if (subResult.failed) {
|
|
2173
|
+
return { kind: "abort", error: subResult.error };
|
|
2174
|
+
}
|
|
2175
|
+
return {
|
|
2176
|
+
kind: "result",
|
|
2177
|
+
result: {
|
|
2178
|
+
type: "if",
|
|
2179
|
+
status: "pass",
|
|
2180
|
+
durationMs: Date.now() - h.stepStart,
|
|
2181
|
+
value: `condition not met, executed ${condition.else.length} else steps`
|
|
2182
|
+
}
|
|
1400
2183
|
};
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
2184
|
+
}
|
|
2185
|
+
return {
|
|
2186
|
+
kind: "result",
|
|
2187
|
+
result: {
|
|
2188
|
+
type: "if",
|
|
2189
|
+
status: "pass",
|
|
2190
|
+
durationMs: Date.now() - h.stepStart,
|
|
2191
|
+
value: "condition not met, skipped"
|
|
2192
|
+
}
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
},
|
|
2196
|
+
repeat: {
|
|
2197
|
+
capabilities: ["query"],
|
|
2198
|
+
run: async (h) => {
|
|
2199
|
+
if (!("repeat" in h.step)) unknownStep();
|
|
2200
|
+
const repeat = h.step.repeat;
|
|
2201
|
+
let totalSubSteps = 0;
|
|
2202
|
+
if (repeat.times !== void 0) {
|
|
2203
|
+
const totalPlanned = repeat.times * repeat.steps.length;
|
|
2204
|
+
if (totalPlanned + totalSubSteps > h.context.maxSteps) {
|
|
2205
|
+
throw new Error(`Repeat exceeded maxSteps guardrail (${h.context.maxSteps})`);
|
|
2206
|
+
}
|
|
2207
|
+
for (let i = 0; i < repeat.times; i++) {
|
|
2208
|
+
totalSubSteps += repeat.steps.length;
|
|
2209
|
+
const subResult = await h.executeNested({
|
|
2210
|
+
steps: repeat.steps,
|
|
2211
|
+
stepPathPrefix: `${h.stepPath}.repeat.steps`
|
|
1411
2212
|
});
|
|
1412
2213
|
for (const sr of subResult.results) {
|
|
1413
|
-
results.push({ ...sr, type: `
|
|
2214
|
+
h.results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
|
|
1414
2215
|
}
|
|
1415
|
-
screenshots.push(...subResult.screenshots);
|
|
2216
|
+
h.screenshots.push(...subResult.screenshots);
|
|
1416
2217
|
if (subResult.failed) {
|
|
1417
|
-
return {
|
|
1418
|
-
results,
|
|
1419
|
-
screenshots,
|
|
1420
|
-
failed: true,
|
|
1421
|
-
error: subResult.error
|
|
1422
|
-
};
|
|
1423
|
-
}
|
|
1424
|
-
stepResult = {
|
|
1425
|
-
type: "if",
|
|
1426
|
-
status: "pass",
|
|
1427
|
-
durationMs: Date.now() - stepStart,
|
|
1428
|
-
value: `condition met, executed ${condition.then.length} steps`
|
|
1429
|
-
};
|
|
1430
|
-
} else {
|
|
1431
|
-
if (condition.else && condition.else.length > 0) {
|
|
1432
|
-
const subResult = await executeNestedSteps(context, {
|
|
1433
|
-
steps: condition.else,
|
|
1434
|
-
stepPathPrefix: `${currentStepPath}.if.else`
|
|
1435
|
-
});
|
|
1436
|
-
for (const sr of subResult.results) {
|
|
1437
|
-
results.push({ ...sr, type: `if > ${sr.type}` });
|
|
1438
|
-
}
|
|
1439
|
-
screenshots.push(...subResult.screenshots);
|
|
1440
|
-
if (subResult.failed) {
|
|
1441
|
-
return {
|
|
1442
|
-
results,
|
|
1443
|
-
screenshots,
|
|
1444
|
-
failed: true,
|
|
1445
|
-
error: subResult.error
|
|
1446
|
-
};
|
|
1447
|
-
}
|
|
1448
|
-
stepResult = {
|
|
1449
|
-
type: "if",
|
|
1450
|
-
status: "pass",
|
|
1451
|
-
durationMs: Date.now() - stepStart,
|
|
1452
|
-
value: `condition not met, executed ${condition.else.length} else steps`
|
|
1453
|
-
};
|
|
1454
|
-
} else {
|
|
1455
|
-
stepResult = {
|
|
1456
|
-
type: "if",
|
|
1457
|
-
status: "pass",
|
|
1458
|
-
durationMs: Date.now() - stepStart,
|
|
1459
|
-
value: "condition not met, skipped"
|
|
1460
|
-
};
|
|
2218
|
+
return { kind: "abort", error: subResult.error };
|
|
1461
2219
|
}
|
|
1462
2220
|
}
|
|
1463
|
-
} else if (
|
|
1464
|
-
const
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
2221
|
+
} else if (repeat.while !== void 0) {
|
|
2222
|
+
const maxIter = repeat.maxIterations;
|
|
2223
|
+
const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
|
|
2224
|
+
h.policy.assertAllowedSelector(whileSelector);
|
|
2225
|
+
for (let i = 0; i < maxIter; i++) {
|
|
2226
|
+
const whileCount = await h.driver.count(whileSelector);
|
|
2227
|
+
const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
|
|
2228
|
+
if (!shouldContinue) break;
|
|
2229
|
+
totalSubSteps += repeat.steps.length;
|
|
2230
|
+
if (totalSubSteps > h.context.maxSteps) {
|
|
2231
|
+
throw new Error(`Repeat exceeded maxSteps guardrail (${h.context.maxSteps})`);
|
|
1470
2232
|
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
});
|
|
1477
|
-
for (const sr of subResult.results) {
|
|
1478
|
-
results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
|
|
1479
|
-
}
|
|
1480
|
-
screenshots.push(...subResult.screenshots);
|
|
1481
|
-
if (subResult.failed) {
|
|
1482
|
-
return {
|
|
1483
|
-
results,
|
|
1484
|
-
screenshots,
|
|
1485
|
-
failed: true,
|
|
1486
|
-
error: subResult.error
|
|
1487
|
-
};
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
} else if (repeat.while !== void 0) {
|
|
1491
|
-
const maxIter = repeat.maxIterations;
|
|
1492
|
-
const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
|
|
1493
|
-
assertAllowedSelector(whileSelector, context.forbiddenSelectors);
|
|
1494
|
-
for (let i = 0; i < maxIter; i++) {
|
|
1495
|
-
const whileCount = await context.page.locator(whileSelector).count();
|
|
1496
|
-
const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
|
|
1497
|
-
if (!shouldContinue) break;
|
|
1498
|
-
totalSubSteps += repeat.steps.length;
|
|
1499
|
-
if (totalSubSteps > context.maxSteps) {
|
|
1500
|
-
throw new Error(`Repeat exceeded maxSteps guardrail (${context.maxSteps})`);
|
|
1501
|
-
}
|
|
1502
|
-
const subResult = await executeNestedSteps(context, {
|
|
1503
|
-
steps: repeat.steps,
|
|
1504
|
-
stepPathPrefix: `${currentStepPath}.repeat.steps`
|
|
1505
|
-
});
|
|
1506
|
-
for (const sr of subResult.results) {
|
|
1507
|
-
results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
|
|
1508
|
-
}
|
|
1509
|
-
screenshots.push(...subResult.screenshots);
|
|
1510
|
-
if (subResult.failed) {
|
|
1511
|
-
return {
|
|
1512
|
-
results,
|
|
1513
|
-
screenshots,
|
|
1514
|
-
failed: true,
|
|
1515
|
-
error: subResult.error
|
|
1516
|
-
};
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
}
|
|
1520
|
-
stepResult = {
|
|
1521
|
-
type: "repeat",
|
|
1522
|
-
status: "pass",
|
|
1523
|
-
durationMs: Date.now() - stepStart
|
|
1524
|
-
};
|
|
1525
|
-
} else if ("mockRoute" in step) {
|
|
1526
|
-
const mock = step.mockRoute;
|
|
1527
|
-
const mocks = context.activeMocks ?? /* @__PURE__ */ new Map();
|
|
1528
|
-
context.activeMocks = mocks;
|
|
1529
|
-
let responseBody;
|
|
1530
|
-
if (mock.response.body !== void 0) {
|
|
1531
|
-
responseBody = mock.response.body;
|
|
1532
|
-
} else {
|
|
1533
|
-
const responseFile = mock.response.file;
|
|
1534
|
-
if (!responseFile) {
|
|
1535
|
-
throw new Error("mock.response must include either body or file");
|
|
2233
|
+
const subResult = await h.executeNested({
|
|
2234
|
+
steps: repeat.steps,
|
|
2235
|
+
stepPathPrefix: `${h.stepPath}.repeat.steps`
|
|
2236
|
+
});
|
|
2237
|
+
for (const sr of subResult.results) {
|
|
2238
|
+
h.results.push({ ...sr, type: `repeat[${i}] > ${sr.type}` });
|
|
1536
2239
|
}
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
const relativePath = path3.relative(resolvedConfigDir, resolvedFilePath);
|
|
1541
|
-
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${path3.sep}`) && !path3.isAbsolute(relativePath);
|
|
1542
|
-
if (!isWithinConfigDir) {
|
|
1543
|
-
throw new Error("mock.response.file must resolve within config directory");
|
|
2240
|
+
h.screenshots.push(...subResult.screenshots);
|
|
2241
|
+
if (subResult.failed) {
|
|
2242
|
+
return { kind: "abort", error: subResult.error };
|
|
1544
2243
|
}
|
|
1545
|
-
responseBody = await fs3.promises.readFile(resolvedFilePath, "utf-8");
|
|
1546
2244
|
}
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
if (!mocks || !mocks.has(url)) {
|
|
1569
|
-
throw new Error(`No active mock for URL: ${url}`);
|
|
2245
|
+
}
|
|
2246
|
+
return {
|
|
2247
|
+
kind: "result",
|
|
2248
|
+
result: { type: "repeat", status: "pass", durationMs: Date.now() - h.stepStart }
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
},
|
|
2252
|
+
mockRoute: {
|
|
2253
|
+
capabilities: ["route"],
|
|
2254
|
+
run: async (h) => {
|
|
2255
|
+
if (!("mockRoute" in h.step)) unknownStep();
|
|
2256
|
+
const mock = h.step.mockRoute;
|
|
2257
|
+
const mocks = h.context.activeMocks ?? /* @__PURE__ */ new Map();
|
|
2258
|
+
h.context.activeMocks = mocks;
|
|
2259
|
+
let responseBody;
|
|
2260
|
+
if (mock.response.body !== void 0) {
|
|
2261
|
+
responseBody = mock.response.body;
|
|
2262
|
+
} else {
|
|
2263
|
+
const responseFile = mock.response.file;
|
|
2264
|
+
if (!responseFile) {
|
|
2265
|
+
throw new Error("mock.response must include either body or file");
|
|
1570
2266
|
}
|
|
1571
|
-
const
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
value: url
|
|
1579
|
-
};
|
|
1580
|
-
} else if ("evalScript" in step) {
|
|
1581
|
-
const expression = typeof step.evalScript === "string" ? step.evalScript : step.evalScript.expression;
|
|
1582
|
-
const result = await context.page.evaluate(expression);
|
|
1583
|
-
const resultStr = String(result);
|
|
1584
|
-
if (typeof step.evalScript !== "string" && step.evalScript.as) {
|
|
1585
|
-
runtimeVars.set(step.evalScript.as, resultStr);
|
|
2267
|
+
const candidateFilePath = path5.isAbsolute(responseFile) ? responseFile : path5.join(h.context.configDir, responseFile);
|
|
2268
|
+
const resolvedConfigDir = path5.resolve(h.context.configDir);
|
|
2269
|
+
const resolvedFilePath = path5.resolve(candidateFilePath);
|
|
2270
|
+
const relativePath = path5.relative(resolvedConfigDir, resolvedFilePath);
|
|
2271
|
+
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${path5.sep}`) && !path5.isAbsolute(relativePath);
|
|
2272
|
+
if (!isWithinConfigDir) {
|
|
2273
|
+
throw new Error("mock.response.file must resolve within config directory");
|
|
1586
2274
|
}
|
|
1587
|
-
|
|
2275
|
+
responseBody = await fs5.promises.readFile(resolvedFilePath, "utf-8");
|
|
2276
|
+
}
|
|
2277
|
+
const contentType = mock.response.contentType ?? "application/json";
|
|
2278
|
+
const status = mock.response.status;
|
|
2279
|
+
await h.driver.route(mock.url, async (route) => {
|
|
2280
|
+
await route.fulfill({
|
|
2281
|
+
status,
|
|
2282
|
+
contentType,
|
|
2283
|
+
body: responseBody
|
|
2284
|
+
});
|
|
2285
|
+
});
|
|
2286
|
+
mocks.set(mock.url, async () => {
|
|
2287
|
+
await h.driver.unroute(mock.url);
|
|
2288
|
+
});
|
|
2289
|
+
return {
|
|
2290
|
+
kind: "result",
|
|
2291
|
+
result: { type: "mockRoute", status: "pass", durationMs: Date.now() - h.stepStart, value: mock.url }
|
|
2292
|
+
};
|
|
2293
|
+
}
|
|
2294
|
+
},
|
|
2295
|
+
unmockRoute: {
|
|
2296
|
+
capabilities: ["route"],
|
|
2297
|
+
run: async (h) => {
|
|
2298
|
+
if (!("unmockRoute" in h.step)) unknownStep();
|
|
2299
|
+
const url = typeof h.step.unmockRoute === "string" ? h.step.unmockRoute : h.step.unmockRoute.url;
|
|
2300
|
+
const mocks = h.context.activeMocks;
|
|
2301
|
+
if (!mocks || !mocks.has(url)) {
|
|
2302
|
+
throw new Error(`No active mock for URL: ${url}`);
|
|
2303
|
+
}
|
|
2304
|
+
const cleanup = mocks.get(url);
|
|
2305
|
+
await cleanup();
|
|
2306
|
+
mocks.delete(url);
|
|
2307
|
+
return {
|
|
2308
|
+
kind: "result",
|
|
2309
|
+
result: { type: "unmockRoute", status: "pass", durationMs: Date.now() - h.stepStart, value: url }
|
|
2310
|
+
};
|
|
2311
|
+
}
|
|
2312
|
+
},
|
|
2313
|
+
evalScript: {
|
|
2314
|
+
capabilities: ["evaluate"],
|
|
2315
|
+
run: async (h) => {
|
|
2316
|
+
if (!("evalScript" in h.step)) unknownStep();
|
|
2317
|
+
const expression = typeof h.step.evalScript === "string" ? h.step.evalScript : h.step.evalScript.expression;
|
|
2318
|
+
const result = await h.driver.evaluate(expression);
|
|
2319
|
+
const resultStr = String(result);
|
|
2320
|
+
if (typeof h.step.evalScript !== "string" && h.step.evalScript.as) {
|
|
2321
|
+
h.runtimeVars.set(h.step.evalScript.as, resultStr);
|
|
2322
|
+
}
|
|
2323
|
+
return {
|
|
2324
|
+
kind: "result",
|
|
2325
|
+
result: {
|
|
1588
2326
|
type: "evalScript",
|
|
1589
2327
|
status: "pass",
|
|
1590
|
-
durationMs: Date.now() - stepStart,
|
|
2328
|
+
durationMs: Date.now() - h.stepStart,
|
|
1591
2329
|
value: resultStr.length > 200 ? resultStr.slice(0, 200) + "\u2026" : resultStr
|
|
1592
|
-
}
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
2330
|
+
}
|
|
2331
|
+
};
|
|
2332
|
+
}
|
|
2333
|
+
},
|
|
2334
|
+
runScript: {
|
|
2335
|
+
capabilities: ["evaluate"],
|
|
2336
|
+
run: async (h) => {
|
|
2337
|
+
if (!("runScript" in h.step)) unknownStep();
|
|
2338
|
+
const filePath = path5.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : path5.join(h.context.configDir, h.step.runScript.file);
|
|
2339
|
+
const fileContents = fs5.readFileSync(filePath, "utf-8");
|
|
2340
|
+
await h.driver.evaluate(fileContents);
|
|
2341
|
+
return {
|
|
2342
|
+
kind: "result",
|
|
2343
|
+
result: {
|
|
1598
2344
|
type: "runScript",
|
|
1599
2345
|
status: "pass",
|
|
1600
|
-
durationMs: Date.now() - stepStart,
|
|
1601
|
-
value: step.runScript.file
|
|
2346
|
+
durationMs: Date.now() - h.stepStart,
|
|
2347
|
+
value: h.step.runScript.file
|
|
2348
|
+
}
|
|
2349
|
+
};
|
|
2350
|
+
}
|
|
2351
|
+
},
|
|
2352
|
+
assertScreenshot: {
|
|
2353
|
+
capabilities: ["screenshot"],
|
|
2354
|
+
run: async (h) => {
|
|
2355
|
+
if (!("assertScreenshot" in h.step)) unknownStep();
|
|
2356
|
+
const { compareScreenshots, ensureBaselineDir } = await import("./visual-FSARM2JS.js");
|
|
2357
|
+
const name = h.step.assertScreenshot.name;
|
|
2358
|
+
const threshold = h.step.assertScreenshot.threshold ?? 0.1;
|
|
2359
|
+
const baselineDir = ensureBaselineDir(h.context.configDir);
|
|
2360
|
+
const baselinePath = path5.join(baselineDir, `${name}.png`);
|
|
2361
|
+
const currentScreenshotPath = path5.join(h.context.runDir, "screenshots", `${name}-current.png`);
|
|
2362
|
+
fs5.mkdirSync(path5.dirname(currentScreenshotPath), { recursive: true });
|
|
2363
|
+
await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
|
|
2364
|
+
h.screenshots.push(path5.join("screenshots", `${name}-current.png`));
|
|
2365
|
+
if (!fs5.existsSync(baselinePath)) {
|
|
2366
|
+
fs5.copyFileSync(currentScreenshotPath, baselinePath);
|
|
2367
|
+
return {
|
|
2368
|
+
kind: "result",
|
|
2369
|
+
result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
|
|
1602
2370
|
};
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
fs3.mkdirSync(path3.dirname(currentScreenshotPath), { recursive: true });
|
|
1611
|
-
await context.page.screenshot({ path: currentScreenshotPath, fullPage: true });
|
|
1612
|
-
screenshots.push(path3.join("screenshots", `${name}-current.png`));
|
|
1613
|
-
if (!fs3.existsSync(baselinePath)) {
|
|
1614
|
-
fs3.copyFileSync(currentScreenshotPath, baselinePath);
|
|
1615
|
-
stepResult = {
|
|
2371
|
+
}
|
|
2372
|
+
const diffPath = path5.join(h.context.runDir, "screenshots", `${name}-diff.png`);
|
|
2373
|
+
const comparison = await compareScreenshots(baselinePath, currentScreenshotPath, diffPath, threshold);
|
|
2374
|
+
if (comparison.match) {
|
|
2375
|
+
return {
|
|
2376
|
+
kind: "result",
|
|
2377
|
+
result: {
|
|
1616
2378
|
type: "assertScreenshot",
|
|
1617
2379
|
status: "pass",
|
|
1618
|
-
durationMs: Date.now() - stepStart,
|
|
1619
|
-
value:
|
|
1620
|
-
};
|
|
1621
|
-
} else {
|
|
1622
|
-
const diffPath = path3.join(context.runDir, "screenshots", `${name}-diff.png`);
|
|
1623
|
-
const comparison = await compareScreenshots(baselinePath, currentScreenshotPath, diffPath, threshold);
|
|
1624
|
-
if (comparison.match) {
|
|
1625
|
-
stepResult = {
|
|
1626
|
-
type: "assertScreenshot",
|
|
1627
|
-
status: "pass",
|
|
1628
|
-
durationMs: Date.now() - stepStart,
|
|
1629
|
-
value: `diff: ${(comparison.diffPercentage * 100).toFixed(2)}%`
|
|
1630
|
-
};
|
|
1631
|
-
} else {
|
|
1632
|
-
screenshots.push(path3.join("screenshots", `${name}-diff.png`));
|
|
1633
|
-
throw new Error(
|
|
1634
|
-
`Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
|
|
1635
|
-
);
|
|
2380
|
+
durationMs: Date.now() - h.stepStart,
|
|
2381
|
+
value: `diff: ${(comparison.diffPercentage * 100).toFixed(2)}%`
|
|
1636
2382
|
}
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
2385
|
+
h.screenshots.push(path5.join("screenshots", `${name}-diff.png`));
|
|
2386
|
+
throw new Error(
|
|
2387
|
+
`Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
|
|
2388
|
+
);
|
|
2389
|
+
}
|
|
2390
|
+
},
|
|
2391
|
+
copyText: {
|
|
2392
|
+
capabilities: ["query"],
|
|
2393
|
+
run: async (h) => {
|
|
2394
|
+
if (!("copyText" in h.step)) unknownStep();
|
|
2395
|
+
h.policy.assertAllowedSelector(h.step.copyText.selector);
|
|
2396
|
+
const text = await h.driver.textContent(h.step.copyText.selector);
|
|
2397
|
+
if (text === null) {
|
|
2398
|
+
throw new Error(`No text content found for selector: ${h.step.copyText.selector}`);
|
|
2399
|
+
}
|
|
2400
|
+
h.runtimeVars.set(h.step.copyText.as, text);
|
|
2401
|
+
return {
|
|
2402
|
+
kind: "result",
|
|
2403
|
+
result: {
|
|
1646
2404
|
type: "copyText",
|
|
1647
2405
|
status: "pass",
|
|
1648
|
-
durationMs: Date.now() - stepStart,
|
|
1649
|
-
selector: step.copyText.selector,
|
|
2406
|
+
durationMs: Date.now() - h.stepStart,
|
|
2407
|
+
selector: h.step.copyText.selector,
|
|
1650
2408
|
value: "[REDACTED]"
|
|
1651
|
-
};
|
|
1652
|
-
} else if ("waitForDownload" in step) {
|
|
1653
|
-
const opts = step.waitForDownload;
|
|
1654
|
-
const downloadPromise = context.pendingDownload ?? armDownloadListener(
|
|
1655
|
-
context.page,
|
|
1656
|
-
opts?.timeout ?? 3e4
|
|
1657
|
-
);
|
|
1658
|
-
context.pendingDownload = void 0;
|
|
1659
|
-
const download = await downloadPromise;
|
|
1660
|
-
const suggestedFilename = validateDownloadFilename(download.suggestedFilename());
|
|
1661
|
-
if (opts?.filename !== void 0 && suggestedFilename !== opts.filename) {
|
|
1662
|
-
throw new Error(
|
|
1663
|
-
`Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
|
|
1664
|
-
);
|
|
1665
2409
|
}
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
2410
|
+
};
|
|
2411
|
+
}
|
|
2412
|
+
},
|
|
2413
|
+
waitForDownload: {
|
|
2414
|
+
capabilities: ["download"],
|
|
2415
|
+
run: async (h) => {
|
|
2416
|
+
if (!("waitForDownload" in h.step)) unknownStep();
|
|
2417
|
+
const opts = h.step.waitForDownload;
|
|
2418
|
+
const downloadPromise = h.context.pendingDownload ?? armDownloadListener(h.driver, opts?.timeout ?? 3e4);
|
|
2419
|
+
h.context.pendingDownload = void 0;
|
|
2420
|
+
const download = await downloadPromise;
|
|
2421
|
+
const suggestedFilename = validateDownloadFilename(download.suggestedFilename());
|
|
2422
|
+
if (opts?.filename !== void 0 && suggestedFilename !== opts.filename) {
|
|
2423
|
+
throw new Error(
|
|
2424
|
+
`Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
2427
|
+
const savePath = path5.join(h.context.runDir, suggestedFilename);
|
|
2428
|
+
await download.saveAs(savePath);
|
|
2429
|
+
return {
|
|
2430
|
+
kind: "result",
|
|
2431
|
+
result: {
|
|
1669
2432
|
type: "waitForDownload",
|
|
1670
2433
|
status: "pass",
|
|
1671
|
-
durationMs: Date.now() - stepStart,
|
|
2434
|
+
durationMs: Date.now() - h.stepStart,
|
|
1672
2435
|
value: suggestedFilename
|
|
1673
|
-
}
|
|
1674
|
-
}
|
|
1675
|
-
|
|
2436
|
+
}
|
|
2437
|
+
};
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
};
|
|
2441
|
+
async function executeSteps(context) {
|
|
2442
|
+
let driver = context.driver;
|
|
2443
|
+
if (!driver) {
|
|
2444
|
+
if (!context.page) {
|
|
2445
|
+
throw new Error("executeSteps requires a driver or a Playwright page");
|
|
2446
|
+
}
|
|
2447
|
+
driver = createPlaywrightDriver(context.page);
|
|
2448
|
+
}
|
|
2449
|
+
const policy = createRunPolicy(driver, {
|
|
2450
|
+
forbiddenSelectors: context.forbiddenSelectors,
|
|
2451
|
+
allowedDomains: context.allowedDomains,
|
|
2452
|
+
allowedApps: context.allowedApps,
|
|
2453
|
+
maxSteps: context.maxSteps,
|
|
2454
|
+
selfHealing: context.selfHealing
|
|
2455
|
+
});
|
|
2456
|
+
const screenshotsDir = path5.join(context.runDir, "screenshots");
|
|
2457
|
+
fs5.mkdirSync(screenshotsDir, { recursive: true });
|
|
2458
|
+
const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
|
|
2459
|
+
policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
|
|
2460
|
+
const results = [];
|
|
2461
|
+
const screenshots = [];
|
|
2462
|
+
const runStartedAtMs = context.runStartedAtMs ?? Date.now();
|
|
2463
|
+
context.runStartedAtMs = runStartedAtMs;
|
|
2464
|
+
const addScreenshot = async (fileName) => {
|
|
2465
|
+
const fullPath = screenshotPath(screenshotsDir, fileName);
|
|
2466
|
+
await captureScreenshot(driver, fullPath);
|
|
2467
|
+
const relative = path5.join("screenshots", fileName);
|
|
2468
|
+
screenshots.push(relative);
|
|
2469
|
+
return relative;
|
|
2470
|
+
};
|
|
2471
|
+
const executeNested = (overrides) => executeNestedSteps(context, { driver, ...overrides });
|
|
2472
|
+
for (let index = 0; index < context.steps.length; index += 1) {
|
|
2473
|
+
const currentStepPath = stepPath(context.stepPathPrefix, index);
|
|
2474
|
+
if (Date.now() - runStartedAtMs > context.maxTotalTimeMs) {
|
|
2475
|
+
results.push({
|
|
2476
|
+
type: "timeout",
|
|
2477
|
+
status: "fail",
|
|
2478
|
+
durationMs: 0,
|
|
2479
|
+
error: `Max total time exceeded (${context.maxTotalTimeMs}ms)`
|
|
2480
|
+
});
|
|
2481
|
+
return { results, screenshots, failed: true, error: "Max total time exceeded" };
|
|
2482
|
+
}
|
|
2483
|
+
const runtimeVars = context.runtimeVars ?? /* @__PURE__ */ new Map();
|
|
2484
|
+
context.runtimeVars = runtimeVars;
|
|
2485
|
+
let step = context.steps[index];
|
|
2486
|
+
if (runtimeVars.size > 0) {
|
|
2487
|
+
step = applyRuntimeVars(step, runtimeVars);
|
|
2488
|
+
}
|
|
2489
|
+
const nextStep = context.steps[index + 1];
|
|
2490
|
+
if (!isWaitForDownloadStep(step) && context.pendingDownload === void 0 && isWaitForDownloadStep(nextStep)) {
|
|
2491
|
+
context.pendingDownload = armDownloadListener(
|
|
2492
|
+
driver,
|
|
2493
|
+
nextStep.waitForDownload?.timeout ?? 3e4
|
|
2494
|
+
);
|
|
2495
|
+
}
|
|
2496
|
+
const stepStart = Date.now();
|
|
2497
|
+
const stepType = getStepType(step);
|
|
2498
|
+
let stepResult = null;
|
|
2499
|
+
try {
|
|
2500
|
+
const handler = STEP_HANDLERS[stepType];
|
|
2501
|
+
if (!handler) {
|
|
1676
2502
|
throw new Error("Unknown step type");
|
|
1677
2503
|
}
|
|
2504
|
+
for (const capability of handler.capabilities) {
|
|
2505
|
+
if (!driver.capabilities.has(capability)) {
|
|
2506
|
+
throw new Error(
|
|
2507
|
+
`Driver does not support capability "${capability}" required by step "${stepType}"`
|
|
2508
|
+
);
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
const outcome = await handler.run({
|
|
2512
|
+
driver,
|
|
2513
|
+
policy,
|
|
2514
|
+
context,
|
|
2515
|
+
step,
|
|
2516
|
+
index,
|
|
2517
|
+
stepPath: currentStepPath,
|
|
2518
|
+
stepStart,
|
|
2519
|
+
runtimeVars,
|
|
2520
|
+
results,
|
|
2521
|
+
screenshots,
|
|
2522
|
+
addScreenshot,
|
|
2523
|
+
executeNested
|
|
2524
|
+
});
|
|
2525
|
+
if (outcome.kind === "abort") {
|
|
2526
|
+
return { results, screenshots, failed: true, error: outcome.error };
|
|
2527
|
+
}
|
|
2528
|
+
stepResult = outcome.result;
|
|
1678
2529
|
if (context.screenshotsMode === "all" && stepResult.type !== "screenshot") {
|
|
1679
2530
|
const fileName = `step_${index + 1}.png`;
|
|
1680
2531
|
await addScreenshot(fileName);
|
|
@@ -1701,12 +2552,12 @@ async function executeSteps(context) {
|
|
|
1701
2552
|
return { results, screenshots, failed: false };
|
|
1702
2553
|
}
|
|
1703
2554
|
async function captureFinalScreenshot(page, runDir) {
|
|
1704
|
-
const screenshotsDir =
|
|
1705
|
-
|
|
2555
|
+
const screenshotsDir = path5.join(runDir, "screenshots");
|
|
2556
|
+
fs5.mkdirSync(screenshotsDir, { recursive: true });
|
|
1706
2557
|
const fileName = "final.png";
|
|
1707
2558
|
const filePath = screenshotPath(screenshotsDir, fileName);
|
|
1708
2559
|
await captureScreenshot(page, filePath);
|
|
1709
|
-
return
|
|
2560
|
+
return path5.join("screenshots", fileName);
|
|
1710
2561
|
}
|
|
1711
2562
|
|
|
1712
2563
|
// src/runner/assertions.ts
|
|
@@ -1866,18 +2717,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
|
|
|
1866
2717
|
}
|
|
1867
2718
|
|
|
1868
2719
|
// src/reporter/result.ts
|
|
1869
|
-
import
|
|
1870
|
-
import
|
|
2720
|
+
import fs6 from "fs";
|
|
2721
|
+
import path6 from "path";
|
|
1871
2722
|
function writeResult(runDir, result) {
|
|
1872
2723
|
const fileName = "result.json";
|
|
1873
|
-
const fullPath =
|
|
1874
|
-
|
|
2724
|
+
const fullPath = path6.join(runDir, fileName);
|
|
2725
|
+
fs6.writeFileSync(fullPath, JSON.stringify(result, null, 2));
|
|
1875
2726
|
return fileName;
|
|
1876
2727
|
}
|
|
1877
2728
|
|
|
1878
2729
|
// src/reporter/summary.ts
|
|
1879
|
-
import
|
|
1880
|
-
import
|
|
2730
|
+
import fs7 from "fs";
|
|
2731
|
+
import path7 from "path";
|
|
1881
2732
|
function escapeMd(text) {
|
|
1882
2733
|
return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
|
|
1883
2734
|
}
|
|
@@ -1955,15 +2806,15 @@ function writeSummary(runDir, result) {
|
|
|
1955
2806
|
}
|
|
1956
2807
|
}
|
|
1957
2808
|
const fileName = "summary.md";
|
|
1958
|
-
const fullPath =
|
|
1959
|
-
|
|
2809
|
+
const fullPath = path7.join(runDir, fileName);
|
|
2810
|
+
fs7.writeFileSync(fullPath, `${lines.join("\n")}
|
|
1960
2811
|
`);
|
|
1961
2812
|
return fileName;
|
|
1962
2813
|
}
|
|
1963
2814
|
|
|
1964
2815
|
// src/reporter/junit.ts
|
|
1965
|
-
import
|
|
1966
|
-
import
|
|
2816
|
+
import fs8 from "fs";
|
|
2817
|
+
import path8 from "path";
|
|
1967
2818
|
function escapeXml(text) {
|
|
1968
2819
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1969
2820
|
}
|
|
@@ -2007,8 +2858,8 @@ function writeJunit(runDir, result) {
|
|
|
2007
2858
|
lines.push(" </testsuite>");
|
|
2008
2859
|
lines.push("</testsuites>");
|
|
2009
2860
|
const fileName = "junit.xml";
|
|
2010
|
-
const fullPath =
|
|
2011
|
-
|
|
2861
|
+
const fullPath = path8.join(runDir, fileName);
|
|
2862
|
+
fs8.writeFileSync(fullPath, `${lines.join("\n")}
|
|
2012
2863
|
`);
|
|
2013
2864
|
return fileName;
|
|
2014
2865
|
}
|
|
@@ -2050,11 +2901,11 @@ function parseViewportFlag(value) {
|
|
|
2050
2901
|
return value;
|
|
2051
2902
|
}
|
|
2052
2903
|
function resolvePath(configDir, inputPath) {
|
|
2053
|
-
if (
|
|
2904
|
+
if (path9.isAbsolute(inputPath)) {
|
|
2054
2905
|
return inputPath;
|
|
2055
2906
|
}
|
|
2056
|
-
const projectRoot =
|
|
2057
|
-
return
|
|
2907
|
+
const projectRoot = path9.dirname(configDir);
|
|
2908
|
+
return path9.join(projectRoot, inputPath);
|
|
2058
2909
|
}
|
|
2059
2910
|
function buildRunResult(options) {
|
|
2060
2911
|
return {
|
|
@@ -2073,12 +2924,12 @@ function buildRunResult(options) {
|
|
|
2073
2924
|
}
|
|
2074
2925
|
function writeConsoleLog(runDir, entries) {
|
|
2075
2926
|
const fileName = "console.log";
|
|
2076
|
-
const filePath =
|
|
2927
|
+
const filePath = path9.join(runDir, fileName);
|
|
2077
2928
|
const lines = entries.map((entry) => {
|
|
2078
2929
|
const location = entry.location ? ` (${entry.location})` : "";
|
|
2079
2930
|
return `[${entry.type}] ${entry.text}${location}`;
|
|
2080
2931
|
});
|
|
2081
|
-
|
|
2932
|
+
fs9.writeFileSync(filePath, `${lines.join("\n")}
|
|
2082
2933
|
`);
|
|
2083
2934
|
return fileName;
|
|
2084
2935
|
}
|
|
@@ -2086,8 +2937,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
2086
2937
|
const headless = options.headed ? false : config.browser.headless;
|
|
2087
2938
|
const slowMo = options.slowMo ?? config.browser.slowMo;
|
|
2088
2939
|
const maxSteps = config.guardrails.maxSteps;
|
|
2089
|
-
const runDir =
|
|
2090
|
-
|
|
2940
|
+
const runDir = path9.join(configDir, "runs", timestamp());
|
|
2941
|
+
fs9.mkdirSync(runDir, { recursive: true });
|
|
2091
2942
|
const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
|
|
2092
2943
|
const engine = options.browser ?? config.browser.engine;
|
|
2093
2944
|
const channel = options.channel ?? config.browser.channel;
|
|
@@ -2106,6 +2957,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
2106
2957
|
});
|
|
2107
2958
|
let result;
|
|
2108
2959
|
try {
|
|
2960
|
+
const driver = createPlaywrightDriver(session.page);
|
|
2109
2961
|
const consoleEntries = [];
|
|
2110
2962
|
const networkEntries = [];
|
|
2111
2963
|
const traceCorrelations = [];
|
|
@@ -2117,7 +2969,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
2117
2969
|
location: message.location().url
|
|
2118
2970
|
});
|
|
2119
2971
|
});
|
|
2120
|
-
|
|
2972
|
+
driver.onResponse((response) => {
|
|
2121
2973
|
if (response.status() >= 400) {
|
|
2122
2974
|
networkEntries.push({ url: response.url(), status: response.status() });
|
|
2123
2975
|
captureTraceCorrelation(response, traceHeader, traceCorrelations, redactionValues);
|
|
@@ -2131,6 +2983,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
2131
2983
|
try {
|
|
2132
2984
|
const stepExecution = await executeSteps({
|
|
2133
2985
|
page: session.page,
|
|
2986
|
+
driver,
|
|
2134
2987
|
steps: interpolatedHunt.steps,
|
|
2135
2988
|
targetUrl,
|
|
2136
2989
|
runDir,
|
|
@@ -2163,7 +3016,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
2163
3016
|
}
|
|
2164
3017
|
let finalScreenshot;
|
|
2165
3018
|
try {
|
|
2166
|
-
finalScreenshot = await captureFinalScreenshot(
|
|
3019
|
+
finalScreenshot = await captureFinalScreenshot(driver, runDir);
|
|
2167
3020
|
} catch {
|
|
2168
3021
|
finalScreenshot = void 0;
|
|
2169
3022
|
}
|
|
@@ -2207,6 +3060,9 @@ function delay(ms) {
|
|
|
2207
3060
|
}
|
|
2208
3061
|
async function runHunt(options) {
|
|
2209
3062
|
const { config, configDir } = loadConfig(options.configPath);
|
|
3063
|
+
if (config.target.type === "macos") {
|
|
3064
|
+
return runMacHunt(options, config, configDir, config.target);
|
|
3065
|
+
}
|
|
2210
3066
|
const hunt = loadHunt(options.huntName, configDir);
|
|
2211
3067
|
const {
|
|
2212
3068
|
hunt: interpolatedHunt,
|
|
@@ -2257,9 +3113,124 @@ async function runHunt(options) {
|
|
|
2257
3113
|
}
|
|
2258
3114
|
return lastResult;
|
|
2259
3115
|
}
|
|
3116
|
+
async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
3117
|
+
const maxSteps = config.guardrails.maxSteps;
|
|
3118
|
+
const runDir = path9.join(configDir, "runs", timestamp());
|
|
3119
|
+
fs9.mkdirSync(runDir, { recursive: true });
|
|
3120
|
+
const session = await launchMacSession({
|
|
3121
|
+
app: target.app,
|
|
3122
|
+
timeoutMs: config.browser.timeout,
|
|
3123
|
+
clientFactory: options.macClientFactory
|
|
3124
|
+
});
|
|
3125
|
+
let result;
|
|
3126
|
+
try {
|
|
3127
|
+
const targetLabel = `macos:${session.bundleId}`;
|
|
3128
|
+
const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, target.app, session.bundleId])];
|
|
3129
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3130
|
+
const startTime = Date.now();
|
|
3131
|
+
let stepResults = [];
|
|
3132
|
+
let stepScreenshots = [];
|
|
3133
|
+
let stepFailed = false;
|
|
3134
|
+
try {
|
|
3135
|
+
const stepExecution = await executeSteps({
|
|
3136
|
+
driver: session.driver,
|
|
3137
|
+
steps: interpolatedHunt.steps,
|
|
3138
|
+
targetUrl: targetLabel,
|
|
3139
|
+
runDir,
|
|
3140
|
+
screenshotsMode: config.artifacts.screenshots,
|
|
3141
|
+
forbiddenSelectors: config.guardrails.forbiddenSelectors,
|
|
3142
|
+
allowedDomains: [],
|
|
3143
|
+
allowedApps: effectiveAllowedApps,
|
|
3144
|
+
maxSteps,
|
|
3145
|
+
maxTotalTimeMs: config.assertions.maxTotalTimeMs,
|
|
3146
|
+
selfHealing: config.guardrails.selfHealing,
|
|
3147
|
+
redactedFillSteps,
|
|
3148
|
+
randomVars,
|
|
3149
|
+
configDir,
|
|
3150
|
+
huntStack: [options.huntName],
|
|
3151
|
+
onStep: options.onStep
|
|
3152
|
+
});
|
|
3153
|
+
stepResults = stepExecution.results;
|
|
3154
|
+
stepScreenshots = stepExecution.screenshots;
|
|
3155
|
+
stepFailed = stepExecution.failed;
|
|
3156
|
+
} catch (error) {
|
|
3157
|
+
const message = error instanceof Error ? error.message : "Step execution failed";
|
|
3158
|
+
stepResults = [{ type: "steps", status: "fail", durationMs: 0, error: message }];
|
|
3159
|
+
stepFailed = true;
|
|
3160
|
+
}
|
|
3161
|
+
let finalScreenshot;
|
|
3162
|
+
try {
|
|
3163
|
+
finalScreenshot = await captureFinalScreenshot(session.driver, runDir);
|
|
3164
|
+
} catch {
|
|
3165
|
+
finalScreenshot = void 0;
|
|
3166
|
+
}
|
|
3167
|
+
const durationMs = Date.now() - startTime;
|
|
3168
|
+
const status = stepFailed ? "fail" : "pass";
|
|
3169
|
+
const artifacts = {
|
|
3170
|
+
screenshots: finalScreenshot ? [...stepScreenshots, finalScreenshot] : stepScreenshots
|
|
3171
|
+
};
|
|
3172
|
+
const runResult = buildRunResult({
|
|
3173
|
+
status,
|
|
3174
|
+
startedAt,
|
|
3175
|
+
durationMs,
|
|
3176
|
+
hunt: options.huntName,
|
|
3177
|
+
targetUrl: targetLabel,
|
|
3178
|
+
steps: stepResults,
|
|
3179
|
+
assertions: [],
|
|
3180
|
+
artifacts
|
|
3181
|
+
});
|
|
3182
|
+
result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
|
|
3183
|
+
} finally {
|
|
3184
|
+
await closeMacSession(session);
|
|
3185
|
+
}
|
|
3186
|
+
return { result, runDir, steps: interpolatedHunt.steps };
|
|
3187
|
+
}
|
|
3188
|
+
async function runMacHunt(options, config, configDir, target) {
|
|
3189
|
+
const hunt = loadHunt(options.huntName, configDir);
|
|
3190
|
+
const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
|
|
3191
|
+
assertStepsSupportedByTarget(interpolatedHunt.steps, "macos");
|
|
3192
|
+
assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, "macos");
|
|
3193
|
+
assertTargetAppAllowed(config.guardrails.allowedApps, target.app);
|
|
3194
|
+
const maxSteps = config.guardrails.maxSteps;
|
|
3195
|
+
if (interpolatedHunt.steps.length > maxSteps) {
|
|
3196
|
+
throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
|
|
3197
|
+
}
|
|
3198
|
+
const maxRetries = hunt.retry?.maxRetries ?? 0;
|
|
3199
|
+
const retryDelay = hunt.retry?.delay ?? 0;
|
|
3200
|
+
let lastResult;
|
|
3201
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3202
|
+
if (attempt > 0 && retryDelay > 0) {
|
|
3203
|
+
await delay(retryDelay);
|
|
3204
|
+
}
|
|
3205
|
+
lastResult = await executeMacHuntAttempt(
|
|
3206
|
+
options,
|
|
3207
|
+
config,
|
|
3208
|
+
configDir,
|
|
3209
|
+
target,
|
|
3210
|
+
interpolatedHunt,
|
|
3211
|
+
redactedFillSteps,
|
|
3212
|
+
randomVars,
|
|
3213
|
+
config.guardrails.allowedApps
|
|
3214
|
+
);
|
|
3215
|
+
if (lastResult.result.status === "pass") {
|
|
3216
|
+
if (attempt > 0) {
|
|
3217
|
+
lastResult.result.artifacts.summary = `Passed on attempt ${attempt + 1} of ${maxRetries + 1}`;
|
|
3218
|
+
}
|
|
3219
|
+
recordHistory(configDir, lastResult, config.history.maxRuns);
|
|
3220
|
+
return lastResult;
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
if (maxRetries > 0 && lastResult) {
|
|
3224
|
+
lastResult.result.artifacts.summary = `Failed after ${maxRetries + 1} attempts`;
|
|
3225
|
+
}
|
|
3226
|
+
if (lastResult) {
|
|
3227
|
+
recordHistory(configDir, lastResult, config.history.maxRuns);
|
|
3228
|
+
}
|
|
3229
|
+
return lastResult;
|
|
3230
|
+
}
|
|
2260
3231
|
function recordHistory(configDir, outcome, maxRuns) {
|
|
2261
3232
|
try {
|
|
2262
|
-
const relativeRunDir =
|
|
3233
|
+
const relativeRunDir = path9.relative(configDir, outcome.runDir);
|
|
2263
3234
|
appendEntry(
|
|
2264
3235
|
configDir,
|
|
2265
3236
|
{
|
|
@@ -2380,8 +3351,8 @@ function clusterFailures(failures) {
|
|
|
2380
3351
|
}
|
|
2381
3352
|
|
|
2382
3353
|
// src/backlog/index.ts
|
|
2383
|
-
import
|
|
2384
|
-
import
|
|
3354
|
+
import fs10 from "fs";
|
|
3355
|
+
import path10 from "path";
|
|
2385
3356
|
|
|
2386
3357
|
// src/backlog/parse.ts
|
|
2387
3358
|
var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
|
|
@@ -2471,7 +3442,7 @@ ${after}`;
|
|
|
2471
3442
|
// src/backlog/index.ts
|
|
2472
3443
|
function readFileOrEmpty(filePath) {
|
|
2473
3444
|
try {
|
|
2474
|
-
return
|
|
3445
|
+
return fs10.readFileSync(filePath, "utf-8");
|
|
2475
3446
|
} catch (error) {
|
|
2476
3447
|
const err = error;
|
|
2477
3448
|
if (err.code === "ENOENT") return "";
|
|
@@ -2487,7 +3458,7 @@ function buildFailure(hunt) {
|
|
|
2487
3458
|
if (!hunt.runDir) return failure;
|
|
2488
3459
|
let run;
|
|
2489
3460
|
try {
|
|
2490
|
-
const resultJson = readFileOrEmpty(
|
|
3461
|
+
const resultJson = readFileOrEmpty(path10.join(hunt.runDir, "result.json"));
|
|
2491
3462
|
if (!resultJson) return failure;
|
|
2492
3463
|
run = JSON.parse(resultJson);
|
|
2493
3464
|
} catch (error) {
|
|
@@ -2516,8 +3487,8 @@ function extractFailures(suiteResult) {
|
|
|
2516
3487
|
}
|
|
2517
3488
|
function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
2518
3489
|
const projectRoot = options.projectRoot ?? process.cwd();
|
|
2519
|
-
const backlogPath = options.backlogPath ??
|
|
2520
|
-
const resolvedPath = options.resolvedPath ??
|
|
3490
|
+
const backlogPath = options.backlogPath ?? path10.join(projectRoot, "docs", "backlog.md");
|
|
3491
|
+
const resolvedPath = options.resolvedPath ?? path10.join(projectRoot, "docs", "resolved.md");
|
|
2521
3492
|
const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2522
3493
|
const summary = { created: [], regressions: [], skipped: [], backlogPath };
|
|
2523
3494
|
const failures = extractFailures(suiteResult);
|
|
@@ -2549,18 +3520,18 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
|
2549
3520
|
}
|
|
2550
3521
|
}
|
|
2551
3522
|
if (ticketsToAdd.length > 0) {
|
|
2552
|
-
|
|
2553
|
-
|
|
3523
|
+
fs10.mkdirSync(path10.dirname(backlogPath), { recursive: true });
|
|
3524
|
+
fs10.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
|
|
2554
3525
|
}
|
|
2555
3526
|
return summary;
|
|
2556
3527
|
}
|
|
2557
3528
|
|
|
2558
3529
|
// src/runner/suite.ts
|
|
2559
|
-
import
|
|
3530
|
+
import path12 from "path";
|
|
2560
3531
|
|
|
2561
3532
|
// src/reporter/ci-summary.ts
|
|
2562
|
-
import
|
|
2563
|
-
import
|
|
3533
|
+
import fs11 from "fs";
|
|
3534
|
+
import path11 from "path";
|
|
2564
3535
|
import chalk from "chalk";
|
|
2565
3536
|
function countCiResults(results) {
|
|
2566
3537
|
return {
|
|
@@ -2624,9 +3595,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
|
|
|
2624
3595
|
...flaky.length > 0 ? { flaky } : {},
|
|
2625
3596
|
...clusters.length > 0 ? { clusters } : {}
|
|
2626
3597
|
};
|
|
2627
|
-
|
|
2628
|
-
const filePath =
|
|
2629
|
-
|
|
3598
|
+
fs11.mkdirSync(ciRunDir, { recursive: true });
|
|
3599
|
+
const filePath = path11.join(ciRunDir, "ci-result.json");
|
|
3600
|
+
fs11.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
|
|
2630
3601
|
return filePath;
|
|
2631
3602
|
}
|
|
2632
3603
|
|
|
@@ -2811,7 +3782,7 @@ async function runSuite(options = {}) {
|
|
|
2811
3782
|
const clusters = clusterFailures(
|
|
2812
3783
|
extractFailures({ result: { hunts: results }, resultPath: null })
|
|
2813
3784
|
).filter((cluster) => cluster.count > 1);
|
|
2814
|
-
const ciRunDir =
|
|
3785
|
+
const ciRunDir = path12.join(configDir, "runs", timestamp("ci"));
|
|
2815
3786
|
const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
|
|
2816
3787
|
const { passed, failed, skipped } = countCiResults(results);
|
|
2817
3788
|
return {
|
|
@@ -2948,7 +3919,20 @@ async function analyzePage(page) {
|
|
|
2948
3919
|
|
|
2949
3920
|
// src/generator/index.ts
|
|
2950
3921
|
import yaml from "yaml";
|
|
2951
|
-
|
|
3922
|
+
|
|
3923
|
+
// src/browser/engines.ts
|
|
3924
|
+
function formatSupportedBrowserEngines() {
|
|
3925
|
+
return SUPPORTED_BROWSER_ENGINES.join(", ");
|
|
3926
|
+
}
|
|
3927
|
+
function parseBrowserEngine(value, fallback = "chromium") {
|
|
3928
|
+
if (value === void 0 || value.length === 0) {
|
|
3929
|
+
return fallback;
|
|
3930
|
+
}
|
|
3931
|
+
if (SUPPORTED_BROWSER_ENGINES.includes(value)) {
|
|
3932
|
+
return value;
|
|
3933
|
+
}
|
|
3934
|
+
throw new Error(`Unsupported browser engine "${value}". Use ${formatSupportedBrowserEngines()}.`);
|
|
3935
|
+
}
|
|
2952
3936
|
|
|
2953
3937
|
// src/generator/prompt.ts
|
|
2954
3938
|
var STEP_REFERENCE = `
|
|
@@ -3110,18 +4094,34 @@ async function generateWithOpenAi(prompt, config) {
|
|
|
3110
4094
|
}
|
|
3111
4095
|
|
|
3112
4096
|
// src/generator/index.ts
|
|
4097
|
+
function parseViewportFlag2(value) {
|
|
4098
|
+
const match = /^(\d+)x(\d+)$/i.exec(value);
|
|
4099
|
+
if (match) {
|
|
4100
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
4101
|
+
}
|
|
4102
|
+
return value;
|
|
4103
|
+
}
|
|
3113
4104
|
async function generateHunt(options) {
|
|
3114
4105
|
let analysis = options.analysis;
|
|
3115
4106
|
if (!analysis && options.url) {
|
|
3116
|
-
const
|
|
3117
|
-
const
|
|
3118
|
-
const
|
|
4107
|
+
const engine = parseBrowserEngine(options.browser);
|
|
4108
|
+
const viewport = options.viewport ? resolveViewport(parseViewportFlag2(options.viewport)) : resolveViewport(void 0);
|
|
4109
|
+
const session = await launchBrowser({
|
|
4110
|
+
headless: true,
|
|
4111
|
+
slowMo: 0,
|
|
4112
|
+
timeout: 3e4,
|
|
4113
|
+
trace: false,
|
|
4114
|
+
recordHar: false,
|
|
4115
|
+
runDir: process.cwd(),
|
|
4116
|
+
engine,
|
|
4117
|
+
viewport
|
|
4118
|
+
});
|
|
4119
|
+
const driver = createPlaywrightDriver(session.page);
|
|
3119
4120
|
try {
|
|
3120
|
-
await
|
|
3121
|
-
analysis = await analyzePage(
|
|
4121
|
+
await driver.goto(options.url, { waitUntil: "networkidle" });
|
|
4122
|
+
analysis = await analyzePage(driver);
|
|
3122
4123
|
} finally {
|
|
3123
|
-
await
|
|
3124
|
-
await browser.close();
|
|
4124
|
+
await closeBrowser(session);
|
|
3125
4125
|
}
|
|
3126
4126
|
}
|
|
3127
4127
|
if (!analysis) {
|
|
@@ -3138,6 +4138,22 @@ async function generateHunt(options) {
|
|
|
3138
4138
|
|
|
3139
4139
|
export {
|
|
3140
4140
|
interpolateHunt,
|
|
4141
|
+
WEB_ONLY_STEP_TYPES,
|
|
4142
|
+
webOnlyReason,
|
|
4143
|
+
assertStepsSupportedByTarget,
|
|
4144
|
+
assertTargetAppAllowed,
|
|
4145
|
+
launchBrowser,
|
|
4146
|
+
closeBrowser,
|
|
4147
|
+
saveStorageState,
|
|
4148
|
+
createPlaywrightDriver,
|
|
4149
|
+
parseMacSelector,
|
|
4150
|
+
createMacDriver,
|
|
4151
|
+
macdriverBuildInstructions,
|
|
4152
|
+
resolveHelperBinary,
|
|
4153
|
+
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
4154
|
+
SpawnMacHelperClient,
|
|
4155
|
+
launchMacSession,
|
|
4156
|
+
closeMacSession,
|
|
3141
4157
|
extractSelectorIntent,
|
|
3142
4158
|
buildHealCandidates,
|
|
3143
4159
|
healSelector,
|
|
@@ -3152,7 +4168,8 @@ export {
|
|
|
3152
4168
|
extractFailures,
|
|
3153
4169
|
updateBacklogFromSuite,
|
|
3154
4170
|
runSuite,
|
|
4171
|
+
parseBrowserEngine,
|
|
3155
4172
|
analyzePage,
|
|
3156
4173
|
generateHunt
|
|
3157
4174
|
};
|
|
3158
|
-
//# sourceMappingURL=chunk-
|
|
4175
|
+
//# sourceMappingURL=chunk-ZEFVTKQT.js.map
|