prowl-tools 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib.cjs CHANGED
@@ -356,6 +356,9 @@ function createMacDriver(client, options = {}) {
356
356
  async hover(selector) {
357
357
  await query("hover", selector);
358
358
  },
359
+ scroll() {
360
+ return rejectUnsupported("scroll");
361
+ },
359
362
  async scrollIntoView(selector) {
360
363
  await query("scrollTo", selector);
361
364
  },
@@ -521,10 +524,15 @@ ${macdriverBuildInstructions()}`
521
524
  ${macdriverBuildInstructions()}`);
522
525
  }
523
526
  var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
527
+ function errorMessage(error) {
528
+ return error instanceof Error ? error.message : String(error);
529
+ }
524
530
  var SpawnMacHelperClient = class {
525
531
  child;
526
532
  pending = /* @__PURE__ */ new Map();
527
533
  requestTimeoutMs;
534
+ onEvent;
535
+ onEventError;
528
536
  stdoutBuffer = "";
529
537
  stderrBuffer = "";
530
538
  nextId = 1;
@@ -532,6 +540,9 @@ var SpawnMacHelperClient = class {
532
540
  terminalError;
533
541
  constructor(binaryPath, options = {}) {
534
542
  this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
543
+ this.onEvent = options.onEvent;
544
+ this.onEventError = options.onEventError ?? ((message) => process.stderr.write(`${message}
545
+ `));
535
546
  this.child = (0, import_node_child_process.spawn)(binaryPath, ["serve"], { stdio: ["pipe", "pipe", "pipe"] });
536
547
  this.child.stdout?.setEncoding("utf-8");
537
548
  this.child.stderr?.setEncoding("utf-8");
@@ -568,6 +579,10 @@ var SpawnMacHelperClient = class {
568
579
  } catch {
569
580
  return;
570
581
  }
582
+ if (typeof message.event === "string" && typeof message.id !== "number") {
583
+ this.handleEvent(message);
584
+ return;
585
+ }
571
586
  const id = typeof message.id === "number" ? message.id : void 0;
572
587
  if (id === void 0) {
573
588
  return;
@@ -584,6 +599,18 @@ var SpawnMacHelperClient = class {
584
599
  pending.reject(new Error(typeof message.error === "string" ? message.error : "prowl-macdriver error"));
585
600
  }
586
601
  }
602
+ handleEvent(event) {
603
+ try {
604
+ this.onEvent?.(event);
605
+ } catch (error) {
606
+ try {
607
+ this.onEventError(
608
+ `prowl-macdriver event sink failed for event "${event.event}": ${errorMessage(error)}`
609
+ );
610
+ } catch {
611
+ }
612
+ }
613
+ }
587
614
  failAll(error) {
588
615
  for (const pending of this.pending.values()) {
589
616
  clearTimeout(pending.timer);
@@ -1258,6 +1285,101 @@ function parseSnapshot(xml) {
1258
1285
  return parseXml(xml);
1259
1286
  }
1260
1287
 
1288
+ // src/browser/touch-gestures.ts
1289
+ var DEFAULT_SWIPE_FRACTION = 0.75;
1290
+ var SCROLL_TO_PROBE_SWIPE_FRACTION = 0.6;
1291
+ var MAX_SWIPE_FRACTION = 0.9;
1292
+ var SWIPE_HOLD_MS = 100;
1293
+ var SWIPE_MOVE_DURATION_MS = 300;
1294
+ var SCROLL_TO_SWEEP_DEPTH = 10;
1295
+ var SCROLL_TO_PROBE_DIRECTIONS = [
1296
+ ...Array.from({ length: SCROLL_TO_SWEEP_DEPTH }, () => "down"),
1297
+ ...Array.from({ length: SCROLL_TO_SWEEP_DEPTH * 2 }, () => "up")
1298
+ ];
1299
+ var MAX_SCROLL_TO_SWIPES = SCROLL_TO_PROBE_DIRECTIONS.length;
1300
+ var OPPOSITE_SWIPE_DIRECTIONS = {
1301
+ up: "down",
1302
+ down: "up",
1303
+ left: "right",
1304
+ right: "left"
1305
+ };
1306
+ async function probeScrollIntoView({
1307
+ isVisible,
1308
+ swipe,
1309
+ directions = SCROLL_TO_PROBE_DIRECTIONS
1310
+ }) {
1311
+ if (await isVisible()) {
1312
+ return true;
1313
+ }
1314
+ for (const direction of directions) {
1315
+ await swipe(direction);
1316
+ if (await isVisible()) {
1317
+ return true;
1318
+ }
1319
+ }
1320
+ return false;
1321
+ }
1322
+ function toScreenSize(value, source) {
1323
+ const record = value ?? {};
1324
+ const width = Number(record.width);
1325
+ const height = Number(record.height);
1326
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
1327
+ throw new Error(`${source} did not return a usable screen size`);
1328
+ }
1329
+ return { width, height };
1330
+ }
1331
+ function isVertical(direction) {
1332
+ return direction === "up" || direction === "down";
1333
+ }
1334
+ function swipeDistanceFor(direction, size, amount) {
1335
+ if (amount !== void 0 && !Number.isFinite(amount)) {
1336
+ throw new Error("scroll amount must be a finite number");
1337
+ }
1338
+ const axis = isVertical(direction) ? size.height : size.width;
1339
+ const requested = amount === void 0 ? axis * DEFAULT_SWIPE_FRACTION : Math.abs(amount);
1340
+ const max = axis * MAX_SWIPE_FRACTION;
1341
+ return Math.max(1, Math.round(Math.min(requested, max)));
1342
+ }
1343
+ function scrollToProbeDistanceFor(direction, size) {
1344
+ const axis = isVertical(direction) ? size.height : size.width;
1345
+ return Math.max(1, Math.round(axis * SCROLL_TO_PROBE_SWIPE_FRACTION));
1346
+ }
1347
+ function swipeEndpoints(direction, size, distance) {
1348
+ const cx = Math.round(size.width / 2);
1349
+ const cy = Math.round(size.height / 2);
1350
+ const half = Math.round(distance / 2);
1351
+ switch (direction) {
1352
+ case "down":
1353
+ return { start: { x: cx, y: cy + half }, end: { x: cx, y: cy - half } };
1354
+ case "up":
1355
+ return { start: { x: cx, y: cy - half }, end: { x: cx, y: cy + half } };
1356
+ case "right":
1357
+ return { start: { x: cx + half, y: cy }, end: { x: cx - half, y: cy } };
1358
+ case "left":
1359
+ return { start: { x: cx - half, y: cy }, end: { x: cx + half, y: cy } };
1360
+ }
1361
+ }
1362
+ function buildSwipeActions(start, end) {
1363
+ return {
1364
+ type: "pointer",
1365
+ id: "finger1",
1366
+ parameters: { pointerType: "touch" },
1367
+ actions: [
1368
+ { type: "pointerMove", duration: 0, x: start.x, y: start.y, origin: "viewport" },
1369
+ { type: "pointerDown", button: 0 },
1370
+ { type: "pause", duration: SWIPE_HOLD_MS },
1371
+ { type: "pointerMove", duration: SWIPE_MOVE_DURATION_MS, x: end.x, y: end.y, origin: "viewport" },
1372
+ { type: "pointerUp", button: 0 }
1373
+ ]
1374
+ };
1375
+ }
1376
+ function buildDirectionalSwipe(direction, size, amount) {
1377
+ const normalizedDirection = amount !== void 0 && amount < 0 ? OPPOSITE_SWIPE_DIRECTIONS[direction] : direction;
1378
+ const distance = swipeDistanceFor(normalizedDirection, size, amount);
1379
+ const { start, end } = swipeEndpoints(normalizedDirection, size, distance);
1380
+ return { actions: buildSwipeActions(start, end), distance, start, end };
1381
+ }
1382
+
1261
1383
  // src/browser/android-driver.ts
1262
1384
  var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
1263
1385
  "query",
@@ -1373,6 +1495,11 @@ function createAndroidDriver(client, options = {}) {
1373
1495
  async function fillSelector(selector, value) {
1374
1496
  await client.setValue(await resolveOne(selector), value);
1375
1497
  }
1498
+ async function swipe(direction, amount, size) {
1499
+ const actualSize = size ?? await client.windowSize();
1500
+ const { actions } = buildDirectionalSwipe(direction, actualSize, amount);
1501
+ await client.performActions(actions);
1502
+ }
1376
1503
  return {
1377
1504
  capabilities: ANDROID_CAPABILITIES,
1378
1505
  // navigation -----------------------------------------------------------
@@ -1410,8 +1537,31 @@ function createAndroidDriver(client, options = {}) {
1410
1537
  hover() {
1411
1538
  return rejectUnsupported("hover");
1412
1539
  },
1413
- scrollIntoView() {
1414
- return rejectUnsupported("scrollTo");
1540
+ // Screen-centred swipe via the W3C actions endpoint (PROWL-080). Direction
1541
+ // semantics match the web step: scrolling "down" reveals lower content, so
1542
+ // the finger drags up. See ./touch-gestures.ts.
1543
+ async scroll(direction, amount) {
1544
+ await swipe(direction, amount);
1545
+ },
1546
+ // Resolve the element, short-circuiting if it is already present in the
1547
+ // hierarchy; otherwise use the shared bounded down/up mobile probe before
1548
+ // failing with a message naming the selector and attempts.
1549
+ async scrollIntoView(selector) {
1550
+ const query = parseAndroidSelector(selector);
1551
+ let probeSize;
1552
+ const found = await probeScrollIntoView({
1553
+ isVisible: async () => (await client.findElements(query)).length > 0,
1554
+ swipe: async (direction) => {
1555
+ probeSize ??= await client.windowSize();
1556
+ await swipe(direction, scrollToProbeDistanceFor(direction, probeSize), probeSize);
1557
+ }
1558
+ });
1559
+ if (found) {
1560
+ return;
1561
+ }
1562
+ throw new Error(
1563
+ `scrollTo: element "${selector}" not visible after ${MAX_SCROLL_TO_SWIPES} scroll attempts on the Android target`
1564
+ );
1415
1565
  },
1416
1566
  setInputFiles() {
1417
1567
  return rejectUnsupported("setInputFiles");
@@ -1889,6 +2039,13 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
1889
2039
  async pressKeyCode(keyCode) {
1890
2040
  await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
1891
2041
  },
2042
+ async windowSize() {
2043
+ const value = await transport.request("GET", `${base}/window/current/size`);
2044
+ return toScreenSize(value, "uiautomator2 /window/current/size");
2045
+ },
2046
+ async performActions(actions) {
2047
+ await transport.request("POST", `${base}/actions`, { actions: [actions] });
2048
+ },
1892
2049
  async screenshotPng() {
1893
2050
  const value = await transport.request("GET", `${base}/screenshot`);
1894
2051
  if (typeof value !== "string") {
@@ -1925,10 +2082,17 @@ var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
1925
2082
  "select",
1926
2083
  "selectOption",
1927
2084
  "setInputFiles",
1928
- "waitForDownload",
1929
- "scroll"
1930
- // directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
2085
+ "waitForDownload"
1931
2086
  ]);
2087
+ var MACOS_UNSUPPORTED_STEP_TYPES = /* @__PURE__ */ new Set(["scroll"]);
2088
+ function macosUnsupportedReason(step) {
2089
+ for (const type of MACOS_UNSUPPORTED_STEP_TYPES) {
2090
+ if (type in step) {
2091
+ return type;
2092
+ }
2093
+ }
2094
+ return null;
2095
+ }
1932
2096
  function webOnlyReason(step) {
1933
2097
  for (const type of WEB_ONLY_STEP_TYPES) {
1934
2098
  if (type in step) {
@@ -1964,6 +2128,14 @@ function assertStepsSupportedByTarget(steps, target) {
1964
2128
  `Step "${reason}" is not supported by the ${label} target. It is web-only; use a portable step (click, fill, type, press, wait, assert visible, screenshot, etc.).`
1965
2129
  );
1966
2130
  }
2131
+ if (target === "macos") {
2132
+ const macReason = macosUnsupportedReason(step);
2133
+ if (macReason) {
2134
+ throw new Error(
2135
+ `Step "${macReason}" is not supported by the macOS target. Directional scroll is a touch swipe available on the iOS and Android targets; there is no macOS accessibility equivalent (use scrollTo to bring a specific element into view instead).`
2136
+ );
2137
+ }
2138
+ }
1967
2139
  if ("if" in step) {
1968
2140
  assertStepsSupportedByTarget(step.if.then, target);
1969
2141
  if (step.if.else) {
@@ -2234,6 +2406,7 @@ async function launchAndroidSession(options) {
2234
2406
  readyDeadlineMs,
2235
2407
  appPackage: pkg
2236
2408
  });
2409
+ await launchPackage(runner, serial, pkg);
2237
2410
  const driver = createAndroidDriver(client, { appLabel: pkg });
2238
2411
  return { client, driver, package: pkg, serial, teardown };
2239
2412
  } catch (error) {
@@ -2254,6 +2427,7 @@ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
2254
2427
  ]);
2255
2428
  var WAIT_POLL_INTERVAL_MS2 = 250;
2256
2429
  var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
2430
+ var DISPLAYED_CHECK_CONCURRENCY = 4;
2257
2431
  var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
2258
2432
  function escapePredicateArg(value) {
2259
2433
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
@@ -2328,6 +2502,64 @@ function createIosDriver(client, options) {
2328
2502
  async function fillSelector(selector, value) {
2329
2503
  await client.setValue(await resolveOne(selector), value);
2330
2504
  }
2505
+ async function swipe(direction, amount, size) {
2506
+ const actualSize = size ?? await client.windowSize();
2507
+ const { actions } = buildDirectionalSwipe(direction, actualSize, amount);
2508
+ await client.performActions(actions);
2509
+ }
2510
+ async function visibleElementIds(query) {
2511
+ const ids = await client.findElements(query);
2512
+ const visible = Array.from({ length: ids.length }, () => null);
2513
+ let nextIndex = 0;
2514
+ const workerCount = Math.min(DISPLAYED_CHECK_CONCURRENCY, ids.length);
2515
+ await Promise.all(
2516
+ Array.from({ length: workerCount }, async () => {
2517
+ for (; ; ) {
2518
+ const index = nextIndex;
2519
+ nextIndex += 1;
2520
+ if (index >= ids.length) {
2521
+ return;
2522
+ }
2523
+ const id = ids[index];
2524
+ if (await client.isDisplayed(id)) {
2525
+ visible[index] = id;
2526
+ }
2527
+ }
2528
+ })
2529
+ );
2530
+ return visible.filter((id) => id !== null);
2531
+ }
2532
+ async function hasVisibleElement(query) {
2533
+ const ids = await client.findElements(query);
2534
+ if (ids.length === 0) {
2535
+ return false;
2536
+ }
2537
+ if (await client.isDisplayed(ids[0])) {
2538
+ return true;
2539
+ }
2540
+ let found = false;
2541
+ let nextIndex = 1;
2542
+ const workerCount = Math.min(DISPLAYED_CHECK_CONCURRENCY, ids.length - 1);
2543
+ await Promise.all(
2544
+ Array.from({ length: workerCount }, async () => {
2545
+ for (; ; ) {
2546
+ if (found) {
2547
+ return;
2548
+ }
2549
+ const index = nextIndex;
2550
+ nextIndex += 1;
2551
+ if (index >= ids.length) {
2552
+ return;
2553
+ }
2554
+ if (await client.isDisplayed(ids[index])) {
2555
+ found = true;
2556
+ return;
2557
+ }
2558
+ }
2559
+ })
2560
+ );
2561
+ return found;
2562
+ }
2331
2563
  async function pressKey(key) {
2332
2564
  const name = key.trim().toLowerCase();
2333
2565
  if (name === "enter" || name === "return") {
@@ -2359,6 +2591,9 @@ function createIosDriver(client, options) {
2359
2591
  async count(selector) {
2360
2592
  return (await client.findElements(parseIosSelector(selector))).length;
2361
2593
  },
2594
+ async visibleCount(selector) {
2595
+ return (await visibleElementIds(parseIosSelector(selector))).length;
2596
+ },
2362
2597
  async textContent(selector) {
2363
2598
  const id = await client.findElement(parseIosSelector(selector));
2364
2599
  if (id === null) {
@@ -2383,15 +2618,38 @@ function createIosDriver(client, options) {
2383
2618
  hover() {
2384
2619
  return rejectUnsupported("hover");
2385
2620
  },
2386
- scrollIntoView() {
2387
- return rejectUnsupported("scrollTo");
2621
+ // Screen-centred swipe via the W3C actions endpoint (PROWL-080). Direction
2622
+ // semantics match the web step: scrolling "down" reveals lower content, so
2623
+ // the finger drags up. See ./touch-gestures.ts.
2624
+ async scroll(direction, amount) {
2625
+ await swipe(direction, amount);
2626
+ },
2627
+ // Resolve the element, short-circuiting only if WDA reports a matching
2628
+ // element displayed in the viewport; hierarchy-only matches can be offscreen.
2629
+ // Otherwise use the shared bounded down/up mobile probe before failing.
2630
+ async scrollIntoView(selector) {
2631
+ const query = parseIosSelector(selector);
2632
+ let probeSize;
2633
+ const found = await probeScrollIntoView({
2634
+ isVisible: () => hasVisibleElement(query),
2635
+ swipe: async (direction) => {
2636
+ probeSize ??= await client.windowSize();
2637
+ await swipe(direction, scrollToProbeDistanceFor(direction, probeSize), probeSize);
2638
+ }
2639
+ });
2640
+ if (found) {
2641
+ return;
2642
+ }
2643
+ throw new Error(
2644
+ `scrollTo: element "${selector}" not visible after ${MAX_SCROLL_TO_SWIPES} scroll attempts on the iOS target`
2645
+ );
2388
2646
  },
2389
2647
  setInputFiles() {
2390
2648
  return rejectUnsupported("setInputFiles");
2391
2649
  },
2392
2650
  // semantic locators ----------------------------------------------------
2393
2651
  async countByRole(role, name) {
2394
- return (await client.findElements({ by: "role", role, name })).length;
2652
+ return (await visibleElementIds({ by: "role", role, name })).length;
2395
2653
  },
2396
2654
  async clickFirstByRole(role, name) {
2397
2655
  const id = await client.findElement({ by: "role", role, name });
@@ -2401,7 +2659,7 @@ function createIosDriver(client, options) {
2401
2659
  await client.click(id);
2402
2660
  },
2403
2661
  async countByLabel(label) {
2404
- return (await client.findElements({ by: "label", value: label })).length;
2662
+ return (await visibleElementIds({ by: "label", value: label })).length;
2405
2663
  },
2406
2664
  async fillFirstByLabel(label, value) {
2407
2665
  const id = await client.findElement({ by: "label", value: label });
@@ -2419,7 +2677,7 @@ function createIosDriver(client, options) {
2419
2677
  const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
2420
2678
  const deadline = Date.now() + timeoutMs;
2421
2679
  for (; ; ) {
2422
- if ((await client.findElements(query)).length > 0) {
2680
+ if (await hasVisibleElement(query)) {
2423
2681
  return;
2424
2682
  }
2425
2683
  if (Date.now() >= deadline) {
@@ -2919,12 +3177,29 @@ function createWdaAgentClient(transport, sessionId) {
2919
3177
  const value = await transport.request("GET", `${base}/element/${elementId}/text`);
2920
3178
  return typeof value === "string" ? value : value == null ? null : String(value);
2921
3179
  },
3180
+ async isDisplayed(elementId) {
3181
+ try {
3182
+ return await transport.request("GET", `${base}/element/${elementId}/displayed`) === true;
3183
+ } catch (error) {
3184
+ if (isNoSuchElement2(error)) {
3185
+ return false;
3186
+ }
3187
+ throw error;
3188
+ }
3189
+ },
2922
3190
  async sendKeys(keys) {
2923
3191
  await transport.request("POST", `${base}/wda/keys`, { value: keys });
2924
3192
  },
2925
3193
  async homescreen() {
2926
3194
  await transport.request("POST", "/wda/homescreen", {});
2927
3195
  },
3196
+ async windowSize() {
3197
+ const value = await transport.request("GET", `${base}/window/size`);
3198
+ return toScreenSize(value, "WebDriverAgent /window/size");
3199
+ },
3200
+ async performActions(actions) {
3201
+ await transport.request("POST", `${base}/actions`, { actions: [actions] });
3202
+ },
2928
3203
  async source() {
2929
3204
  const value = await transport.request("GET", "/source");
2930
3205
  if (typeof value !== "string") {
@@ -3619,339 +3894,87 @@ var huntSchema = import_zod.z.object({
3619
3894
  }).strict().optional()
3620
3895
  }).strict();
3621
3896
 
3622
- // src/config/loader.ts
3623
- var DEFAULT_WEB_URL = "http://localhost:3000";
3624
- var DEFAULT_CONFIG = {
3625
- target: {
3626
- type: "web",
3627
- url: DEFAULT_WEB_URL
3628
- },
3629
- browser: {
3630
- headless: true,
3631
- slowMo: 0,
3632
- timeout: 3e4,
3633
- engine: "chromium",
3634
- viewport: { width: 1280, height: 720 }
3635
- },
3636
- artifacts: {
3637
- screenshots: "on-failure",
3638
- networkHar: false,
3639
- console: true,
3640
- junit: false
3641
- },
3642
- assertions: {
3643
- noConsoleErrors: true,
3644
- noNetworkErrors: true,
3645
- maxTotalTimeMs: 3e4,
3646
- networkIgnorePatterns: []
3647
- },
3648
- guardrails: {
3649
- maxSteps: 50,
3650
- allowedDomains: ["localhost", "127.0.0.1", "0.0.0.0"],
3651
- allowedApps: [],
3652
- forbiddenSelectors: ["[data-danger]", ".delete-btn"],
3653
- selfHealing: false
3654
- },
3655
- auth: {
3656
- storageStatePath: ".prowl/auth-state.json"
3657
- },
3658
- history: {
3659
- maxRuns: 100
3660
- }
3661
- };
3662
- var CONFIG_DIR = ".prowl";
3663
- var LEGACY_CONFIG_DIR = ".prowlqa";
3664
- var legacyDirWarned = false;
3665
- function warnLegacyConfigDir() {
3666
- if (legacyDirWarned) {
3897
+ // src/config/interpolate.ts
3898
+ var import_node_crypto2 = __toESM(require("crypto"), 1);
3899
+ var VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
3900
+ function collectInterpolatedValues(input, vars, values) {
3901
+ if (typeof input === "string") {
3902
+ for (const match of input.matchAll(VAR_PATTERN)) {
3903
+ const varValue = vars[match[1]];
3904
+ if (varValue) values.add(varValue);
3905
+ }
3667
3906
  return;
3668
3907
  }
3669
- legacyDirWarned = true;
3670
- console.warn(
3671
- 'Warning: the ".prowlqa/" config directory is deprecated; rename it to ".prowl/". Support for ".prowlqa/" will be removed in a future release.'
3672
- );
3673
- }
3674
- function findConfigPath(startDir) {
3675
- let current = startDir;
3676
- while (current) {
3677
- for (const dir of [CONFIG_DIR, LEGACY_CONFIG_DIR]) {
3678
- const candidate = import_node_path8.default.join(current, dir, "config.yml");
3679
- if (import_node_fs7.default.existsSync(candidate)) {
3680
- return candidate;
3681
- }
3908
+ if (Array.isArray(input)) {
3909
+ for (const item of input) {
3910
+ collectInterpolatedValues(item, vars, values);
3682
3911
  }
3683
- const parent = import_node_path8.default.dirname(current);
3684
- if (parent === current) {
3685
- break;
3912
+ return;
3913
+ }
3914
+ if (input && typeof input === "object") {
3915
+ for (const [key, value] of Object.entries(input)) {
3916
+ collectInterpolatedValues(key, vars, values);
3917
+ collectInterpolatedValues(value, vars, values);
3686
3918
  }
3687
- current = parent;
3688
3919
  }
3689
- return null;
3690
3920
  }
3691
- var VIEWPORT_PRESETS = {
3692
- mobile: { width: 375, height: 812 },
3693
- tablet: { width: 768, height: 1024 },
3694
- desktop: { width: 1280, height: 720 }
3695
- };
3696
- function resolveViewport(value) {
3697
- if (value === void 0) {
3698
- return DEFAULT_CONFIG.browser.viewport;
3699
- }
3700
- if (typeof value === "string") {
3701
- const preset = VIEWPORT_PRESETS[value];
3702
- if (!preset) {
3703
- throw new Error(`Unknown viewport preset: "${value}". Use mobile, tablet, or desktop.`);
3921
+ function interpolateString(input, vars) {
3922
+ const usedVars = [];
3923
+ const value = input.replace(VAR_PATTERN, (_, name) => {
3924
+ const varValue = vars[name];
3925
+ if (varValue === void 0) {
3926
+ throw new Error(`Missing variable: ${name}`);
3704
3927
  }
3705
- return preset;
3706
- }
3707
- return value;
3928
+ usedVars.push(name);
3929
+ return varValue;
3930
+ });
3931
+ return { value, usedVars };
3708
3932
  }
3709
- function resolveTarget(target) {
3710
- const type = target?.type;
3711
- if (type === "macos") {
3712
- return { type: "macos", app: target.app };
3933
+ var RANDOM_FIRST_NAMES = ["Alex", "Jordan", "Morgan", "Taylor", "Casey", "Riley", "Quinn", "Avery"];
3934
+ var RANDOM_LAST_NAMES = ["Smith", "Johnson", "Brown", "Davis", "Wilson", "Clark", "Hall", "Young"];
3935
+ function generateRandomVars(randomSource) {
3936
+ const random = randomSource?.random ?? Math.random;
3937
+ const randomBytes = randomSource?.randomBytes ?? import_node_crypto2.default.randomBytes;
3938
+ const randomUUID = randomSource?.randomUUID ?? import_node_crypto2.default.randomUUID;
3939
+ const hex = randomBytes(4).toString("hex");
3940
+ const firstIndex = Math.floor(random() * RANDOM_FIRST_NAMES.length);
3941
+ const lastIndex = Math.floor(random() * RANDOM_LAST_NAMES.length);
3942
+ const num2 = Math.floor(random() * 9e3) + 1e3;
3943
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
3944
+ let text = "";
3945
+ for (let i = 0; i < 8; i++) {
3946
+ text += chars[Math.floor(random() * chars.length)];
3713
3947
  }
3714
- if (type === "android") {
3715
- const androidTarget = target;
3948
+ return {
3949
+ RANDOM_EMAIL: `prowl_${hex}@test.com`,
3950
+ RANDOM_NAME: `${RANDOM_FIRST_NAMES[firstIndex]} ${RANDOM_LAST_NAMES[lastIndex]}`,
3951
+ RANDOM_NUMBER: String(num2),
3952
+ RANDOM_UUID: randomUUID(),
3953
+ RANDOM_TEXT: text
3954
+ };
3955
+ }
3956
+ function interpolateStep(step, vars, stepPath2, redacted) {
3957
+ const isExplicitFill = (value) => typeof value.selector === "string" && typeof value.value === "string";
3958
+ const interpolateSinglePair = (record) => {
3959
+ const entries = Object.entries(record);
3960
+ if (entries.length !== 1) {
3961
+ throw new Error("Shorthand step expects exactly one key-value pair");
3962
+ }
3963
+ const [key, value] = entries[0];
3716
3964
  return {
3717
- type: "android",
3718
- app: androidTarget.app,
3719
- ...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
3720
- ...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
3965
+ [interpolateString(key, vars).value]: interpolateString(value, vars).value
3721
3966
  };
3967
+ };
3968
+ if ("navigate" in step) {
3969
+ const result = interpolateString(step.navigate, vars);
3970
+ return { navigate: result.value };
3722
3971
  }
3723
- if (type === "ios") {
3724
- const iosTarget = target;
3725
- return {
3726
- type: "ios",
3727
- app: iosTarget.app,
3728
- ...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
3729
- ...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
3730
- };
3731
- }
3732
- return {
3733
- type: "web",
3734
- url: target?.url ?? DEFAULT_WEB_URL
3735
- };
3736
- }
3737
- function mergeConfig(partial) {
3738
- return {
3739
- target: resolveTarget(partial.target),
3740
- browser: {
3741
- headless: partial.browser?.headless ?? DEFAULT_CONFIG.browser.headless,
3742
- slowMo: partial.browser?.slowMo ?? DEFAULT_CONFIG.browser.slowMo,
3743
- timeout: partial.browser?.timeout ?? DEFAULT_CONFIG.browser.timeout,
3744
- engine: partial.browser?.engine ?? DEFAULT_CONFIG.browser.engine,
3745
- channel: partial.browser?.channel,
3746
- viewport: resolveViewport(partial.browser?.viewport)
3747
- },
3748
- artifacts: {
3749
- screenshots: partial.artifacts?.screenshots ?? DEFAULT_CONFIG.artifacts.screenshots,
3750
- networkHar: partial.artifacts?.networkHar ?? DEFAULT_CONFIG.artifacts.networkHar,
3751
- console: partial.artifacts?.console ?? DEFAULT_CONFIG.artifacts.console,
3752
- junit: partial.artifacts?.junit ?? DEFAULT_CONFIG.artifacts.junit
3753
- },
3754
- assertions: {
3755
- noConsoleErrors: partial.assertions?.noConsoleErrors ?? DEFAULT_CONFIG.assertions.noConsoleErrors,
3756
- noNetworkErrors: partial.assertions?.noNetworkErrors ?? DEFAULT_CONFIG.assertions.noNetworkErrors,
3757
- maxTotalTimeMs: partial.assertions?.maxTotalTimeMs ?? DEFAULT_CONFIG.assertions.maxTotalTimeMs,
3758
- networkIgnorePatterns: partial.assertions?.networkIgnorePatterns ?? DEFAULT_CONFIG.assertions.networkIgnorePatterns
3759
- },
3760
- guardrails: {
3761
- maxSteps: partial.guardrails?.maxSteps ?? DEFAULT_CONFIG.guardrails.maxSteps,
3762
- allowedDomains: partial.guardrails?.allowedDomains ?? DEFAULT_CONFIG.guardrails.allowedDomains,
3763
- allowedApps: partial.guardrails?.allowedApps ?? DEFAULT_CONFIG.guardrails.allowedApps,
3764
- forbiddenSelectors: partial.guardrails?.forbiddenSelectors ?? DEFAULT_CONFIG.guardrails.forbiddenSelectors,
3765
- selfHealing: partial.guardrails?.selfHealing ?? DEFAULT_CONFIG.guardrails.selfHealing
3766
- },
3767
- auth: {
3768
- storageStatePath: partial.auth?.storageStatePath ?? (partial.auth !== void 0 ? DEFAULT_CONFIG.auth.storageStatePath : void 0)
3769
- },
3770
- history: {
3771
- maxRuns: partial.history?.maxRuns ?? DEFAULT_CONFIG.history.maxRuns
3772
- },
3773
- bugLog: partial.bugLog,
3774
- tracing: partial.tracing,
3775
- reliability: partial.reliability
3776
- };
3777
- }
3778
- function ensureAllowedDomain(allowed, urlValue) {
3779
- try {
3780
- const host = new URL(urlValue).hostname;
3781
- if (!allowed.includes(host)) {
3782
- return [...allowed, host];
3783
- }
3784
- } catch {
3785
- return allowed;
3786
- }
3787
- return allowed;
3788
- }
3789
- function loadConfig(configPath) {
3790
- const resolvedPath = configPath ? import_node_path8.default.resolve(configPath) : findConfigPath(process.cwd());
3791
- if (!resolvedPath) {
3792
- throw new Error("Could not find .prowl/config.yml. Run `prowl init` first.");
3793
- }
3794
- if (!import_node_fs7.default.existsSync(resolvedPath)) {
3795
- throw new Error(`Config file not found at ${resolvedPath}`);
3796
- }
3797
- const configDir = import_node_path8.default.dirname(resolvedPath);
3798
- if (import_node_path8.default.basename(configDir) === LEGACY_CONFIG_DIR) {
3799
- warnLegacyConfigDir();
3800
- }
3801
- import_dotenv.default.config({ path: import_node_path8.default.join(configDir, ".env"), override: false });
3802
- const raw = import_node_fs7.default.readFileSync(resolvedPath, "utf-8");
3803
- const parsed = import_yaml.default.parse(raw) ?? {};
3804
- const validated = configSchema.parse(parsed);
3805
- const config = mergeConfig(validated);
3806
- if (config.target.type === "web") {
3807
- config.guardrails.allowedDomains = ensureAllowedDomain(
3808
- config.guardrails.allowedDomains,
3809
- config.target.url
3810
- );
3811
- }
3812
- return { config, configPath: resolvedPath, configDir };
3813
- }
3814
- function loadHunt(huntName, configDir) {
3815
- assertValidHuntName(huntName);
3816
- const huntPath = import_node_path8.default.join(configDir, "hunts", `${huntName}.yml`);
3817
- if (!import_node_fs7.default.existsSync(huntPath)) {
3818
- throw new Error(`Hunt file not found: ${huntPath}`);
3819
- }
3820
- const raw = import_node_fs7.default.readFileSync(huntPath, "utf-8");
3821
- const parsed = import_yaml.default.parse(raw) ?? {};
3822
- const validated = huntSchema.parse(parsed);
3823
- return validated;
3824
- }
3825
- function loadHuntTags(huntName, configDir) {
3826
- assertValidHuntName(huntName);
3827
- const huntPath = import_node_path8.default.join(configDir, "hunts", `${huntName}.yml`);
3828
- if (!import_node_fs7.default.existsSync(huntPath)) {
3829
- return [];
3830
- }
3831
- const raw = import_node_fs7.default.readFileSync(huntPath, "utf-8");
3832
- const parsed = import_yaml.default.parse(raw) ?? {};
3833
- return Array.isArray(parsed.tags) ? parsed.tags : [];
3834
- }
3835
- function loadHuntMeta(huntName, configDir) {
3836
- assertValidHuntName(huntName);
3837
- const huntPath = import_node_path8.default.join(configDir, "hunts", `${huntName}.yml`);
3838
- if (!import_node_fs7.default.existsSync(huntPath)) {
3839
- return { tags: [] };
3840
- }
3841
- const raw = import_node_fs7.default.readFileSync(huntPath, "utf-8");
3842
- const parsed = import_yaml.default.parse(raw) ?? {};
3843
- return {
3844
- description: typeof parsed.description === "string" ? parsed.description : void 0,
3845
- tags: Array.isArray(parsed.tags) ? parsed.tags : []
3846
- };
3847
- }
3848
- function listHunts(configDir) {
3849
- const huntsDir = import_node_path8.default.join(configDir, "hunts");
3850
- if (!import_node_fs7.default.existsSync(huntsDir)) {
3851
- return [];
3852
- }
3853
- const stats = import_node_fs7.default.statSync(huntsDir);
3854
- if (!stats.isDirectory()) {
3855
- throw new Error(`Hunts path is not a directory: ${huntsDir}`);
3856
- }
3857
- const results = [];
3858
- function scanDir(dir) {
3859
- const entries = import_node_fs7.default.readdirSync(dir, { withFileTypes: true });
3860
- for (const entry of entries) {
3861
- if (entry.isFile() && entry.name.endsWith(".yml")) {
3862
- const fullPath = import_node_path8.default.join(dir, entry.name);
3863
- const relative = import_node_path8.default.relative(huntsDir, fullPath);
3864
- results.push(relative.replace(/\.yml$/, ""));
3865
- } else if (entry.isDirectory()) {
3866
- scanDir(import_node_path8.default.join(dir, entry.name));
3867
- }
3868
- }
3869
- }
3870
- scanDir(huntsDir);
3871
- return results.sort((a, b) => a.localeCompare(b));
3872
- }
3873
-
3874
- // src/config/interpolate.ts
3875
- var import_node_crypto2 = __toESM(require("crypto"), 1);
3876
- var VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
3877
- function collectInterpolatedValues(input, vars, values) {
3878
- if (typeof input === "string") {
3879
- for (const match of input.matchAll(VAR_PATTERN)) {
3880
- const varValue = vars[match[1]];
3881
- if (varValue) values.add(varValue);
3882
- }
3883
- return;
3884
- }
3885
- if (Array.isArray(input)) {
3886
- for (const item of input) {
3887
- collectInterpolatedValues(item, vars, values);
3888
- }
3889
- return;
3890
- }
3891
- if (input && typeof input === "object") {
3892
- for (const [key, value] of Object.entries(input)) {
3893
- collectInterpolatedValues(key, vars, values);
3894
- collectInterpolatedValues(value, vars, values);
3895
- }
3896
- }
3897
- }
3898
- function interpolateString(input, vars) {
3899
- const usedVars = [];
3900
- const value = input.replace(VAR_PATTERN, (_, name) => {
3901
- const varValue = vars[name];
3902
- if (varValue === void 0) {
3903
- throw new Error(`Missing variable: ${name}`);
3904
- }
3905
- usedVars.push(name);
3906
- return varValue;
3907
- });
3908
- return { value, usedVars };
3909
- }
3910
- var RANDOM_FIRST_NAMES = ["Alex", "Jordan", "Morgan", "Taylor", "Casey", "Riley", "Quinn", "Avery"];
3911
- var RANDOM_LAST_NAMES = ["Smith", "Johnson", "Brown", "Davis", "Wilson", "Clark", "Hall", "Young"];
3912
- function generateRandomVars(randomSource) {
3913
- const random = randomSource?.random ?? Math.random;
3914
- const randomBytes = randomSource?.randomBytes ?? import_node_crypto2.default.randomBytes;
3915
- const randomUUID = randomSource?.randomUUID ?? import_node_crypto2.default.randomUUID;
3916
- const hex = randomBytes(4).toString("hex");
3917
- const firstIndex = Math.floor(random() * RANDOM_FIRST_NAMES.length);
3918
- const lastIndex = Math.floor(random() * RANDOM_LAST_NAMES.length);
3919
- const num2 = Math.floor(random() * 9e3) + 1e3;
3920
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
3921
- let text = "";
3922
- for (let i = 0; i < 8; i++) {
3923
- text += chars[Math.floor(random() * chars.length)];
3924
- }
3925
- return {
3926
- RANDOM_EMAIL: `prowl_${hex}@test.com`,
3927
- RANDOM_NAME: `${RANDOM_FIRST_NAMES[firstIndex]} ${RANDOM_LAST_NAMES[lastIndex]}`,
3928
- RANDOM_NUMBER: String(num2),
3929
- RANDOM_UUID: randomUUID(),
3930
- RANDOM_TEXT: text
3931
- };
3932
- }
3933
- function interpolateStep(step, vars, stepPath2, redacted) {
3934
- const isExplicitFill = (value) => typeof value.selector === "string" && typeof value.value === "string";
3935
- const interpolateSinglePair = (record) => {
3936
- const entries = Object.entries(record);
3937
- if (entries.length !== 1) {
3938
- throw new Error("Shorthand step expects exactly one key-value pair");
3939
- }
3940
- const [key, value] = entries[0];
3941
- return {
3942
- [interpolateString(key, vars).value]: interpolateString(value, vars).value
3943
- };
3944
- };
3945
- if ("navigate" in step) {
3946
- const result = interpolateString(step.navigate, vars);
3947
- return { navigate: result.value };
3948
- }
3949
- if ("click" in step) {
3950
- if (typeof step.click === "string") {
3951
- return { click: interpolateString(step.click, vars).value };
3952
- }
3953
- const result = interpolateString(step.click.selector, vars);
3954
- return { click: { selector: result.value } };
3972
+ if ("click" in step) {
3973
+ if (typeof step.click === "string") {
3974
+ return { click: interpolateString(step.click, vars).value };
3975
+ }
3976
+ const result = interpolateString(step.click.selector, vars);
3977
+ return { click: { selector: result.value } };
3955
3978
  }
3956
3979
  if ("fill" in step) {
3957
3980
  if (isExplicitFill(step.fill)) {
@@ -4242,6 +4265,278 @@ function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
4242
4265
  };
4243
4266
  }
4244
4267
 
4268
+ // src/config/loader.ts
4269
+ var DEFAULT_WEB_URL = "http://localhost:3000";
4270
+ var DEFAULT_CONFIG = {
4271
+ target: {
4272
+ type: "web",
4273
+ url: DEFAULT_WEB_URL
4274
+ },
4275
+ browser: {
4276
+ headless: true,
4277
+ slowMo: 0,
4278
+ timeout: 3e4,
4279
+ engine: "chromium",
4280
+ viewport: { width: 1280, height: 720 }
4281
+ },
4282
+ artifacts: {
4283
+ screenshots: "on-failure",
4284
+ networkHar: false,
4285
+ console: true,
4286
+ junit: false
4287
+ },
4288
+ assertions: {
4289
+ noConsoleErrors: true,
4290
+ noNetworkErrors: true,
4291
+ maxTotalTimeMs: 3e4,
4292
+ networkIgnorePatterns: []
4293
+ },
4294
+ guardrails: {
4295
+ maxSteps: 50,
4296
+ allowedDomains: ["localhost", "127.0.0.1", "0.0.0.0"],
4297
+ allowedApps: [],
4298
+ forbiddenSelectors: ["[data-danger]", ".delete-btn"],
4299
+ selfHealing: false
4300
+ },
4301
+ auth: {
4302
+ storageStatePath: ".prowl/auth-state.json"
4303
+ },
4304
+ history: {
4305
+ maxRuns: 100
4306
+ }
4307
+ };
4308
+ var CONFIG_DIR = ".prowl";
4309
+ var LEGACY_CONFIG_DIR = ".prowlqa";
4310
+ var legacyDirWarned = false;
4311
+ function warnLegacyConfigDir() {
4312
+ if (legacyDirWarned) {
4313
+ return;
4314
+ }
4315
+ legacyDirWarned = true;
4316
+ console.warn(
4317
+ 'Warning: the ".prowlqa/" config directory is deprecated; rename it to ".prowl/". Support for ".prowlqa/" will be removed in a future release.'
4318
+ );
4319
+ }
4320
+ function findConfigPath(startDir) {
4321
+ let current = startDir;
4322
+ while (current) {
4323
+ for (const dir of [CONFIG_DIR, LEGACY_CONFIG_DIR]) {
4324
+ const candidate = import_node_path8.default.join(current, dir, "config.yml");
4325
+ if (import_node_fs7.default.existsSync(candidate)) {
4326
+ return candidate;
4327
+ }
4328
+ }
4329
+ const parent = import_node_path8.default.dirname(current);
4330
+ if (parent === current) {
4331
+ break;
4332
+ }
4333
+ current = parent;
4334
+ }
4335
+ return null;
4336
+ }
4337
+ var VIEWPORT_PRESETS = {
4338
+ mobile: { width: 375, height: 812 },
4339
+ tablet: { width: 768, height: 1024 },
4340
+ desktop: { width: 1280, height: 720 }
4341
+ };
4342
+ function resolveViewport(value) {
4343
+ if (value === void 0) {
4344
+ return DEFAULT_CONFIG.browser.viewport;
4345
+ }
4346
+ if (typeof value === "string") {
4347
+ const preset = VIEWPORT_PRESETS[value];
4348
+ if (!preset) {
4349
+ throw new Error(`Unknown viewport preset: "${value}". Use mobile, tablet, or desktop.`);
4350
+ }
4351
+ return preset;
4352
+ }
4353
+ return value;
4354
+ }
4355
+ function resolveTarget(target) {
4356
+ const type = target?.type;
4357
+ if (type === "macos") {
4358
+ return { type: "macos", app: target.app };
4359
+ }
4360
+ if (type === "android") {
4361
+ const androidTarget = target;
4362
+ return {
4363
+ type: "android",
4364
+ app: androidTarget.app,
4365
+ ...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
4366
+ ...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
4367
+ };
4368
+ }
4369
+ if (type === "ios") {
4370
+ const iosTarget = target;
4371
+ return {
4372
+ type: "ios",
4373
+ app: iosTarget.app,
4374
+ ...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
4375
+ ...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
4376
+ };
4377
+ }
4378
+ return {
4379
+ type: "web",
4380
+ url: target?.url ?? DEFAULT_WEB_URL
4381
+ };
4382
+ }
4383
+ function mergeConfig(partial) {
4384
+ return {
4385
+ target: resolveTarget(partial.target),
4386
+ browser: {
4387
+ headless: partial.browser?.headless ?? DEFAULT_CONFIG.browser.headless,
4388
+ slowMo: partial.browser?.slowMo ?? DEFAULT_CONFIG.browser.slowMo,
4389
+ timeout: partial.browser?.timeout ?? DEFAULT_CONFIG.browser.timeout,
4390
+ engine: partial.browser?.engine ?? DEFAULT_CONFIG.browser.engine,
4391
+ channel: partial.browser?.channel,
4392
+ viewport: resolveViewport(partial.browser?.viewport)
4393
+ },
4394
+ artifacts: {
4395
+ screenshots: partial.artifacts?.screenshots ?? DEFAULT_CONFIG.artifacts.screenshots,
4396
+ networkHar: partial.artifacts?.networkHar ?? DEFAULT_CONFIG.artifacts.networkHar,
4397
+ console: partial.artifacts?.console ?? DEFAULT_CONFIG.artifacts.console,
4398
+ junit: partial.artifacts?.junit ?? DEFAULT_CONFIG.artifacts.junit
4399
+ },
4400
+ assertions: {
4401
+ noConsoleErrors: partial.assertions?.noConsoleErrors ?? DEFAULT_CONFIG.assertions.noConsoleErrors,
4402
+ noNetworkErrors: partial.assertions?.noNetworkErrors ?? DEFAULT_CONFIG.assertions.noNetworkErrors,
4403
+ maxTotalTimeMs: partial.assertions?.maxTotalTimeMs ?? DEFAULT_CONFIG.assertions.maxTotalTimeMs,
4404
+ networkIgnorePatterns: partial.assertions?.networkIgnorePatterns ?? DEFAULT_CONFIG.assertions.networkIgnorePatterns
4405
+ },
4406
+ guardrails: {
4407
+ maxSteps: partial.guardrails?.maxSteps ?? DEFAULT_CONFIG.guardrails.maxSteps,
4408
+ allowedDomains: partial.guardrails?.allowedDomains ?? DEFAULT_CONFIG.guardrails.allowedDomains,
4409
+ allowedApps: partial.guardrails?.allowedApps ?? DEFAULT_CONFIG.guardrails.allowedApps,
4410
+ forbiddenSelectors: partial.guardrails?.forbiddenSelectors ?? DEFAULT_CONFIG.guardrails.forbiddenSelectors,
4411
+ selfHealing: partial.guardrails?.selfHealing ?? DEFAULT_CONFIG.guardrails.selfHealing
4412
+ },
4413
+ auth: {
4414
+ storageStatePath: partial.auth?.storageStatePath ?? (partial.auth !== void 0 ? DEFAULT_CONFIG.auth.storageStatePath : void 0)
4415
+ },
4416
+ history: {
4417
+ maxRuns: partial.history?.maxRuns ?? DEFAULT_CONFIG.history.maxRuns
4418
+ },
4419
+ bugLog: partial.bugLog,
4420
+ tracing: partial.tracing,
4421
+ reliability: partial.reliability
4422
+ };
4423
+ }
4424
+ function envStringVars(env) {
4425
+ return Object.fromEntries(
4426
+ Object.entries(env).filter(([, value]) => value !== void 0)
4427
+ );
4428
+ }
4429
+ function interpolateConfigStrings(value, vars) {
4430
+ if (typeof value === "string") {
4431
+ return interpolateString(value, vars).value;
4432
+ }
4433
+ if (Array.isArray(value)) {
4434
+ return value.map((item) => interpolateConfigStrings(item, vars));
4435
+ }
4436
+ if (value && typeof value === "object") {
4437
+ return Object.fromEntries(
4438
+ Object.entries(value).map(([key, item]) => [key, interpolateConfigStrings(item, vars)])
4439
+ );
4440
+ }
4441
+ return value;
4442
+ }
4443
+ function ensureAllowedDomain(allowed, urlValue) {
4444
+ try {
4445
+ const host = new URL(urlValue).hostname;
4446
+ if (!allowed.includes(host)) {
4447
+ return [...allowed, host];
4448
+ }
4449
+ } catch {
4450
+ return allowed;
4451
+ }
4452
+ return allowed;
4453
+ }
4454
+ function loadConfig(configPath) {
4455
+ const resolvedPath = configPath ? import_node_path8.default.resolve(configPath) : findConfigPath(process.cwd());
4456
+ if (!resolvedPath) {
4457
+ throw new Error("Could not find .prowl/config.yml. Run `prowl init` first.");
4458
+ }
4459
+ if (!import_node_fs7.default.existsSync(resolvedPath)) {
4460
+ throw new Error(`Config file not found at ${resolvedPath}`);
4461
+ }
4462
+ const configDir = import_node_path8.default.dirname(resolvedPath);
4463
+ if (import_node_path8.default.basename(configDir) === LEGACY_CONFIG_DIR) {
4464
+ warnLegacyConfigDir();
4465
+ }
4466
+ import_dotenv.default.config({ path: import_node_path8.default.join(configDir, ".env"), override: false });
4467
+ const raw = import_node_fs7.default.readFileSync(resolvedPath, "utf-8");
4468
+ const parsed = import_yaml.default.parse(raw) ?? {};
4469
+ const interpolated = interpolateConfigStrings(parsed, envStringVars(process.env));
4470
+ const validated = configSchema.parse(interpolated);
4471
+ const config = mergeConfig(validated);
4472
+ if (config.target.type === "web") {
4473
+ config.guardrails.allowedDomains = ensureAllowedDomain(
4474
+ config.guardrails.allowedDomains,
4475
+ config.target.url
4476
+ );
4477
+ }
4478
+ return { config, configPath: resolvedPath, configDir };
4479
+ }
4480
+ function loadHunt(huntName, configDir) {
4481
+ assertValidHuntName(huntName);
4482
+ const huntPath = import_node_path8.default.join(configDir, "hunts", `${huntName}.yml`);
4483
+ if (!import_node_fs7.default.existsSync(huntPath)) {
4484
+ throw new Error(`Hunt file not found: ${huntPath}`);
4485
+ }
4486
+ const raw = import_node_fs7.default.readFileSync(huntPath, "utf-8");
4487
+ const parsed = import_yaml.default.parse(raw) ?? {};
4488
+ const validated = huntSchema.parse(parsed);
4489
+ return validated;
4490
+ }
4491
+ function loadHuntTags(huntName, configDir) {
4492
+ assertValidHuntName(huntName);
4493
+ const huntPath = import_node_path8.default.join(configDir, "hunts", `${huntName}.yml`);
4494
+ if (!import_node_fs7.default.existsSync(huntPath)) {
4495
+ return [];
4496
+ }
4497
+ const raw = import_node_fs7.default.readFileSync(huntPath, "utf-8");
4498
+ const parsed = import_yaml.default.parse(raw) ?? {};
4499
+ return Array.isArray(parsed.tags) ? parsed.tags : [];
4500
+ }
4501
+ function loadHuntMeta(huntName, configDir) {
4502
+ assertValidHuntName(huntName);
4503
+ const huntPath = import_node_path8.default.join(configDir, "hunts", `${huntName}.yml`);
4504
+ if (!import_node_fs7.default.existsSync(huntPath)) {
4505
+ return { tags: [] };
4506
+ }
4507
+ const raw = import_node_fs7.default.readFileSync(huntPath, "utf-8");
4508
+ const parsed = import_yaml.default.parse(raw) ?? {};
4509
+ return {
4510
+ description: typeof parsed.description === "string" ? parsed.description : void 0,
4511
+ tags: Array.isArray(parsed.tags) ? parsed.tags : []
4512
+ };
4513
+ }
4514
+ function listHunts(configDir) {
4515
+ const huntsDir = import_node_path8.default.join(configDir, "hunts");
4516
+ if (!import_node_fs7.default.existsSync(huntsDir)) {
4517
+ return [];
4518
+ }
4519
+ const stats = import_node_fs7.default.statSync(huntsDir);
4520
+ if (!stats.isDirectory()) {
4521
+ throw new Error(`Hunts path is not a directory: ${huntsDir}`);
4522
+ }
4523
+ const results = [];
4524
+ function scanDir(dir) {
4525
+ const entries = import_node_fs7.default.readdirSync(dir, { withFileTypes: true });
4526
+ for (const entry of entries) {
4527
+ if (entry.isFile() && entry.name.endsWith(".yml")) {
4528
+ const fullPath = import_node_path8.default.join(dir, entry.name);
4529
+ const relative = import_node_path8.default.relative(huntsDir, fullPath);
4530
+ results.push(relative.replace(/\.yml$/, ""));
4531
+ } else if (entry.isDirectory()) {
4532
+ scanDir(import_node_path8.default.join(dir, entry.name));
4533
+ }
4534
+ }
4535
+ }
4536
+ scanDir(huntsDir);
4537
+ return results.sort((a, b) => a.localeCompare(b));
4538
+ }
4539
+
4245
4540
  // src/browser/playwright-driver.ts
4246
4541
  var import_node_fs8 = __toESM(require("fs"), 1);
4247
4542
  var import_node_path9 = __toESM(require("path"), 1);
@@ -4376,6 +4671,16 @@ function createPlaywrightDriver(page) {
4376
4671
  async hover(selector) {
4377
4672
  await page.locator(selector).hover();
4378
4673
  },
4674
+ async scroll(direction, amount = 500) {
4675
+ const deltas = {
4676
+ up: [0, -amount],
4677
+ down: [0, amount],
4678
+ left: [-amount, 0],
4679
+ right: [amount, 0]
4680
+ };
4681
+ const [x, y] = deltas[direction];
4682
+ await page.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
4683
+ },
4379
4684
  async scrollIntoView(selector) {
4380
4685
  await page.locator(selector).scrollIntoViewIfNeeded();
4381
4686
  },
@@ -5155,11 +5460,14 @@ function toVisibilitySelector(value) {
5155
5460
  if (looksLikeSelector(value)) return value;
5156
5461
  return textContainsSelector(value);
5157
5462
  }
5463
+ function countVisible(driver, selector) {
5464
+ return driver.visibleCount?.(selector) ?? driver.count(selector);
5465
+ }
5158
5466
  async function runInlineAssert(driver, policy, assertion) {
5159
5467
  if (assertion.visible !== void 0) {
5160
5468
  const selector = toVisibilitySelector(assertion.visible);
5161
5469
  policy.assertAllowedSelector(selector);
5162
- const count = await driver.count(selector);
5470
+ const count = await countVisible(driver, selector);
5163
5471
  if (count === 0) {
5164
5472
  throw new Error(`Expected visible: ${assertion.visible}`);
5165
5473
  }
@@ -5168,7 +5476,7 @@ async function runInlineAssert(driver, policy, assertion) {
5168
5476
  if (assertion.notVisible !== void 0) {
5169
5477
  const selector = toVisibilitySelector(assertion.notVisible);
5170
5478
  policy.assertAllowedSelector(selector);
5171
- const count = await driver.count(selector);
5479
+ const count = await countVisible(driver, selector);
5172
5480
  if (count > 0) {
5173
5481
  throw new Error(`Expected not visible: ${assertion.notVisible}`);
5174
5482
  }
@@ -5558,25 +5866,21 @@ var STEP_HANDLERS = {
5558
5866
  }
5559
5867
  },
5560
5868
  scroll: {
5561
- capabilities: ["evaluate"],
5869
+ // Dispatched through the driver's `scroll` verb (interact), so native mobile
5870
+ // targets synthesize a touch swipe while web keeps its `window.scrollBy`
5871
+ // behavior. The per-target step gate rejects `scroll` on macOS before here.
5872
+ capabilities: ["interact"],
5562
5873
  run: async (h) => {
5563
5874
  if (!("scroll" in h.step)) unknownStep();
5564
- const amount = h.step.scroll.amount ?? 500;
5565
- const scrollMap = {
5566
- up: [0, -amount],
5567
- down: [0, amount],
5568
- left: [-amount, 0],
5569
- right: [amount, 0]
5570
- };
5571
- const [x, y] = scrollMap[h.step.scroll.direction];
5572
- await h.driver.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
5875
+ const { direction, amount } = h.step.scroll;
5876
+ await h.driver.scroll(direction, amount);
5573
5877
  return {
5574
5878
  kind: "result",
5575
5879
  result: {
5576
5880
  type: "scroll",
5577
5881
  status: "pass",
5578
5882
  durationMs: Date.now() - h.stepStart,
5579
- value: `${h.step.scroll.direction} ${amount}px`
5883
+ value: amount === void 0 ? direction : `${direction} ${amount}px`
5580
5884
  }
5581
5885
  };
5582
5886
  }
@@ -5622,7 +5926,7 @@ var STEP_HANDLERS = {
5622
5926
  const condition = h.step.if;
5623
5927
  const selector = condition.visible ?? condition.notVisible;
5624
5928
  h.policy.assertAllowedSelector(selector);
5625
- const count = await h.driver.count(selector);
5929
+ const count = await countVisible(h.driver, selector);
5626
5930
  const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
5627
5931
  if (conditionMet) {
5628
5932
  const subResult = await h.executeNested({
@@ -5709,7 +6013,7 @@ var STEP_HANDLERS = {
5709
6013
  const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
5710
6014
  h.policy.assertAllowedSelector(whileSelector);
5711
6015
  for (let i = 0; i < maxIter; i++) {
5712
- const whileCount = await h.driver.count(whileSelector);
6016
+ const whileCount = await countVisible(h.driver, whileSelector);
5713
6017
  const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
5714
6018
  if (!shouldContinue) break;
5715
6019
  totalSubSteps += repeat.steps.length;