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/README.md +14 -1
- package/dist/{chunk-R7NUH44M.js → chunk-CWLRDV5P.js} +290 -399
- package/dist/chunk-CWLRDV5P.js.map +1 -0
- package/dist/{chunk-O3OUTZ2P.js → chunk-JFJQNJSJ.js} +394 -2
- package/dist/chunk-JFJQNJSJ.js.map +1 -0
- package/dist/index.cjs +930 -641
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/lib.cjs +626 -346
- package/dist/lib.cjs.map +1 -1
- package/dist/lib.d.cts +71 -17
- package/dist/lib.d.ts +71 -17
- package/dist/lib.js +3 -3
- package/dist/{loader-X37URHUV.js → loader-JTHA4BYG.js} +2 -2
- package/package.json +1 -1
- package/dist/chunk-O3OUTZ2P.js.map +0 -1
- package/dist/chunk-R7NUH44M.js.map +0 -1
- /package/dist/{loader-X37URHUV.js.map → loader-JTHA4BYG.js.map} +0 -0
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
|
-
|
|
1414
|
-
|
|
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
|
-
|
|
2387
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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/
|
|
3623
|
-
var
|
|
3624
|
-
var
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
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
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
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
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
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
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
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
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3904
|
+
usedVars.push(name);
|
|
3905
|
+
return varValue;
|
|
3906
|
+
});
|
|
3907
|
+
return { value, usedVars };
|
|
3708
3908
|
}
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
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
|
-
|
|
3715
|
-
|
|
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
|
-
|
|
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 (
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
5565
|
-
|
|
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: `${
|
|
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
|
|
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
|
|
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;
|