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