prowl-tools 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  },
@@ -1258,6 +1261,101 @@ function parseSnapshot(xml) {
1258
1261
  return parseXml(xml);
1259
1262
  }
1260
1263
 
1264
+ // src/browser/touch-gestures.ts
1265
+ var DEFAULT_SWIPE_FRACTION = 0.75;
1266
+ var SCROLL_TO_PROBE_SWIPE_FRACTION = 0.6;
1267
+ var MAX_SWIPE_FRACTION = 0.9;
1268
+ var SWIPE_HOLD_MS = 100;
1269
+ var SWIPE_MOVE_DURATION_MS = 300;
1270
+ var SCROLL_TO_SWEEP_DEPTH = 10;
1271
+ var SCROLL_TO_PROBE_DIRECTIONS = [
1272
+ ...Array.from({ length: SCROLL_TO_SWEEP_DEPTH }, () => "down"),
1273
+ ...Array.from({ length: SCROLL_TO_SWEEP_DEPTH * 2 }, () => "up")
1274
+ ];
1275
+ var MAX_SCROLL_TO_SWIPES = SCROLL_TO_PROBE_DIRECTIONS.length;
1276
+ var OPPOSITE_SWIPE_DIRECTIONS = {
1277
+ up: "down",
1278
+ down: "up",
1279
+ left: "right",
1280
+ right: "left"
1281
+ };
1282
+ async function probeScrollIntoView({
1283
+ isVisible,
1284
+ swipe,
1285
+ directions = SCROLL_TO_PROBE_DIRECTIONS
1286
+ }) {
1287
+ if (await isVisible()) {
1288
+ return true;
1289
+ }
1290
+ for (const direction of directions) {
1291
+ await swipe(direction);
1292
+ if (await isVisible()) {
1293
+ return true;
1294
+ }
1295
+ }
1296
+ return false;
1297
+ }
1298
+ function toScreenSize(value, source) {
1299
+ const record = value ?? {};
1300
+ const width = Number(record.width);
1301
+ const height = Number(record.height);
1302
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
1303
+ throw new Error(`${source} did not return a usable screen size`);
1304
+ }
1305
+ return { width, height };
1306
+ }
1307
+ function isVertical(direction) {
1308
+ return direction === "up" || direction === "down";
1309
+ }
1310
+ function swipeDistanceFor(direction, size, amount) {
1311
+ if (amount !== void 0 && !Number.isFinite(amount)) {
1312
+ throw new Error("scroll amount must be a finite number");
1313
+ }
1314
+ const axis = isVertical(direction) ? size.height : size.width;
1315
+ const requested = amount === void 0 ? axis * DEFAULT_SWIPE_FRACTION : Math.abs(amount);
1316
+ const max = axis * MAX_SWIPE_FRACTION;
1317
+ return Math.max(1, Math.round(Math.min(requested, max)));
1318
+ }
1319
+ function scrollToProbeDistanceFor(direction, size) {
1320
+ const axis = isVertical(direction) ? size.height : size.width;
1321
+ return Math.max(1, Math.round(axis * SCROLL_TO_PROBE_SWIPE_FRACTION));
1322
+ }
1323
+ function swipeEndpoints(direction, size, distance) {
1324
+ const cx = Math.round(size.width / 2);
1325
+ const cy = Math.round(size.height / 2);
1326
+ const half = Math.round(distance / 2);
1327
+ switch (direction) {
1328
+ case "down":
1329
+ return { start: { x: cx, y: cy + half }, end: { x: cx, y: cy - half } };
1330
+ case "up":
1331
+ return { start: { x: cx, y: cy - half }, end: { x: cx, y: cy + half } };
1332
+ case "right":
1333
+ return { start: { x: cx + half, y: cy }, end: { x: cx - half, y: cy } };
1334
+ case "left":
1335
+ return { start: { x: cx - half, y: cy }, end: { x: cx + half, y: cy } };
1336
+ }
1337
+ }
1338
+ function buildSwipeActions(start, end) {
1339
+ return {
1340
+ type: "pointer",
1341
+ id: "finger1",
1342
+ parameters: { pointerType: "touch" },
1343
+ actions: [
1344
+ { type: "pointerMove", duration: 0, x: start.x, y: start.y, origin: "viewport" },
1345
+ { type: "pointerDown", button: 0 },
1346
+ { type: "pause", duration: SWIPE_HOLD_MS },
1347
+ { type: "pointerMove", duration: SWIPE_MOVE_DURATION_MS, x: end.x, y: end.y, origin: "viewport" },
1348
+ { type: "pointerUp", button: 0 }
1349
+ ]
1350
+ };
1351
+ }
1352
+ function buildDirectionalSwipe(direction, size, amount) {
1353
+ const normalizedDirection = amount !== void 0 && amount < 0 ? OPPOSITE_SWIPE_DIRECTIONS[direction] : direction;
1354
+ const distance = swipeDistanceFor(normalizedDirection, size, amount);
1355
+ const { start, end } = swipeEndpoints(normalizedDirection, size, distance);
1356
+ return { actions: buildSwipeActions(start, end), distance, start, end };
1357
+ }
1358
+
1261
1359
  // src/browser/android-driver.ts
1262
1360
  var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
1263
1361
  "query",
@@ -1373,6 +1471,11 @@ function createAndroidDriver(client, options = {}) {
1373
1471
  async function fillSelector(selector, value) {
1374
1472
  await client.setValue(await resolveOne(selector), value);
1375
1473
  }
1474
+ async function swipe(direction, amount, size) {
1475
+ const actualSize = size ?? await client.windowSize();
1476
+ const { actions } = buildDirectionalSwipe(direction, actualSize, amount);
1477
+ await client.performActions(actions);
1478
+ }
1376
1479
  return {
1377
1480
  capabilities: ANDROID_CAPABILITIES,
1378
1481
  // navigation -----------------------------------------------------------
@@ -1410,8 +1513,31 @@ function createAndroidDriver(client, options = {}) {
1410
1513
  hover() {
1411
1514
  return rejectUnsupported("hover");
1412
1515
  },
1413
- scrollIntoView() {
1414
- return rejectUnsupported("scrollTo");
1516
+ // Screen-centred swipe via the W3C actions endpoint (PROWL-080). Direction
1517
+ // semantics match the web step: scrolling "down" reveals lower content, so
1518
+ // the finger drags up. See ./touch-gestures.ts.
1519
+ async scroll(direction, amount) {
1520
+ await swipe(direction, amount);
1521
+ },
1522
+ // Resolve the element, short-circuiting if it is already present in the
1523
+ // hierarchy; otherwise use the shared bounded down/up mobile probe before
1524
+ // failing with a message naming the selector and attempts.
1525
+ async scrollIntoView(selector) {
1526
+ const query = parseAndroidSelector(selector);
1527
+ let probeSize;
1528
+ const found = await probeScrollIntoView({
1529
+ isVisible: async () => (await client.findElements(query)).length > 0,
1530
+ swipe: async (direction) => {
1531
+ probeSize ??= await client.windowSize();
1532
+ await swipe(direction, scrollToProbeDistanceFor(direction, probeSize), probeSize);
1533
+ }
1534
+ });
1535
+ if (found) {
1536
+ return;
1537
+ }
1538
+ throw new Error(
1539
+ `scrollTo: element "${selector}" not visible after ${MAX_SCROLL_TO_SWIPES} scroll attempts on the Android target`
1540
+ );
1415
1541
  },
1416
1542
  setInputFiles() {
1417
1543
  return rejectUnsupported("setInputFiles");
@@ -1889,6 +2015,13 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
1889
2015
  async pressKeyCode(keyCode) {
1890
2016
  await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
1891
2017
  },
2018
+ async windowSize() {
2019
+ const value = await transport.request("GET", `${base}/window/current/size`);
2020
+ return toScreenSize(value, "uiautomator2 /window/current/size");
2021
+ },
2022
+ async performActions(actions) {
2023
+ await transport.request("POST", `${base}/actions`, { actions: [actions] });
2024
+ },
1892
2025
  async screenshotPng() {
1893
2026
  const value = await transport.request("GET", `${base}/screenshot`);
1894
2027
  if (typeof value !== "string") {
@@ -1925,10 +2058,17 @@ var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
1925
2058
  "select",
1926
2059
  "selectOption",
1927
2060
  "setInputFiles",
1928
- "waitForDownload",
1929
- "scroll"
1930
- // directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
2061
+ "waitForDownload"
1931
2062
  ]);
2063
+ var MACOS_UNSUPPORTED_STEP_TYPES = /* @__PURE__ */ new Set(["scroll"]);
2064
+ function macosUnsupportedReason(step) {
2065
+ for (const type of MACOS_UNSUPPORTED_STEP_TYPES) {
2066
+ if (type in step) {
2067
+ return type;
2068
+ }
2069
+ }
2070
+ return null;
2071
+ }
1932
2072
  function webOnlyReason(step) {
1933
2073
  for (const type of WEB_ONLY_STEP_TYPES) {
1934
2074
  if (type in step) {
@@ -1964,6 +2104,14 @@ function assertStepsSupportedByTarget(steps, target) {
1964
2104
  `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
2105
  );
1966
2106
  }
2107
+ if (target === "macos") {
2108
+ const macReason = macosUnsupportedReason(step);
2109
+ if (macReason) {
2110
+ throw new Error(
2111
+ `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).`
2112
+ );
2113
+ }
2114
+ }
1967
2115
  if ("if" in step) {
1968
2116
  assertStepsSupportedByTarget(step.if.then, target);
1969
2117
  if (step.if.else) {
@@ -2234,6 +2382,7 @@ async function launchAndroidSession(options) {
2234
2382
  readyDeadlineMs,
2235
2383
  appPackage: pkg
2236
2384
  });
2385
+ await launchPackage(runner, serial, pkg);
2237
2386
  const driver = createAndroidDriver(client, { appLabel: pkg });
2238
2387
  return { client, driver, package: pkg, serial, teardown };
2239
2388
  } catch (error) {
@@ -2254,6 +2403,7 @@ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
2254
2403
  ]);
2255
2404
  var WAIT_POLL_INTERVAL_MS2 = 250;
2256
2405
  var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
2406
+ var DISPLAYED_CHECK_CONCURRENCY = 4;
2257
2407
  var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
2258
2408
  function escapePredicateArg(value) {
2259
2409
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
@@ -2328,6 +2478,64 @@ function createIosDriver(client, options) {
2328
2478
  async function fillSelector(selector, value) {
2329
2479
  await client.setValue(await resolveOne(selector), value);
2330
2480
  }
2481
+ async function swipe(direction, amount, size) {
2482
+ const actualSize = size ?? await client.windowSize();
2483
+ const { actions } = buildDirectionalSwipe(direction, actualSize, amount);
2484
+ await client.performActions(actions);
2485
+ }
2486
+ async function visibleElementIds(query) {
2487
+ const ids = await client.findElements(query);
2488
+ const visible = Array.from({ length: ids.length }, () => null);
2489
+ let nextIndex = 0;
2490
+ const workerCount = Math.min(DISPLAYED_CHECK_CONCURRENCY, ids.length);
2491
+ await Promise.all(
2492
+ Array.from({ length: workerCount }, async () => {
2493
+ for (; ; ) {
2494
+ const index = nextIndex;
2495
+ nextIndex += 1;
2496
+ if (index >= ids.length) {
2497
+ return;
2498
+ }
2499
+ const id = ids[index];
2500
+ if (await client.isDisplayed(id)) {
2501
+ visible[index] = id;
2502
+ }
2503
+ }
2504
+ })
2505
+ );
2506
+ return visible.filter((id) => id !== null);
2507
+ }
2508
+ async function hasVisibleElement(query) {
2509
+ const ids = await client.findElements(query);
2510
+ if (ids.length === 0) {
2511
+ return false;
2512
+ }
2513
+ if (await client.isDisplayed(ids[0])) {
2514
+ return true;
2515
+ }
2516
+ let found = false;
2517
+ let nextIndex = 1;
2518
+ const workerCount = Math.min(DISPLAYED_CHECK_CONCURRENCY, ids.length - 1);
2519
+ await Promise.all(
2520
+ Array.from({ length: workerCount }, async () => {
2521
+ for (; ; ) {
2522
+ if (found) {
2523
+ return;
2524
+ }
2525
+ const index = nextIndex;
2526
+ nextIndex += 1;
2527
+ if (index >= ids.length) {
2528
+ return;
2529
+ }
2530
+ if (await client.isDisplayed(ids[index])) {
2531
+ found = true;
2532
+ return;
2533
+ }
2534
+ }
2535
+ })
2536
+ );
2537
+ return found;
2538
+ }
2331
2539
  async function pressKey(key) {
2332
2540
  const name = key.trim().toLowerCase();
2333
2541
  if (name === "enter" || name === "return") {
@@ -2359,6 +2567,9 @@ function createIosDriver(client, options) {
2359
2567
  async count(selector) {
2360
2568
  return (await client.findElements(parseIosSelector(selector))).length;
2361
2569
  },
2570
+ async visibleCount(selector) {
2571
+ return (await visibleElementIds(parseIosSelector(selector))).length;
2572
+ },
2362
2573
  async textContent(selector) {
2363
2574
  const id = await client.findElement(parseIosSelector(selector));
2364
2575
  if (id === null) {
@@ -2383,15 +2594,38 @@ function createIosDriver(client, options) {
2383
2594
  hover() {
2384
2595
  return rejectUnsupported("hover");
2385
2596
  },
2386
- scrollIntoView() {
2387
- return rejectUnsupported("scrollTo");
2597
+ // Screen-centred swipe via the W3C actions endpoint (PROWL-080). Direction
2598
+ // semantics match the web step: scrolling "down" reveals lower content, so
2599
+ // the finger drags up. See ./touch-gestures.ts.
2600
+ async scroll(direction, amount) {
2601
+ await swipe(direction, amount);
2602
+ },
2603
+ // Resolve the element, short-circuiting only if WDA reports a matching
2604
+ // element displayed in the viewport; hierarchy-only matches can be offscreen.
2605
+ // Otherwise use the shared bounded down/up mobile probe before failing.
2606
+ async scrollIntoView(selector) {
2607
+ const query = parseIosSelector(selector);
2608
+ let probeSize;
2609
+ const found = await probeScrollIntoView({
2610
+ isVisible: () => hasVisibleElement(query),
2611
+ swipe: async (direction) => {
2612
+ probeSize ??= await client.windowSize();
2613
+ await swipe(direction, scrollToProbeDistanceFor(direction, probeSize), probeSize);
2614
+ }
2615
+ });
2616
+ if (found) {
2617
+ return;
2618
+ }
2619
+ throw new Error(
2620
+ `scrollTo: element "${selector}" not visible after ${MAX_SCROLL_TO_SWIPES} scroll attempts on the iOS target`
2621
+ );
2388
2622
  },
2389
2623
  setInputFiles() {
2390
2624
  return rejectUnsupported("setInputFiles");
2391
2625
  },
2392
2626
  // semantic locators ----------------------------------------------------
2393
2627
  async countByRole(role, name) {
2394
- return (await client.findElements({ by: "role", role, name })).length;
2628
+ return (await visibleElementIds({ by: "role", role, name })).length;
2395
2629
  },
2396
2630
  async clickFirstByRole(role, name) {
2397
2631
  const id = await client.findElement({ by: "role", role, name });
@@ -2401,7 +2635,7 @@ function createIosDriver(client, options) {
2401
2635
  await client.click(id);
2402
2636
  },
2403
2637
  async countByLabel(label) {
2404
- return (await client.findElements({ by: "label", value: label })).length;
2638
+ return (await visibleElementIds({ by: "label", value: label })).length;
2405
2639
  },
2406
2640
  async fillFirstByLabel(label, value) {
2407
2641
  const id = await client.findElement({ by: "label", value: label });
@@ -2419,7 +2653,7 @@ function createIosDriver(client, options) {
2419
2653
  const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
2420
2654
  const deadline = Date.now() + timeoutMs;
2421
2655
  for (; ; ) {
2422
- if ((await client.findElements(query)).length > 0) {
2656
+ if (await hasVisibleElement(query)) {
2423
2657
  return;
2424
2658
  }
2425
2659
  if (Date.now() >= deadline) {
@@ -2919,12 +3153,29 @@ function createWdaAgentClient(transport, sessionId) {
2919
3153
  const value = await transport.request("GET", `${base}/element/${elementId}/text`);
2920
3154
  return typeof value === "string" ? value : value == null ? null : String(value);
2921
3155
  },
3156
+ async isDisplayed(elementId) {
3157
+ try {
3158
+ return await transport.request("GET", `${base}/element/${elementId}/displayed`) === true;
3159
+ } catch (error) {
3160
+ if (isNoSuchElement2(error)) {
3161
+ return false;
3162
+ }
3163
+ throw error;
3164
+ }
3165
+ },
2922
3166
  async sendKeys(keys) {
2923
3167
  await transport.request("POST", `${base}/wda/keys`, { value: keys });
2924
3168
  },
2925
3169
  async homescreen() {
2926
3170
  await transport.request("POST", "/wda/homescreen", {});
2927
3171
  },
3172
+ async windowSize() {
3173
+ const value = await transport.request("GET", `${base}/window/size`);
3174
+ return toScreenSize(value, "WebDriverAgent /window/size");
3175
+ },
3176
+ async performActions(actions) {
3177
+ await transport.request("POST", `${base}/actions`, { actions: [actions] });
3178
+ },
2928
3179
  async source() {
2929
3180
  const value = await transport.request("GET", "/source");
2930
3181
  if (typeof value !== "string") {
@@ -3619,339 +3870,87 @@ var huntSchema = import_zod.z.object({
3619
3870
  }).strict().optional()
3620
3871
  }).strict();
3621
3872
 
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) {
3873
+ // src/config/interpolate.ts
3874
+ var import_node_crypto2 = __toESM(require("crypto"), 1);
3875
+ var VAR_PATTERN = /\{\{([A-Z0-9_]+)\}\}/g;
3876
+ function collectInterpolatedValues(input, vars, values) {
3877
+ if (typeof input === "string") {
3878
+ for (const match of input.matchAll(VAR_PATTERN)) {
3879
+ const varValue = vars[match[1]];
3880
+ if (varValue) values.add(varValue);
3881
+ }
3667
3882
  return;
3668
3883
  }
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
- }
3884
+ if (Array.isArray(input)) {
3885
+ for (const item of input) {
3886
+ collectInterpolatedValues(item, vars, values);
3682
3887
  }
3683
- const parent = import_node_path8.default.dirname(current);
3684
- if (parent === current) {
3685
- break;
3888
+ return;
3889
+ }
3890
+ if (input && typeof input === "object") {
3891
+ for (const [key, value] of Object.entries(input)) {
3892
+ collectInterpolatedValues(key, vars, values);
3893
+ collectInterpolatedValues(value, vars, values);
3686
3894
  }
3687
- current = parent;
3688
3895
  }
3689
- return null;
3690
3896
  }
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.`);
3897
+ function interpolateString(input, vars) {
3898
+ const usedVars = [];
3899
+ const value = input.replace(VAR_PATTERN, (_, name) => {
3900
+ const varValue = vars[name];
3901
+ if (varValue === void 0) {
3902
+ throw new Error(`Missing variable: ${name}`);
3704
3903
  }
3705
- return preset;
3706
- }
3707
- return value;
3904
+ usedVars.push(name);
3905
+ return varValue;
3906
+ });
3907
+ return { value, usedVars };
3708
3908
  }
3709
- function resolveTarget(target) {
3710
- const type = target?.type;
3711
- if (type === "macos") {
3712
- return { type: "macos", app: target.app };
3909
+ var RANDOM_FIRST_NAMES = ["Alex", "Jordan", "Morgan", "Taylor", "Casey", "Riley", "Quinn", "Avery"];
3910
+ var RANDOM_LAST_NAMES = ["Smith", "Johnson", "Brown", "Davis", "Wilson", "Clark", "Hall", "Young"];
3911
+ function generateRandomVars(randomSource) {
3912
+ const random = randomSource?.random ?? Math.random;
3913
+ const randomBytes = randomSource?.randomBytes ?? import_node_crypto2.default.randomBytes;
3914
+ const randomUUID = randomSource?.randomUUID ?? import_node_crypto2.default.randomUUID;
3915
+ const hex = randomBytes(4).toString("hex");
3916
+ const firstIndex = Math.floor(random() * RANDOM_FIRST_NAMES.length);
3917
+ const lastIndex = Math.floor(random() * RANDOM_LAST_NAMES.length);
3918
+ const num2 = Math.floor(random() * 9e3) + 1e3;
3919
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
3920
+ let text = "";
3921
+ for (let i = 0; i < 8; i++) {
3922
+ text += chars[Math.floor(random() * chars.length)];
3713
3923
  }
3714
- if (type === "android") {
3715
- const androidTarget = target;
3924
+ return {
3925
+ RANDOM_EMAIL: `prowl_${hex}@test.com`,
3926
+ RANDOM_NAME: `${RANDOM_FIRST_NAMES[firstIndex]} ${RANDOM_LAST_NAMES[lastIndex]}`,
3927
+ RANDOM_NUMBER: String(num2),
3928
+ RANDOM_UUID: randomUUID(),
3929
+ RANDOM_TEXT: text
3930
+ };
3931
+ }
3932
+ function interpolateStep(step, vars, stepPath2, redacted) {
3933
+ const isExplicitFill = (value) => typeof value.selector === "string" && typeof value.value === "string";
3934
+ const interpolateSinglePair = (record) => {
3935
+ const entries = Object.entries(record);
3936
+ if (entries.length !== 1) {
3937
+ throw new Error("Shorthand step expects exactly one key-value pair");
3938
+ }
3939
+ const [key, value] = entries[0];
3716
3940
  return {
3717
- type: "android",
3718
- app: androidTarget.app,
3719
- ...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
3720
- ...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
3941
+ [interpolateString(key, vars).value]: interpolateString(value, vars).value
3721
3942
  };
3943
+ };
3944
+ if ("navigate" in step) {
3945
+ const result = interpolateString(step.navigate, vars);
3946
+ return { navigate: result.value };
3722
3947
  }
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 } };
3948
+ if ("click" in step) {
3949
+ if (typeof step.click === "string") {
3950
+ return { click: interpolateString(step.click, vars).value };
3951
+ }
3952
+ const result = interpolateString(step.click.selector, vars);
3953
+ return { click: { selector: result.value } };
3955
3954
  }
3956
3955
  if ("fill" in step) {
3957
3956
  if (isExplicitFill(step.fill)) {
@@ -4242,6 +4241,278 @@ function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
4242
4241
  };
4243
4242
  }
4244
4243
 
4244
+ // src/config/loader.ts
4245
+ var DEFAULT_WEB_URL = "http://localhost:3000";
4246
+ var DEFAULT_CONFIG = {
4247
+ target: {
4248
+ type: "web",
4249
+ url: DEFAULT_WEB_URL
4250
+ },
4251
+ browser: {
4252
+ headless: true,
4253
+ slowMo: 0,
4254
+ timeout: 3e4,
4255
+ engine: "chromium",
4256
+ viewport: { width: 1280, height: 720 }
4257
+ },
4258
+ artifacts: {
4259
+ screenshots: "on-failure",
4260
+ networkHar: false,
4261
+ console: true,
4262
+ junit: false
4263
+ },
4264
+ assertions: {
4265
+ noConsoleErrors: true,
4266
+ noNetworkErrors: true,
4267
+ maxTotalTimeMs: 3e4,
4268
+ networkIgnorePatterns: []
4269
+ },
4270
+ guardrails: {
4271
+ maxSteps: 50,
4272
+ allowedDomains: ["localhost", "127.0.0.1", "0.0.0.0"],
4273
+ allowedApps: [],
4274
+ forbiddenSelectors: ["[data-danger]", ".delete-btn"],
4275
+ selfHealing: false
4276
+ },
4277
+ auth: {
4278
+ storageStatePath: ".prowl/auth-state.json"
4279
+ },
4280
+ history: {
4281
+ maxRuns: 100
4282
+ }
4283
+ };
4284
+ var CONFIG_DIR = ".prowl";
4285
+ var LEGACY_CONFIG_DIR = ".prowlqa";
4286
+ var legacyDirWarned = false;
4287
+ function warnLegacyConfigDir() {
4288
+ if (legacyDirWarned) {
4289
+ return;
4290
+ }
4291
+ legacyDirWarned = true;
4292
+ console.warn(
4293
+ 'Warning: the ".prowlqa/" config directory is deprecated; rename it to ".prowl/". Support for ".prowlqa/" will be removed in a future release.'
4294
+ );
4295
+ }
4296
+ function findConfigPath(startDir) {
4297
+ let current = startDir;
4298
+ while (current) {
4299
+ for (const dir of [CONFIG_DIR, LEGACY_CONFIG_DIR]) {
4300
+ const candidate = import_node_path8.default.join(current, dir, "config.yml");
4301
+ if (import_node_fs7.default.existsSync(candidate)) {
4302
+ return candidate;
4303
+ }
4304
+ }
4305
+ const parent = import_node_path8.default.dirname(current);
4306
+ if (parent === current) {
4307
+ break;
4308
+ }
4309
+ current = parent;
4310
+ }
4311
+ return null;
4312
+ }
4313
+ var VIEWPORT_PRESETS = {
4314
+ mobile: { width: 375, height: 812 },
4315
+ tablet: { width: 768, height: 1024 },
4316
+ desktop: { width: 1280, height: 720 }
4317
+ };
4318
+ function resolveViewport(value) {
4319
+ if (value === void 0) {
4320
+ return DEFAULT_CONFIG.browser.viewport;
4321
+ }
4322
+ if (typeof value === "string") {
4323
+ const preset = VIEWPORT_PRESETS[value];
4324
+ if (!preset) {
4325
+ throw new Error(`Unknown viewport preset: "${value}". Use mobile, tablet, or desktop.`);
4326
+ }
4327
+ return preset;
4328
+ }
4329
+ return value;
4330
+ }
4331
+ function resolveTarget(target) {
4332
+ const type = target?.type;
4333
+ if (type === "macos") {
4334
+ return { type: "macos", app: target.app };
4335
+ }
4336
+ if (type === "android") {
4337
+ const androidTarget = target;
4338
+ return {
4339
+ type: "android",
4340
+ app: androidTarget.app,
4341
+ ...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
4342
+ ...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
4343
+ };
4344
+ }
4345
+ if (type === "ios") {
4346
+ const iosTarget = target;
4347
+ return {
4348
+ type: "ios",
4349
+ app: iosTarget.app,
4350
+ ...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
4351
+ ...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
4352
+ };
4353
+ }
4354
+ return {
4355
+ type: "web",
4356
+ url: target?.url ?? DEFAULT_WEB_URL
4357
+ };
4358
+ }
4359
+ function mergeConfig(partial) {
4360
+ return {
4361
+ target: resolveTarget(partial.target),
4362
+ browser: {
4363
+ headless: partial.browser?.headless ?? DEFAULT_CONFIG.browser.headless,
4364
+ slowMo: partial.browser?.slowMo ?? DEFAULT_CONFIG.browser.slowMo,
4365
+ timeout: partial.browser?.timeout ?? DEFAULT_CONFIG.browser.timeout,
4366
+ engine: partial.browser?.engine ?? DEFAULT_CONFIG.browser.engine,
4367
+ channel: partial.browser?.channel,
4368
+ viewport: resolveViewport(partial.browser?.viewport)
4369
+ },
4370
+ artifacts: {
4371
+ screenshots: partial.artifacts?.screenshots ?? DEFAULT_CONFIG.artifacts.screenshots,
4372
+ networkHar: partial.artifacts?.networkHar ?? DEFAULT_CONFIG.artifacts.networkHar,
4373
+ console: partial.artifacts?.console ?? DEFAULT_CONFIG.artifacts.console,
4374
+ junit: partial.artifacts?.junit ?? DEFAULT_CONFIG.artifacts.junit
4375
+ },
4376
+ assertions: {
4377
+ noConsoleErrors: partial.assertions?.noConsoleErrors ?? DEFAULT_CONFIG.assertions.noConsoleErrors,
4378
+ noNetworkErrors: partial.assertions?.noNetworkErrors ?? DEFAULT_CONFIG.assertions.noNetworkErrors,
4379
+ maxTotalTimeMs: partial.assertions?.maxTotalTimeMs ?? DEFAULT_CONFIG.assertions.maxTotalTimeMs,
4380
+ networkIgnorePatterns: partial.assertions?.networkIgnorePatterns ?? DEFAULT_CONFIG.assertions.networkIgnorePatterns
4381
+ },
4382
+ guardrails: {
4383
+ maxSteps: partial.guardrails?.maxSteps ?? DEFAULT_CONFIG.guardrails.maxSteps,
4384
+ allowedDomains: partial.guardrails?.allowedDomains ?? DEFAULT_CONFIG.guardrails.allowedDomains,
4385
+ allowedApps: partial.guardrails?.allowedApps ?? DEFAULT_CONFIG.guardrails.allowedApps,
4386
+ forbiddenSelectors: partial.guardrails?.forbiddenSelectors ?? DEFAULT_CONFIG.guardrails.forbiddenSelectors,
4387
+ selfHealing: partial.guardrails?.selfHealing ?? DEFAULT_CONFIG.guardrails.selfHealing
4388
+ },
4389
+ auth: {
4390
+ storageStatePath: partial.auth?.storageStatePath ?? (partial.auth !== void 0 ? DEFAULT_CONFIG.auth.storageStatePath : void 0)
4391
+ },
4392
+ history: {
4393
+ maxRuns: partial.history?.maxRuns ?? DEFAULT_CONFIG.history.maxRuns
4394
+ },
4395
+ bugLog: partial.bugLog,
4396
+ tracing: partial.tracing,
4397
+ reliability: partial.reliability
4398
+ };
4399
+ }
4400
+ function envStringVars(env) {
4401
+ return Object.fromEntries(
4402
+ Object.entries(env).filter(([, value]) => value !== void 0)
4403
+ );
4404
+ }
4405
+ function interpolateConfigStrings(value, vars) {
4406
+ if (typeof value === "string") {
4407
+ return interpolateString(value, vars).value;
4408
+ }
4409
+ if (Array.isArray(value)) {
4410
+ return value.map((item) => interpolateConfigStrings(item, vars));
4411
+ }
4412
+ if (value && typeof value === "object") {
4413
+ return Object.fromEntries(
4414
+ Object.entries(value).map(([key, item]) => [key, interpolateConfigStrings(item, vars)])
4415
+ );
4416
+ }
4417
+ return value;
4418
+ }
4419
+ function ensureAllowedDomain(allowed, urlValue) {
4420
+ try {
4421
+ const host = new URL(urlValue).hostname;
4422
+ if (!allowed.includes(host)) {
4423
+ return [...allowed, host];
4424
+ }
4425
+ } catch {
4426
+ return allowed;
4427
+ }
4428
+ return allowed;
4429
+ }
4430
+ function loadConfig(configPath) {
4431
+ const resolvedPath = configPath ? import_node_path8.default.resolve(configPath) : findConfigPath(process.cwd());
4432
+ if (!resolvedPath) {
4433
+ throw new Error("Could not find .prowl/config.yml. Run `prowl init` first.");
4434
+ }
4435
+ if (!import_node_fs7.default.existsSync(resolvedPath)) {
4436
+ throw new Error(`Config file not found at ${resolvedPath}`);
4437
+ }
4438
+ const configDir = import_node_path8.default.dirname(resolvedPath);
4439
+ if (import_node_path8.default.basename(configDir) === LEGACY_CONFIG_DIR) {
4440
+ warnLegacyConfigDir();
4441
+ }
4442
+ import_dotenv.default.config({ path: import_node_path8.default.join(configDir, ".env"), override: false });
4443
+ const raw = import_node_fs7.default.readFileSync(resolvedPath, "utf-8");
4444
+ const parsed = import_yaml.default.parse(raw) ?? {};
4445
+ const interpolated = interpolateConfigStrings(parsed, envStringVars(process.env));
4446
+ const validated = configSchema.parse(interpolated);
4447
+ const config = mergeConfig(validated);
4448
+ if (config.target.type === "web") {
4449
+ config.guardrails.allowedDomains = ensureAllowedDomain(
4450
+ config.guardrails.allowedDomains,
4451
+ config.target.url
4452
+ );
4453
+ }
4454
+ return { config, configPath: resolvedPath, configDir };
4455
+ }
4456
+ function loadHunt(huntName, configDir) {
4457
+ assertValidHuntName(huntName);
4458
+ const huntPath = import_node_path8.default.join(configDir, "hunts", `${huntName}.yml`);
4459
+ if (!import_node_fs7.default.existsSync(huntPath)) {
4460
+ throw new Error(`Hunt file not found: ${huntPath}`);
4461
+ }
4462
+ const raw = import_node_fs7.default.readFileSync(huntPath, "utf-8");
4463
+ const parsed = import_yaml.default.parse(raw) ?? {};
4464
+ const validated = huntSchema.parse(parsed);
4465
+ return validated;
4466
+ }
4467
+ function loadHuntTags(huntName, configDir) {
4468
+ assertValidHuntName(huntName);
4469
+ const huntPath = import_node_path8.default.join(configDir, "hunts", `${huntName}.yml`);
4470
+ if (!import_node_fs7.default.existsSync(huntPath)) {
4471
+ return [];
4472
+ }
4473
+ const raw = import_node_fs7.default.readFileSync(huntPath, "utf-8");
4474
+ const parsed = import_yaml.default.parse(raw) ?? {};
4475
+ return Array.isArray(parsed.tags) ? parsed.tags : [];
4476
+ }
4477
+ function loadHuntMeta(huntName, configDir) {
4478
+ assertValidHuntName(huntName);
4479
+ const huntPath = import_node_path8.default.join(configDir, "hunts", `${huntName}.yml`);
4480
+ if (!import_node_fs7.default.existsSync(huntPath)) {
4481
+ return { tags: [] };
4482
+ }
4483
+ const raw = import_node_fs7.default.readFileSync(huntPath, "utf-8");
4484
+ const parsed = import_yaml.default.parse(raw) ?? {};
4485
+ return {
4486
+ description: typeof parsed.description === "string" ? parsed.description : void 0,
4487
+ tags: Array.isArray(parsed.tags) ? parsed.tags : []
4488
+ };
4489
+ }
4490
+ function listHunts(configDir) {
4491
+ const huntsDir = import_node_path8.default.join(configDir, "hunts");
4492
+ if (!import_node_fs7.default.existsSync(huntsDir)) {
4493
+ return [];
4494
+ }
4495
+ const stats = import_node_fs7.default.statSync(huntsDir);
4496
+ if (!stats.isDirectory()) {
4497
+ throw new Error(`Hunts path is not a directory: ${huntsDir}`);
4498
+ }
4499
+ const results = [];
4500
+ function scanDir(dir) {
4501
+ const entries = import_node_fs7.default.readdirSync(dir, { withFileTypes: true });
4502
+ for (const entry of entries) {
4503
+ if (entry.isFile() && entry.name.endsWith(".yml")) {
4504
+ const fullPath = import_node_path8.default.join(dir, entry.name);
4505
+ const relative = import_node_path8.default.relative(huntsDir, fullPath);
4506
+ results.push(relative.replace(/\.yml$/, ""));
4507
+ } else if (entry.isDirectory()) {
4508
+ scanDir(import_node_path8.default.join(dir, entry.name));
4509
+ }
4510
+ }
4511
+ }
4512
+ scanDir(huntsDir);
4513
+ return results.sort((a, b) => a.localeCompare(b));
4514
+ }
4515
+
4245
4516
  // src/browser/playwright-driver.ts
4246
4517
  var import_node_fs8 = __toESM(require("fs"), 1);
4247
4518
  var import_node_path9 = __toESM(require("path"), 1);
@@ -4376,6 +4647,16 @@ function createPlaywrightDriver(page) {
4376
4647
  async hover(selector) {
4377
4648
  await page.locator(selector).hover();
4378
4649
  },
4650
+ async scroll(direction, amount = 500) {
4651
+ const deltas = {
4652
+ up: [0, -amount],
4653
+ down: [0, amount],
4654
+ left: [-amount, 0],
4655
+ right: [amount, 0]
4656
+ };
4657
+ const [x, y] = deltas[direction];
4658
+ await page.evaluate(([sx, sy]) => window.scrollBy(sx, sy), [x, y]);
4659
+ },
4379
4660
  async scrollIntoView(selector) {
4380
4661
  await page.locator(selector).scrollIntoViewIfNeeded();
4381
4662
  },
@@ -5155,11 +5436,14 @@ function toVisibilitySelector(value) {
5155
5436
  if (looksLikeSelector(value)) return value;
5156
5437
  return textContainsSelector(value);
5157
5438
  }
5439
+ function countVisible(driver, selector) {
5440
+ return driver.visibleCount?.(selector) ?? driver.count(selector);
5441
+ }
5158
5442
  async function runInlineAssert(driver, policy, assertion) {
5159
5443
  if (assertion.visible !== void 0) {
5160
5444
  const selector = toVisibilitySelector(assertion.visible);
5161
5445
  policy.assertAllowedSelector(selector);
5162
- const count = await driver.count(selector);
5446
+ const count = await countVisible(driver, selector);
5163
5447
  if (count === 0) {
5164
5448
  throw new Error(`Expected visible: ${assertion.visible}`);
5165
5449
  }
@@ -5168,7 +5452,7 @@ async function runInlineAssert(driver, policy, assertion) {
5168
5452
  if (assertion.notVisible !== void 0) {
5169
5453
  const selector = toVisibilitySelector(assertion.notVisible);
5170
5454
  policy.assertAllowedSelector(selector);
5171
- const count = await driver.count(selector);
5455
+ const count = await countVisible(driver, selector);
5172
5456
  if (count > 0) {
5173
5457
  throw new Error(`Expected not visible: ${assertion.notVisible}`);
5174
5458
  }
@@ -5558,25 +5842,21 @@ var STEP_HANDLERS = {
5558
5842
  }
5559
5843
  },
5560
5844
  scroll: {
5561
- capabilities: ["evaluate"],
5845
+ // Dispatched through the driver's `scroll` verb (interact), so native mobile
5846
+ // targets synthesize a touch swipe while web keeps its `window.scrollBy`
5847
+ // behavior. The per-target step gate rejects `scroll` on macOS before here.
5848
+ capabilities: ["interact"],
5562
5849
  run: async (h) => {
5563
5850
  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]);
5851
+ const { direction, amount } = h.step.scroll;
5852
+ await h.driver.scroll(direction, amount);
5573
5853
  return {
5574
5854
  kind: "result",
5575
5855
  result: {
5576
5856
  type: "scroll",
5577
5857
  status: "pass",
5578
5858
  durationMs: Date.now() - h.stepStart,
5579
- value: `${h.step.scroll.direction} ${amount}px`
5859
+ value: amount === void 0 ? direction : `${direction} ${amount}px`
5580
5860
  }
5581
5861
  };
5582
5862
  }
@@ -5622,7 +5902,7 @@ var STEP_HANDLERS = {
5622
5902
  const condition = h.step.if;
5623
5903
  const selector = condition.visible ?? condition.notVisible;
5624
5904
  h.policy.assertAllowedSelector(selector);
5625
- const count = await h.driver.count(selector);
5905
+ const count = await countVisible(h.driver, selector);
5626
5906
  const conditionMet = condition.visible !== void 0 ? count > 0 : count === 0;
5627
5907
  if (conditionMet) {
5628
5908
  const subResult = await h.executeNested({
@@ -5709,7 +5989,7 @@ var STEP_HANDLERS = {
5709
5989
  const whileSelector = repeat.while.visible ?? repeat.while.notVisible;
5710
5990
  h.policy.assertAllowedSelector(whileSelector);
5711
5991
  for (let i = 0; i < maxIter; i++) {
5712
- const whileCount = await h.driver.count(whileSelector);
5992
+ const whileCount = await countVisible(h.driver, whileSelector);
5713
5993
  const shouldContinue = repeat.while.visible !== void 0 ? whileCount > 0 : whileCount === 0;
5714
5994
  if (!shouldContinue) break;
5715
5995
  totalSubSteps += repeat.steps.length;