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.
@@ -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-NXXGJOBG.js";
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 num = Math.floor(random() * 9e3) + 1e3;
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(num),
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(page, selector, options) {
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 = page.locator(candidate.selector);
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 fs from "fs";
433
- import path from "path";
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 path.join(configDir, HISTORY_FILE);
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 (!fs.existsSync(filePath)) {
964
+ if (!fs3.existsSync(filePath)) {
452
965
  return { entries: [] };
453
966
  }
454
967
  try {
455
- const raw = fs.readFileSync(filePath, "utf-8");
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
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
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 = fs.openSync(lockPath, "wx");
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
- fs.closeSync(fd);
1024
+ fs3.closeSync(fd);
512
1025
  } catch {
513
1026
  }
514
- fs.rmSync(lockPath, { force: true });
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
- fs.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
1040
+ fs3.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
528
1041
  `);
529
- fs.renameSync(tempPath, filePath);
1042
+ fs3.renameSync(tempPath, filePath);
530
1043
  });
531
1044
  }
532
1045
 
533
1046
  // src/runner/index.ts
534
- import fs7 from "fs";
535
- import path7 from "path";
1047
+ import fs9 from "fs";
1048
+ import path9 from "path";
536
1049
 
537
- // src/browser/controller.ts
538
- import fs2 from "fs";
539
- import path2 from "path";
540
- import { chromium, firefox, webkit } from "playwright";
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 engine = ENGINES[options.engine ?? "chromium"];
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
- const contextOptions = {};
550
- if (options.viewport) {
551
- contextOptions.viewport = options.viewport;
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
- if (session.tracePath) {
576
- await session.context.tracing.stop({ path: session.tracePath });
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 fillElement(page, selector, value) {
591
- await page.locator(selector).fill(value);
1116
+ async function saveStorageState(session, storageStatePath) {
1117
+ await session.context.storageState({ path: storageStatePath });
592
1118
  }
593
- async function pressKey(page, selector, key) {
594
- await page.locator(selector).press(key);
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
- async function selectOption(page, selector, value) {
597
- await page.locator(selector).selectOption(value);
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 setupDialogHandler(page, action) {
600
- page.once("dialog", async (dialog) => {
601
- if (action === "accept") {
602
- await dialog.accept();
603
- } else {
604
- await dialog.dismiss();
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 unwrapTextSelector(value) {
615
- const trimmed = value.trim();
616
- if (trimmed.startsWith('text="') && trimmed.endsWith('"')) {
617
- return trimmed.slice(6, -1);
618
- }
619
- if (trimmed.startsWith("text='") && trimmed.endsWith("'")) {
620
- return trimmed.slice(6, -1);
621
- }
622
- if (trimmed.startsWith("text=")) {
623
- return trimmed.slice(5);
624
- }
625
- return null;
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
- const forbiddenText = unwrapTextSelector(forbidden);
633
- if (forbiddenText !== null) {
634
- return selectorText.includes(forbiddenText);
1290
+ function isForbiddenSelector(selector) {
1291
+ return forbiddenSelectors.some(
1292
+ (forbidden) => selector.includes(forbidden) || matchesForbiddenPattern(selector, forbidden)
1293
+ );
635
1294
  }
636
- return selectorText.includes(forbidden);
637
- }
638
- function isForbiddenSelector(selector, forbiddenSelectors) {
639
- return forbiddenSelectors.some(
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
- async function resolveActionSelector(context, selector) {
649
- assertAllowedSelector(selector, context.forbiddenSelectors);
650
- if (!context.selfHealing) {
651
- return { selector };
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
- let matched = false;
654
- try {
655
- matched = await context.page.locator(selector).count() > 0;
656
- } catch {
657
- return { selector };
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
- if (matched) {
660
- return { selector };
1324
+ function ensureAppAllowed(app) {
1325
+ if (!allowedApps.includes(app)) {
1326
+ throw new Error(`Interaction with disallowed app: ${app}`);
1327
+ }
661
1328
  }
662
- const healed = await healSelector(context.page, selector, { enabled: true });
663
- if (!healed) {
664
- return { selector };
1329
+ function ensureLocationAllowed(activeDriver) {
1330
+ if (activeDriver.capabilities.has("navigate")) {
1331
+ ensureUrlAllowed(activeDriver.currentUrl());
1332
+ }
665
1333
  }
666
- assertAllowedSelector(healed.selector, context.forbiddenSelectors);
667
- console.warn(
668
- `Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
669
- );
670
- return { selector: healed.selector, healedFrom: healed.healedFrom };
671
- }
672
- function assertWithinMaxSteps(stepCount, maxSteps, huntName) {
673
- if (stepCount > maxSteps) {
674
- if (huntName) {
675
- throw new Error(`Hunt "${huntName}" has ${stepCount} steps. Max allowed is ${maxSteps}.`);
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
- throw new Error(`Hunt has ${stepCount} steps. Max allowed is ${maxSteps}.`);
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(page, text, forbiddenSelectors) {
1501
+ async function clickByTextWithFallback(driver, policy, text) {
821
1502
  const roleSelector = `role=button[name="${escapeForAttribute(text)}"]`;
822
- assertAllowedSelector(roleSelector, forbiddenSelectors);
823
- const button = page.getByRole("button", { name: text });
824
- if (await button.count()) {
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, forbiddenSelectors);
830
- await page.locator(selector).first().click();
1509
+ policy.assertAllowedSelector(selector);
1510
+ await driver.clickFirst(selector);
831
1511
  return selector;
832
1512
  }
833
- async function fillByLabelOrPlaceholder(page, label, value, forbiddenSelectors) {
1513
+ async function fillByLabelOrPlaceholder(driver, policy, label, value) {
834
1514
  const labelSelector = `label="${escapeForAttribute(label)}"`;
835
- assertAllowedSelector(labelSelector, forbiddenSelectors);
836
- const byLabel = page.getByLabel(label, { exact: true });
837
- if (await byLabel.count()) {
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, forbiddenSelectors);
843
- const byPlaceholder = page.locator(placeholder);
844
- if (await byPlaceholder.count()) {
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(page, label, value, forbiddenSelectors) {
1528
+ async function selectByLabelOrFallback(driver, policy, label, value) {
851
1529
  const labelSelector = `label="${escapeForAttribute(label)}"`;
852
- assertAllowedSelector(labelSelector, forbiddenSelectors);
853
- const byLabel = page.getByLabel(label, { exact: true });
854
- if (await byLabel.count()) {
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, forbiddenSelectors);
860
- const byAria = page.locator(ariaSelector);
861
- if (await byAria.count()) {
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, forbiddenSelectors);
867
- const byPlaceholder = page.locator(placeholderSelector);
868
- if (await byPlaceholder.count()) {
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(page, assertion, forbiddenSelectors) {
1672
+ async function runInlineAssert(driver, policy, assertion) {
998
1673
  if (assertion.visible !== void 0) {
999
1674
  const selector = toVisibilitySelector(assertion.visible);
1000
- assertAllowedSelector(selector, forbiddenSelectors);
1001
- const count = await page.locator(selector).count();
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, forbiddenSelectors);
1010
- const count = await page.locator(selector).count();
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 = page.url();
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 = page.url();
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 path3.join(screenshotsDir, fileName);
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(page, timeout) {
1042
- const downloadPromise = page.waitForEvent("download", { timeout });
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 !== path3.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(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(page, filePath) {
1730
+ async function captureScreenshot(taker, filePath) {
1056
1731
  try {
1057
- await page.screenshot({ path: filePath, fullPage: true });
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
- async function executeSteps(context) {
1077
- const screenshotsDir = path3.join(context.runDir, "screenshots");
1078
- fs3.mkdirSync(screenshotsDir, { recursive: true });
1079
- const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
1080
- assertWithinMaxSteps(context.steps.length, context.maxSteps, currentHuntName);
1081
- const results = [];
1082
- const screenshots = [];
1083
- const runStartedAtMs = context.runStartedAtMs ?? Date.now();
1084
- context.runStartedAtMs = runStartedAtMs;
1085
- const addScreenshot = async (fileName) => {
1086
- const fullPath = screenshotPath(screenshotsDir, fileName);
1087
- await captureScreenshot(context.page, fullPath);
1088
- const relative = path3.join("screenshots", fileName);
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
- const stepStart = Date.now();
1117
- const stepType = getStepType(step);
1118
- let stepResult = null;
1119
- try {
1120
- if ("navigate" in step) {
1121
- const destination = resolveNavigationTarget(context.targetUrl, step.navigate);
1122
- ensureAllowedUrl(destination, context.allowedDomains);
1123
- await context.page.goto(destination);
1124
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1125
- stepResult = { type: "navigate", status: "pass", durationMs: Date.now() - stepStart };
1126
- } else if ("click" in step) {
1127
- let selector;
1128
- let healedFrom;
1129
- if (typeof step.click === "string") {
1130
- selector = await clickByTextWithFallback(
1131
- context.page,
1132
- step.click,
1133
- context.forbiddenSelectors
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
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1170
- stepResult = {
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(currentStepPath) ? "[REDACTED]" : value,
1819
+ value: h.context.redactedFillSteps.has(h.stepPath) ? "[REDACTED]" : value,
1176
1820
  ...healedFrom ? { healedFrom } : {}
1177
- };
1178
- } else if ("type" in step) {
1179
- assertAllowedSelector(":focus", context.forbiddenSelectors);
1180
- await fillElement(context.page, ":focus", step.type);
1181
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1182
- stepResult = {
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(currentStepPath) ? "[REDACTED]" : step.type
1188
- };
1189
- } else if ("selectOption" in step) {
1190
- const resolved = await resolveActionSelector(context, step.selectOption.selector);
1191
- await selectOption(context.page, resolved.selector, step.selectOption.value);
1192
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1193
- stepResult = {
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
- } else if ("select" in step) {
1202
- const [label, value] = getSinglePair(step.select, "select");
1203
- const selector = await selectByLabelOrFallback(
1204
- context.page,
1205
- label,
1206
- value,
1207
- context.forbiddenSelectors
1208
- );
1209
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1210
- stepResult = {
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
- } else if ("onDialog" in step) {
1218
- setupDialogHandler(context.page, step.onDialog.action);
1219
- stepResult = {
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
- } else if ("setInputFiles" in step) {
1226
- const resolvedInput = await resolveActionSelector(context, step.setInputFiles.selector);
1227
- const rawFiles = step.setInputFiles.files;
1228
- const resolveFile = (f) => path3.isAbsolute(f) ? f : path3.join(context.configDir, f);
1229
- const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
1230
- await setInputFiles(context.page, resolvedInput.selector, resolvedFiles);
1231
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1232
- const filesLabel = Array.isArray(rawFiles) ? rawFiles.join(", ") : rawFiles;
1233
- stepResult = {
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
- screenshots.push(...subResult.screenshots);
1274
- if (subResult.failed) {
1275
- return {
1276
- results,
1277
- screenshots,
1278
- failed: true,
1279
- error: `Sub-hunt "${huntName}" failed: ${subResult.error}`
1280
- };
1281
- }
1282
- stepResult = {
1283
- type: "runHunt",
1284
- status: "pass",
1285
- durationMs: Date.now() - stepStart,
1286
- value: huntName
1287
- };
1288
- } else if ("press" in step) {
1289
- const resolved = await resolveActionSelector(context, step.press.selector);
1290
- await pressKey(context.page, resolved.selector, step.press.key);
1291
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1292
- stepResult = {
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
- } else if ("assert" in step) {
1300
- const value = await runInlineAssert(context.page, step.assert, context.forbiddenSelectors);
1301
- stepResult = {
1302
- type: "assert",
1303
- status: "pass",
1304
- durationMs: Date.now() - stepStart,
1305
- value
1306
- };
1307
- } else if ("wait" in step) {
1308
- const text = typeof step.wait === "string" ? step.wait : step.wait.for;
1309
- const timeout = typeof step.wait === "string" ? void 0 : step.wait.timeout;
1310
- const selector = `text=${escapeForText(text)}`;
1311
- assertAllowedSelector(selector, context.forbiddenSelectors);
1312
- await context.page.waitForSelector(selector, { timeout });
1313
- stepResult = {
1314
- type: "wait",
1315
- status: "pass",
1316
- durationMs: Date.now() - stepStart,
1317
- selector
1318
- };
1319
- } else if ("waitForSelector" in step) {
1320
- assertAllowedSelector(step.waitForSelector.selector, context.forbiddenSelectors);
1321
- await context.page.waitForSelector(step.waitForSelector.selector, {
1322
- timeout: step.waitForSelector.timeout
1323
- });
1324
- stepResult = {
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
- } else if ("waitForUrl" in step) {
1331
- await context.page.waitForURL(
1332
- (url) => url.toString().includes(step.waitForUrl.value),
1333
- { timeout: step.waitForUrl.timeout }
1334
- );
1335
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1336
- stepResult = {
1337
- type: "waitForUrl",
1338
- status: "pass",
1339
- durationMs: Date.now() - stepStart,
1340
- value: step.waitForUrl.value
1341
- };
1342
- } else if ("waitForNetworkIdle" in step) {
1343
- await context.page.waitForLoadState("networkidle", {
1344
- timeout: step.waitForNetworkIdle.timeout
1345
- });
1346
- stepResult = {
1347
- type: "waitForNetworkIdle",
1348
- status: "pass",
1349
- durationMs: Date.now() - stepStart
1350
- };
1351
- } else if ("hover" in step) {
1352
- const resolved = await resolveActionSelector(context, step.hover.selector);
1353
- await context.page.locator(resolved.selector).hover();
1354
- ensureAllowedUrl(context.page.url(), context.allowedDomains);
1355
- stepResult = {
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
- } else if ("scroll" in step) {
1363
- const amount = step.scroll.amount ?? 500;
1364
- const scrollMap = {
1365
- up: [0, -amount],
1366
- down: [0, amount],
1367
- left: [-amount, 0],
1368
- right: [amount, 0]
1369
- };
1370
- const [x, y] = scrollMap[step.scroll.direction];
1371
- await context.page.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
1372
- stepResult = {
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
- } else if ("scrollTo" in step) {
1379
- const resolved = await resolveActionSelector(context, step.scrollTo.selector);
1380
- await context.page.locator(resolved.selector).scrollIntoViewIfNeeded();
1381
- stepResult = {
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
- } else if ("screenshot" in step) {
1389
- const name = step.screenshot.name ?? `manual_step_${index + 1}.png`;
1390
- if (/[/\\]|\.\./.test(name)) {
1391
- throw new Error(`Invalid screenshot name: "${name}" must not contain path separators or ".."`);
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
- const fileName = name.endsWith(".png") ? name : `${name}.png`;
1394
- const relative = await addScreenshot(fileName);
1395
- stepResult = {
1396
- type: "screenshot",
1397
- status: "pass",
1398
- durationMs: Date.now() - stepStart,
1399
- screenshot: relative
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
- } else if ("if" in step) {
1402
- const condition = step.if;
1403
- const selector = condition.visible ?? condition.notVisible;
1404
- assertAllowedSelector(selector, context.forbiddenSelectors);
1405
- const count = await context.page.locator(selector).count();
1406
- const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
1407
- if (conditionMet) {
1408
- const subResult = await executeNestedSteps(context, {
1409
- steps: condition.then,
1410
- stepPathPrefix: `${currentStepPath}.if.then`
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: `if > ${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 ("repeat" in step) {
1464
- const repeat = step.repeat;
1465
- let totalSubSteps = 0;
1466
- if (repeat.times !== void 0) {
1467
- const totalPlanned = repeat.times * repeat.steps.length;
1468
- if (totalPlanned + totalSubSteps > context.maxSteps) {
1469
- throw new Error(`Repeat exceeded maxSteps guardrail (${context.maxSteps})`);
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
- for (let i = 0; i < repeat.times; i++) {
1472
- totalSubSteps += repeat.steps.length;
1473
- const subResult = await executeNestedSteps(context, {
1474
- steps: repeat.steps,
1475
- stepPathPrefix: `${currentStepPath}.repeat.steps`
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
- const candidateFilePath = path3.isAbsolute(responseFile) ? responseFile : path3.join(context.configDir, responseFile);
1538
- const resolvedConfigDir = path3.resolve(context.configDir);
1539
- const resolvedFilePath = path3.resolve(candidateFilePath);
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
- const contentType = mock.response.contentType ?? "application/json";
1548
- const status = mock.response.status;
1549
- await context.page.route(mock.url, (route) => {
1550
- route.fulfill({
1551
- status,
1552
- contentType,
1553
- body: responseBody
1554
- });
1555
- });
1556
- mocks.set(mock.url, async () => {
1557
- await context.page.unroute(mock.url);
1558
- });
1559
- stepResult = {
1560
- type: "mockRoute",
1561
- status: "pass",
1562
- durationMs: Date.now() - stepStart,
1563
- value: mock.url
1564
- };
1565
- } else if ("unmockRoute" in step) {
1566
- const url = typeof step.unmockRoute === "string" ? step.unmockRoute : step.unmockRoute.url;
1567
- const mocks = context.activeMocks;
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 cleanup = mocks.get(url);
1572
- await cleanup();
1573
- mocks.delete(url);
1574
- stepResult = {
1575
- type: "unmockRoute",
1576
- status: "pass",
1577
- durationMs: Date.now() - stepStart,
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
- stepResult = {
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
- } else if ("runScript" in step) {
1594
- const filePath = path3.isAbsolute(step.runScript.file) ? step.runScript.file : path3.join(context.configDir, step.runScript.file);
1595
- const fileContents = fs3.readFileSync(filePath, "utf-8");
1596
- await context.page.evaluate(fileContents);
1597
- stepResult = {
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
- } else if ("assertScreenshot" in step) {
1604
- const { compareScreenshots, ensureBaselineDir } = await import("./visual-FSARM2JS.js");
1605
- const name = step.assertScreenshot.name;
1606
- const threshold = step.assertScreenshot.threshold ?? 0.1;
1607
- const baselineDir = ensureBaselineDir(context.configDir);
1608
- const baselinePath = path3.join(baselineDir, `${name}.png`);
1609
- const currentScreenshotPath = path3.join(context.runDir, "screenshots", `${name}-current.png`);
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: "baseline created"
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
- } else if ("copyText" in step) {
1639
- assertAllowedSelector(step.copyText.selector, context.forbiddenSelectors);
1640
- const text = await context.page.locator(step.copyText.selector).textContent();
1641
- if (text === null) {
1642
- throw new Error(`No text content found for selector: ${step.copyText.selector}`);
1643
- }
1644
- runtimeVars.set(step.copyText.as, text);
1645
- stepResult = {
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
- const savePath = path3.join(context.runDir, suggestedFilename);
1667
- await download.saveAs(savePath);
1668
- stepResult = {
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
- if (!stepResult) {
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 = path3.join(runDir, "screenshots");
1705
- fs3.mkdirSync(screenshotsDir, { recursive: true });
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 path3.join("screenshots", fileName);
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 fs4 from "fs";
1870
- import path4 from "path";
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 = path4.join(runDir, fileName);
1874
- fs4.writeFileSync(fullPath, JSON.stringify(result, null, 2));
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 fs5 from "fs";
1880
- import path5 from "path";
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 = path5.join(runDir, fileName);
1959
- fs5.writeFileSync(fullPath, `${lines.join("\n")}
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 fs6 from "fs";
1966
- import path6 from "path";
2816
+ import fs8 from "fs";
2817
+ import path8 from "path";
1967
2818
  function escapeXml(text) {
1968
2819
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
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 = path6.join(runDir, fileName);
2011
- fs6.writeFileSync(fullPath, `${lines.join("\n")}
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 (path7.isAbsolute(inputPath)) {
2904
+ if (path9.isAbsolute(inputPath)) {
2054
2905
  return inputPath;
2055
2906
  }
2056
- const projectRoot = path7.dirname(configDir);
2057
- return path7.join(projectRoot, inputPath);
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 = path7.join(runDir, fileName);
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
- fs7.writeFileSync(filePath, `${lines.join("\n")}
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 = path7.join(configDir, "runs", timestamp());
2090
- fs7.mkdirSync(runDir, { recursive: true });
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
- session.page.on("response", (response) => {
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(session.page, runDir);
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 = path7.relative(configDir, outcome.runDir);
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 fs8 from "fs";
2384
- import path8 from "path";
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 fs8.readFileSync(filePath, "utf-8");
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(path8.join(hunt.runDir, "result.json"));
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 ?? path8.join(projectRoot, "docs", "backlog.md");
2520
- const resolvedPath = options.resolvedPath ?? path8.join(projectRoot, "docs", "resolved.md");
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
- fs8.mkdirSync(path8.dirname(backlogPath), { recursive: true });
2553
- fs8.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
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 path10 from "path";
3530
+ import path12 from "path";
2560
3531
 
2561
3532
  // src/reporter/ci-summary.ts
2562
- import fs9 from "fs";
2563
- import path9 from "path";
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
- fs9.mkdirSync(ciRunDir, { recursive: true });
2628
- const filePath = path9.join(ciRunDir, "ci-result.json");
2629
- fs9.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
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 = path10.join(configDir, "runs", timestamp("ci"));
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
- import { chromium as chromium2 } from "playwright";
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 browser = await chromium2.launch({ headless: true });
3117
- const context = await browser.newContext();
3118
- const page = await context.newPage();
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 page.goto(options.url, { waitUntil: "networkidle" });
3121
- analysis = await analyzePage(page);
4121
+ await driver.goto(options.url, { waitUntil: "networkidle" });
4122
+ analysis = await analyzePage(driver);
3122
4123
  } finally {
3123
- await context.close();
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-T7YLXF6X.js.map
4175
+ //# sourceMappingURL=chunk-ZEFVTKQT.js.map