prowl-tools 0.1.4 → 0.1.6
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/NOTICE +10 -0
- package/README.md +519 -2
- package/dist/{chunk-ZEFVTKQT.js → chunk-5KQR3IR3.js} +3321 -346
- package/dist/chunk-5KQR3IR3.js.map +1 -0
- package/dist/{chunk-MBAIGNVO.js → chunk-WHAMB4TY.js} +43 -3
- package/dist/chunk-WHAMB4TY.js.map +1 -0
- package/dist/index.cjs +3527 -481
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +325 -67
- package/dist/index.js.map +1 -1
- package/dist/lib.cjs +3444 -325
- package/dist/lib.cjs.map +1 -1
- package/dist/lib.d.cts +1236 -11
- package/dist/lib.d.ts +1236 -11
- package/dist/lib.js +194 -2
- package/dist/{loader-XMTDB6OL.js → loader-PBBYV3U7.js} +2 -2
- package/package.json +5 -1
- package/dist/chunk-MBAIGNVO.js.map +0 -1
- package/dist/chunk-ZEFVTKQT.js.map +0 -1
- /package/dist/{loader-XMTDB6OL.js.map → loader-PBBYV3U7.js.map} +0 -0
package/dist/index.cjs
CHANGED
|
@@ -59,7 +59,7 @@ var init_hunt_name = __esm({
|
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
// src/config/schema.ts
|
|
62
|
-
var import_zod, webTargetSchema, macosTargetSchema, targetSchema, configSchema, navigateStepSchema, clickStepSchema, singleKeyValueSchema, fillStepSchema, typeStepSchema, pressStepSchema, waitForSelectorStepSchema, waitStepSchema, waitForUrlStepSchema, waitForNetworkIdleStepSchema, selectOptionStepSchema, selectStepSchema, onDialogStepSchema, setInputFilesStepSchema, inlineAssertStepSchema, runHuntStepSchema, hoverStepSchema, scrollStepSchema, scrollToStepSchema, screenshotStepSchema, ifStepSchema, repeatStepSchema, mockRouteStepSchema, unmockRouteStepSchema, evalScriptStepSchema, runScriptStepSchema, assertScreenshotStepSchema, copyTextStepSchema, waitForDownloadStepSchema, stepSchema, assertionSchema, huntSchema;
|
|
62
|
+
var import_zod, webTargetSchema, macosTargetSchema, androidTargetSchema, iosTargetSchema, targetSchema, configSchema, navigateStepSchema, clickStepSchema, singleKeyValueSchema, fillStepSchema, typeStepSchema, pressStepSchema, waitForSelectorStepSchema, waitStepSchema, waitForUrlStepSchema, waitForNetworkIdleStepSchema, selectOptionStepSchema, selectStepSchema, onDialogStepSchema, setInputFilesStepSchema, inlineAssertStepSchema, runHuntStepSchema, hoverStepSchema, scrollStepSchema, scrollToStepSchema, screenshotStepSchema, ifStepSchema, repeatStepSchema, mockRouteStepSchema, unmockRouteStepSchema, evalScriptStepSchema, runScriptStepSchema, assertScreenshotStepSchema, assertWithAiStepSchema, copyTextStepSchema, waitForDownloadStepSchema, stepSchema, assertionSchema, huntSchema;
|
|
63
63
|
var init_schema = __esm({
|
|
64
64
|
"src/config/schema.ts"() {
|
|
65
65
|
"use strict";
|
|
@@ -74,7 +74,24 @@ var init_schema = __esm({
|
|
|
74
74
|
type: import_zod.z.literal("macos"),
|
|
75
75
|
app: import_zod.z.string().min(1)
|
|
76
76
|
}).strict();
|
|
77
|
-
|
|
77
|
+
androidTargetSchema = import_zod.z.object({
|
|
78
|
+
type: import_zod.z.literal("android"),
|
|
79
|
+
app: import_zod.z.string().min(1),
|
|
80
|
+
deviceSerial: import_zod.z.string().min(1).optional(),
|
|
81
|
+
coldStart: import_zod.z.boolean().optional()
|
|
82
|
+
}).strict();
|
|
83
|
+
iosTargetSchema = import_zod.z.object({
|
|
84
|
+
type: import_zod.z.literal("ios"),
|
|
85
|
+
app: import_zod.z.string().min(1),
|
|
86
|
+
udid: import_zod.z.string().min(1).optional(),
|
|
87
|
+
coldStart: import_zod.z.boolean().optional()
|
|
88
|
+
}).strict();
|
|
89
|
+
targetSchema = import_zod.z.union([
|
|
90
|
+
macosTargetSchema,
|
|
91
|
+
androidTargetSchema,
|
|
92
|
+
iosTargetSchema,
|
|
93
|
+
webTargetSchema
|
|
94
|
+
]);
|
|
78
95
|
configSchema = import_zod.z.object({
|
|
79
96
|
target: targetSchema,
|
|
80
97
|
browser: import_zod.z.object({
|
|
@@ -294,6 +311,9 @@ var init_schema = __esm({
|
|
|
294
311
|
threshold: import_zod.z.number().min(0).max(1).optional()
|
|
295
312
|
}).strict()
|
|
296
313
|
}).strict();
|
|
314
|
+
assertWithAiStepSchema = import_zod.z.object({
|
|
315
|
+
assertWithAI: import_zod.z.string().min(1)
|
|
316
|
+
}).strict();
|
|
297
317
|
copyTextStepSchema = import_zod.z.object({
|
|
298
318
|
copyText: import_zod.z.object({
|
|
299
319
|
selector: import_zod.z.string().min(1),
|
|
@@ -336,6 +356,7 @@ var init_schema = __esm({
|
|
|
336
356
|
evalScriptStepSchema,
|
|
337
357
|
runScriptStepSchema,
|
|
338
358
|
assertScreenshotStepSchema,
|
|
359
|
+
assertWithAiStepSchema,
|
|
339
360
|
copyTextStepSchema,
|
|
340
361
|
waitForDownloadStepSchema
|
|
341
362
|
]);
|
|
@@ -417,9 +438,28 @@ function resolveViewport(value) {
|
|
|
417
438
|
return value;
|
|
418
439
|
}
|
|
419
440
|
function resolveTarget(target) {
|
|
420
|
-
|
|
441
|
+
const type = target?.type;
|
|
442
|
+
if (type === "macos") {
|
|
421
443
|
return { type: "macos", app: target.app };
|
|
422
444
|
}
|
|
445
|
+
if (type === "android") {
|
|
446
|
+
const androidTarget = target;
|
|
447
|
+
return {
|
|
448
|
+
type: "android",
|
|
449
|
+
app: androidTarget.app,
|
|
450
|
+
...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
|
|
451
|
+
...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
if (type === "ios") {
|
|
455
|
+
const iosTarget = target;
|
|
456
|
+
return {
|
|
457
|
+
type: "ios",
|
|
458
|
+
app: iosTarget.app,
|
|
459
|
+
...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
|
|
460
|
+
...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
423
463
|
return {
|
|
424
464
|
type: "web",
|
|
425
465
|
url: target?.url ?? DEFAULT_WEB_URL
|
|
@@ -628,12 +668,12 @@ __export(visual_exports, {
|
|
|
628
668
|
ensureBaselineDir: () => ensureBaselineDir
|
|
629
669
|
});
|
|
630
670
|
async function compareScreenshots(baselinePath, currentPath, diffPath, threshold) {
|
|
631
|
-
const baselineData = import_pngjs.PNG.sync.read(
|
|
632
|
-
const currentData = import_pngjs.PNG.sync.read(
|
|
671
|
+
const baselineData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(baselinePath));
|
|
672
|
+
const currentData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(currentPath));
|
|
633
673
|
const { width, height } = baselineData;
|
|
634
674
|
if (currentData.width !== width || currentData.height !== height) {
|
|
635
675
|
const diff2 = new import_pngjs.PNG({ width, height });
|
|
636
|
-
|
|
676
|
+
import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
|
|
637
677
|
return {
|
|
638
678
|
match: false,
|
|
639
679
|
diffPercentage: 1,
|
|
@@ -651,7 +691,7 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
|
|
|
651
691
|
);
|
|
652
692
|
const totalPixels = width * height;
|
|
653
693
|
const diffPercentage = diffPixels / totalPixels;
|
|
654
|
-
|
|
694
|
+
import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
|
|
655
695
|
return {
|
|
656
696
|
match: diffPercentage <= threshold,
|
|
657
697
|
diffPercentage,
|
|
@@ -659,16 +699,16 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
|
|
|
659
699
|
};
|
|
660
700
|
}
|
|
661
701
|
function ensureBaselineDir(configDir) {
|
|
662
|
-
const baselineDir =
|
|
663
|
-
|
|
702
|
+
const baselineDir = import_node_path8.default.join(configDir, "baselines");
|
|
703
|
+
import_node_fs8.default.mkdirSync(baselineDir, { recursive: true });
|
|
664
704
|
return baselineDir;
|
|
665
705
|
}
|
|
666
|
-
var
|
|
706
|
+
var import_node_fs8, import_node_path8, import_pngjs, import_pixelmatch;
|
|
667
707
|
var init_visual = __esm({
|
|
668
708
|
"src/runner/visual.ts"() {
|
|
669
709
|
"use strict";
|
|
670
|
-
|
|
671
|
-
|
|
710
|
+
import_node_fs8 = __toESM(require("fs"), 1);
|
|
711
|
+
import_node_path8 = __toESM(require("path"), 1);
|
|
672
712
|
import_pngjs = require("pngjs");
|
|
673
713
|
import_pixelmatch = __toESM(require("pixelmatch"), 1);
|
|
674
714
|
}
|
|
@@ -680,7 +720,7 @@ var import_commander13 = require("commander");
|
|
|
680
720
|
// package.json
|
|
681
721
|
var package_default = {
|
|
682
722
|
name: "prowl-tools",
|
|
683
|
-
version: "0.1.
|
|
723
|
+
version: "0.1.6",
|
|
684
724
|
description: "CLI-first QA testing tool for deterministic Playwright flows.",
|
|
685
725
|
type: "module",
|
|
686
726
|
license: "Apache-2.0",
|
|
@@ -750,6 +790,10 @@ var package_default = {
|
|
|
750
790
|
yaml: "^2.6.1",
|
|
751
791
|
zod: "^3.23.8"
|
|
752
792
|
},
|
|
793
|
+
optionalDependencies: {
|
|
794
|
+
"appium-uiautomator2-server": "10.6.2",
|
|
795
|
+
"appium-webdriveragent": "16.4.0"
|
|
796
|
+
},
|
|
753
797
|
devDependencies: {
|
|
754
798
|
"@types/node": "^22.13.1",
|
|
755
799
|
"@types/pngjs": "^6.0.5",
|
|
@@ -767,8 +811,8 @@ var import_commander = require("commander");
|
|
|
767
811
|
var import_chalk3 = __toESM(require("chalk"), 1);
|
|
768
812
|
|
|
769
813
|
// src/runner/index.ts
|
|
770
|
-
var
|
|
771
|
-
var
|
|
814
|
+
var import_node_fs14 = __toESM(require("fs"), 1);
|
|
815
|
+
var import_node_path14 = __toESM(require("path"), 1);
|
|
772
816
|
init_loader();
|
|
773
817
|
|
|
774
818
|
// src/config/interpolate.ts
|
|
@@ -1065,6 +1109,11 @@ function interpolateStep(step, vars, stepPath2, redacted) {
|
|
|
1065
1109
|
}
|
|
1066
1110
|
};
|
|
1067
1111
|
}
|
|
1112
|
+
if ("assertWithAI" in step) {
|
|
1113
|
+
return {
|
|
1114
|
+
assertWithAI: interpolateString(step.assertWithAI, vars).value
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1068
1117
|
if ("copyText" in step) {
|
|
1069
1118
|
return {
|
|
1070
1119
|
copyText: {
|
|
@@ -1171,15 +1220,25 @@ function webOnlyReason(step) {
|
|
|
1171
1220
|
}
|
|
1172
1221
|
return null;
|
|
1173
1222
|
}
|
|
1223
|
+
function nativeTargetLabel(target) {
|
|
1224
|
+
if (target === "android") {
|
|
1225
|
+
return "Android";
|
|
1226
|
+
}
|
|
1227
|
+
if (target === "ios") {
|
|
1228
|
+
return "iOS";
|
|
1229
|
+
}
|
|
1230
|
+
return "macOS";
|
|
1231
|
+
}
|
|
1174
1232
|
function assertStepsSupportedByTarget(steps, target) {
|
|
1175
|
-
if (target
|
|
1233
|
+
if (target === "web") {
|
|
1176
1234
|
return;
|
|
1177
1235
|
}
|
|
1236
|
+
const label = nativeTargetLabel(target);
|
|
1178
1237
|
for (const step of steps) {
|
|
1179
1238
|
const reason = webOnlyReason(step);
|
|
1180
1239
|
if (reason) {
|
|
1181
1240
|
throw new Error(
|
|
1182
|
-
`Step "${reason}" is not supported by the
|
|
1241
|
+
`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.).`
|
|
1183
1242
|
);
|
|
1184
1243
|
}
|
|
1185
1244
|
if ("if" in step) {
|
|
@@ -1194,20 +1253,34 @@ function assertStepsSupportedByTarget(steps, target) {
|
|
|
1194
1253
|
}
|
|
1195
1254
|
}
|
|
1196
1255
|
function assertHuntAssertionsSupportedByTarget(assertions, target) {
|
|
1197
|
-
if (target
|
|
1256
|
+
if (target === "web" || !assertions || assertions.length === 0) {
|
|
1198
1257
|
return;
|
|
1199
1258
|
}
|
|
1200
1259
|
throw new Error(
|
|
1201
|
-
|
|
1260
|
+
`Hunt-level assertions are not supported by the ${nativeTargetLabel(target)} target. Use inline assert visible/notVisible steps instead.`
|
|
1202
1261
|
);
|
|
1203
1262
|
}
|
|
1204
1263
|
function trimTrailingPathSeparators(value) {
|
|
1205
1264
|
return value.replace(/[\\/]+$/g, "");
|
|
1206
1265
|
}
|
|
1207
|
-
function
|
|
1266
|
+
function looksLikeAppBundlePath(app) {
|
|
1208
1267
|
const trimmed = trimTrailingPathSeparators(app);
|
|
1209
1268
|
return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
|
|
1210
1269
|
}
|
|
1270
|
+
function looksLikeIosAppPath(app) {
|
|
1271
|
+
const trimmed = trimTrailingPathSeparators(app);
|
|
1272
|
+
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
|
1273
|
+
return true;
|
|
1274
|
+
}
|
|
1275
|
+
if (trimmed.toLowerCase().endsWith(".app")) {
|
|
1276
|
+
try {
|
|
1277
|
+
return import_node_fs2.default.statSync(normalizeAppPath(app)).isDirectory();
|
|
1278
|
+
} catch {
|
|
1279
|
+
return false;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
return false;
|
|
1283
|
+
}
|
|
1211
1284
|
function normalizeAppPath(app) {
|
|
1212
1285
|
return import_node_path2.default.resolve(trimTrailingPathSeparators(app));
|
|
1213
1286
|
}
|
|
@@ -1215,8 +1288,8 @@ function parseBundleIdentifier(plist) {
|
|
|
1215
1288
|
const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
|
|
1216
1289
|
return match?.[1]?.trim() || null;
|
|
1217
1290
|
}
|
|
1218
|
-
function readBundleIdentifier(appPath) {
|
|
1219
|
-
const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath),
|
|
1291
|
+
function readBundleIdentifier(appPath, ...plistSubPath) {
|
|
1292
|
+
const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), ...plistSubPath);
|
|
1220
1293
|
if (!import_node_fs2.default.existsSync(infoPlistPath)) {
|
|
1221
1294
|
return null;
|
|
1222
1295
|
}
|
|
@@ -1241,9 +1314,15 @@ function readBundleIdentifier(appPath) {
|
|
|
1241
1314
|
return null;
|
|
1242
1315
|
}
|
|
1243
1316
|
}
|
|
1244
|
-
function
|
|
1317
|
+
function readMacosBundleIdentifier(appPath) {
|
|
1318
|
+
return readBundleIdentifier(appPath, "Contents", "Info.plist");
|
|
1319
|
+
}
|
|
1320
|
+
function readIosBundleIdentifier(appPath) {
|
|
1321
|
+
return readBundleIdentifier(appPath, "Info.plist");
|
|
1322
|
+
}
|
|
1323
|
+
function appBundleAllowedIdentities(app, readBundleId, isAppPath = looksLikeAppBundlePath) {
|
|
1245
1324
|
const identities = /* @__PURE__ */ new Set([app]);
|
|
1246
|
-
if (
|
|
1325
|
+
if (isAppPath(app)) {
|
|
1247
1326
|
const normalizedPath = normalizeAppPath(app);
|
|
1248
1327
|
identities.add(trimTrailingPathSeparators(app));
|
|
1249
1328
|
identities.add(normalizedPath);
|
|
@@ -1251,19 +1330,56 @@ function macosAppAllowedIdentities(app) {
|
|
|
1251
1330
|
if (bundleName) {
|
|
1252
1331
|
identities.add(bundleName);
|
|
1253
1332
|
}
|
|
1254
|
-
const bundleId =
|
|
1333
|
+
const bundleId = readBundleId(app);
|
|
1255
1334
|
if (bundleId) {
|
|
1256
1335
|
identities.add(bundleId);
|
|
1257
1336
|
}
|
|
1258
1337
|
}
|
|
1259
1338
|
return [...identities];
|
|
1260
1339
|
}
|
|
1340
|
+
function macosAppAllowedIdentities(app) {
|
|
1341
|
+
return appBundleAllowedIdentities(app, readMacosBundleIdentifier);
|
|
1342
|
+
}
|
|
1343
|
+
function iosAppAllowedIdentities(app) {
|
|
1344
|
+
return appBundleAllowedIdentities(app, readIosBundleIdentifier, looksLikeIosAppPath);
|
|
1345
|
+
}
|
|
1261
1346
|
function assertTargetAppAllowed(allowedApps, app) {
|
|
1347
|
+
assertNativeAppAllowed(allowedApps, app, macosAppAllowedIdentities);
|
|
1348
|
+
}
|
|
1349
|
+
function assertIosAppAllowed(allowedApps, app) {
|
|
1350
|
+
assertNativeAppAllowed(allowedApps, app, iosAppAllowedIdentities);
|
|
1351
|
+
}
|
|
1352
|
+
function looksLikeApkPath(app) {
|
|
1353
|
+
const trimmed = trimTrailingPathSeparators(app);
|
|
1354
|
+
return trimmed.includes("/") || trimmed.includes("\\") || trimmed.toLowerCase().endsWith(".apk");
|
|
1355
|
+
}
|
|
1356
|
+
function normalizeAndroidApkPath(app) {
|
|
1357
|
+
const resolved = import_node_path2.default.resolve(trimTrailingPathSeparators(app));
|
|
1358
|
+
try {
|
|
1359
|
+
return import_node_fs2.default.realpathSync.native(resolved);
|
|
1360
|
+
} catch {
|
|
1361
|
+
return resolved;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
function androidAppAllowedIdentities(app, resolvedPackage) {
|
|
1365
|
+
if (looksLikeApkPath(app)) {
|
|
1366
|
+
return resolvedPackage ? [normalizeAndroidApkPath(app), resolvedPackage] : [normalizeAndroidApkPath(app)];
|
|
1367
|
+
}
|
|
1368
|
+
return [app];
|
|
1369
|
+
}
|
|
1370
|
+
function assertAndroidAppAllowed(allowedApps, app, resolvedPackage) {
|
|
1371
|
+
assertNativeAppAllowed(
|
|
1372
|
+
allowedApps,
|
|
1373
|
+
app,
|
|
1374
|
+
(value) => androidAppAllowedIdentities(value, value === app ? resolvedPackage : void 0)
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
function assertNativeAppAllowed(allowedApps, app, resolveIdentities) {
|
|
1262
1378
|
if (allowedApps.length === 0) {
|
|
1263
1379
|
return;
|
|
1264
1380
|
}
|
|
1265
|
-
const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) =>
|
|
1266
|
-
if (
|
|
1381
|
+
const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => resolveIdentities(allowedApp)));
|
|
1382
|
+
if (resolveIdentities(app).some((identity) => allowedIdentities.has(identity))) {
|
|
1267
1383
|
return;
|
|
1268
1384
|
}
|
|
1269
1385
|
throw new Error(
|
|
@@ -1641,7 +1757,11 @@ function createMacDriver(client, options = {}) {
|
|
|
1641
1757
|
return rejectUnsupported("evalScript");
|
|
1642
1758
|
},
|
|
1643
1759
|
async screenshot(screenshotOptions) {
|
|
1644
|
-
await client.request("screenshot", { path: screenshotOptions.path });
|
|
1760
|
+
const result = await client.request("screenshot", { path: screenshotOptions.path });
|
|
1761
|
+
const warning = result.warning;
|
|
1762
|
+
if (typeof warning === "string" && warning.length > 0) {
|
|
1763
|
+
console.warn(`macOS screenshot fell back to full screen: ${warning}`);
|
|
1764
|
+
}
|
|
1645
1765
|
},
|
|
1646
1766
|
// network / dialogs / downloads (all web-only) -------------------------
|
|
1647
1767
|
onResponse(_handler) {
|
|
@@ -1868,154 +1988,2177 @@ async function closeMacSession(session) {
|
|
|
1868
1988
|
}
|
|
1869
1989
|
}
|
|
1870
1990
|
|
|
1871
|
-
// src/
|
|
1991
|
+
// src/browser/android-helper.ts
|
|
1992
|
+
var import_node_module = require("module");
|
|
1993
|
+
var import_node_child_process4 = require("child_process");
|
|
1872
1994
|
var import_node_fs6 = __toESM(require("fs"), 1);
|
|
1873
|
-
var
|
|
1874
|
-
init_loader();
|
|
1995
|
+
var import_node_path5 = __toESM(require("path"), 1);
|
|
1875
1996
|
|
|
1876
|
-
// src/
|
|
1877
|
-
var
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1997
|
+
// src/browser/android-driver.ts
|
|
1998
|
+
var import_node_fs5 = __toESM(require("fs"), 1);
|
|
1999
|
+
|
|
2000
|
+
// src/analyzer/xml.ts
|
|
2001
|
+
var ENTITIES = {
|
|
2002
|
+
amp: "&",
|
|
2003
|
+
lt: "<",
|
|
2004
|
+
gt: ">",
|
|
2005
|
+
quot: '"',
|
|
2006
|
+
apos: "'"
|
|
2007
|
+
};
|
|
2008
|
+
function decodeXmlEntities(value) {
|
|
2009
|
+
if (!value.includes("&")) {
|
|
2010
|
+
return value;
|
|
1886
2011
|
}
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
const
|
|
1891
|
-
|
|
1892
|
-
words.push(lower);
|
|
1893
|
-
}
|
|
2012
|
+
return value.replace(/&(#(?:[xX][0-9a-fA-F]+|[0-9]+)|[a-zA-Z]+);/g, (match, code) => {
|
|
2013
|
+
if (code[0] === "#") {
|
|
2014
|
+
const hex = code[1] === "x" || code[1] === "X";
|
|
2015
|
+
const num2 = Number.parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10);
|
|
2016
|
+
return Number.isSafeInteger(num2) && num2 <= 1114111 ? String.fromCodePoint(num2) : match;
|
|
1894
2017
|
}
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
}
|
|
1898
|
-
function splitToken(token) {
|
|
1899
|
-
return token.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[\s\-_.:]+/).filter((part) => part.length > 0);
|
|
1900
|
-
}
|
|
1901
|
-
function buildHealCandidates(selector) {
|
|
1902
|
-
const { words, label } = extractSelectorIntent(selector);
|
|
1903
|
-
if (words.length === 0) return [];
|
|
1904
|
-
const escaped = label.replace(/"/g, '\\"');
|
|
1905
|
-
const candidates = [];
|
|
1906
|
-
candidates.push({ selector: `text=${label}`, strategy: "text" });
|
|
1907
|
-
candidates.push({ selector: `[aria-label*="${escaped}" i]`, strategy: "aria" });
|
|
1908
|
-
for (const tag of INTERACTIVE_TAGS) {
|
|
1909
|
-
candidates.push({ selector: `${tag}:has-text("${escaped}")`, strategy: "structural" });
|
|
1910
|
-
}
|
|
1911
|
-
return candidates;
|
|
2018
|
+
const named = ENTITIES[code];
|
|
2019
|
+
return named ?? match;
|
|
2020
|
+
});
|
|
1912
2021
|
}
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
2022
|
+
var ATTR_RE = /([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
|
2023
|
+
function parseTagBody(body) {
|
|
2024
|
+
const trimmed = body.trim();
|
|
2025
|
+
const nameMatch = /^([^\s/>]+)/.exec(trimmed);
|
|
2026
|
+
const tag = nameMatch ? nameMatch[1] : "";
|
|
2027
|
+
const attrs = {};
|
|
2028
|
+
const rest = trimmed.slice(tag.length);
|
|
2029
|
+
ATTR_RE.lastIndex = 0;
|
|
2030
|
+
let m;
|
|
2031
|
+
while ((m = ATTR_RE.exec(rest)) !== null) {
|
|
2032
|
+
const rawValue = m[3] !== void 0 ? m[3] : m[4] ?? "";
|
|
2033
|
+
attrs[m[1]] = decodeXmlEntities(rawValue);
|
|
2034
|
+
}
|
|
2035
|
+
return { tag, attrs };
|
|
2036
|
+
}
|
|
2037
|
+
function parseXml(input) {
|
|
2038
|
+
const n = input.length;
|
|
2039
|
+
const stack = [];
|
|
2040
|
+
let root = null;
|
|
2041
|
+
let i = 0;
|
|
2042
|
+
while (i < n) {
|
|
2043
|
+
const lt = input.indexOf("<", i);
|
|
2044
|
+
if (lt < 0) {
|
|
2045
|
+
break;
|
|
2046
|
+
}
|
|
2047
|
+
i = lt + 1;
|
|
2048
|
+
const ch = input[i];
|
|
2049
|
+
if (ch === "?") {
|
|
2050
|
+
const end = input.indexOf("?>", i);
|
|
2051
|
+
i = end < 0 ? n : end + 2;
|
|
1921
2052
|
continue;
|
|
1922
2053
|
}
|
|
1923
|
-
if (
|
|
1924
|
-
|
|
2054
|
+
if (ch === "!") {
|
|
2055
|
+
if (input.startsWith("!--", i)) {
|
|
2056
|
+
const end = input.indexOf("-->", i);
|
|
2057
|
+
i = end < 0 ? n : end + 3;
|
|
2058
|
+
} else {
|
|
2059
|
+
const end = input.indexOf(">", i);
|
|
2060
|
+
i = end < 0 ? n : end + 1;
|
|
2061
|
+
}
|
|
2062
|
+
continue;
|
|
2063
|
+
}
|
|
2064
|
+
if (ch === "/") {
|
|
2065
|
+
const gt = input.indexOf(">", i);
|
|
2066
|
+
i = gt < 0 ? n : gt + 1;
|
|
2067
|
+
stack.pop();
|
|
2068
|
+
continue;
|
|
2069
|
+
}
|
|
2070
|
+
let j = i;
|
|
2071
|
+
let quote2 = null;
|
|
2072
|
+
while (j < n) {
|
|
2073
|
+
const c = input[j];
|
|
2074
|
+
if (quote2 !== null) {
|
|
2075
|
+
if (c === quote2) {
|
|
2076
|
+
quote2 = null;
|
|
2077
|
+
}
|
|
2078
|
+
} else if (c === '"' || c === "'") {
|
|
2079
|
+
quote2 = c;
|
|
2080
|
+
} else if (c === ">") {
|
|
2081
|
+
break;
|
|
2082
|
+
}
|
|
2083
|
+
j += 1;
|
|
2084
|
+
}
|
|
2085
|
+
const inner = input.slice(i, j);
|
|
2086
|
+
i = j + 1;
|
|
2087
|
+
const selfClose = inner.endsWith("/");
|
|
2088
|
+
const body = selfClose ? inner.slice(0, -1) : inner;
|
|
2089
|
+
const { tag, attrs } = parseTagBody(body);
|
|
2090
|
+
if (tag.length === 0) {
|
|
2091
|
+
continue;
|
|
2092
|
+
}
|
|
2093
|
+
const element = { tag, attrs, children: [] };
|
|
2094
|
+
const parent = stack[stack.length - 1];
|
|
2095
|
+
if (parent) {
|
|
2096
|
+
parent.children.push(element);
|
|
2097
|
+
}
|
|
2098
|
+
if (root === null) {
|
|
2099
|
+
root = element;
|
|
2100
|
+
}
|
|
2101
|
+
if (!selfClose) {
|
|
2102
|
+
stack.push(element);
|
|
1925
2103
|
}
|
|
1926
2104
|
}
|
|
1927
|
-
return
|
|
2105
|
+
return root;
|
|
1928
2106
|
}
|
|
1929
2107
|
|
|
1930
|
-
// src/
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
const
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
const selectorText = driver.parseTextSelector(selector);
|
|
1937
|
-
if (selectorText === null) {
|
|
1938
|
-
return false;
|
|
1939
|
-
}
|
|
1940
|
-
const forbiddenText = driver.parseTextSelector(forbidden);
|
|
1941
|
-
if (forbiddenText !== null) {
|
|
1942
|
-
return selectorText.includes(forbiddenText);
|
|
1943
|
-
}
|
|
1944
|
-
return selectorText.includes(forbidden);
|
|
2108
|
+
// src/selector/native.ts
|
|
2109
|
+
function unquoteSelectorValue(value) {
|
|
2110
|
+
const trimmed = value.trim();
|
|
2111
|
+
const first = trimmed[0];
|
|
2112
|
+
if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
|
|
2113
|
+
return trimmed.slice(1, -1);
|
|
1945
2114
|
}
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
2115
|
+
return trimmed;
|
|
2116
|
+
}
|
|
2117
|
+
function quoteSelectorValue(value) {
|
|
2118
|
+
return `"${value}"`;
|
|
2119
|
+
}
|
|
2120
|
+
var ROLE_SELECTOR_RE = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s;
|
|
2121
|
+
var NATIVE_SELECTOR_PREFIX_RE = /^(id|label|text|role)=/;
|
|
2122
|
+
function invalidSelectorMessage(selector, reason) {
|
|
2123
|
+
return `Invalid native selector ${JSON.stringify(selector)}: ${reason}.`;
|
|
2124
|
+
}
|
|
2125
|
+
function parseNativeSelector(selector) {
|
|
2126
|
+
const trimmed = selector.trim();
|
|
2127
|
+
if (trimmed.length === 0) {
|
|
2128
|
+
throw new Error(
|
|
2129
|
+
invalidSelectorMessage(
|
|
2130
|
+
selector,
|
|
2131
|
+
"selector is empty; use id=, label=, text=, role=, :focus, or a bare text value"
|
|
2132
|
+
)
|
|
1949
2133
|
);
|
|
1950
2134
|
}
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
throw new Error(`Forbidden selector: ${selector}`);
|
|
1954
|
-
}
|
|
2135
|
+
if (trimmed === ":focus") {
|
|
2136
|
+
return { kind: "focused" };
|
|
1955
2137
|
}
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
throw new Error(`Hunt "${huntName}" has ${stepCount} steps. Max allowed is ${maxSteps}.`);
|
|
1960
|
-
}
|
|
1961
|
-
throw new Error(`Hunt has ${stepCount} steps. Max allowed is ${maxSteps}.`);
|
|
1962
|
-
}
|
|
2138
|
+
const idMatch = /^id=(.+)$/s.exec(trimmed);
|
|
2139
|
+
if (idMatch) {
|
|
2140
|
+
return { kind: "id", value: unquoteSelectorValue(idMatch[1]) };
|
|
1963
2141
|
}
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
2142
|
+
const roleMatch = ROLE_SELECTOR_RE.exec(trimmed);
|
|
2143
|
+
if (roleMatch) {
|
|
2144
|
+
const name = roleMatch[2] !== void 0 ? unquoteSelectorValue(roleMatch[2]) : void 0;
|
|
2145
|
+
return name !== void 0 && name.length > 0 ? { kind: "role", role: roleMatch[1], name } : { kind: "role", role: roleMatch[1] };
|
|
2146
|
+
}
|
|
2147
|
+
const labelMatch = /^label=(.+)$/s.exec(trimmed);
|
|
2148
|
+
if (labelMatch) {
|
|
2149
|
+
return { kind: "label", value: unquoteSelectorValue(labelMatch[1]) };
|
|
2150
|
+
}
|
|
2151
|
+
const textMatch = /^text=(.+)$/s.exec(trimmed);
|
|
2152
|
+
if (textMatch) {
|
|
2153
|
+
return { kind: "text", value: unquoteSelectorValue(textMatch[1]) };
|
|
2154
|
+
}
|
|
2155
|
+
const prefix = NATIVE_SELECTOR_PREFIX_RE.exec(trimmed);
|
|
2156
|
+
if (prefix) {
|
|
2157
|
+
throw new Error(
|
|
2158
|
+
invalidSelectorMessage(
|
|
2159
|
+
selector,
|
|
2160
|
+
`malformed ${prefix[1]}= selector; expected id=<value>, label=<value>, text=<value>, or role=<Type>[name=<value>]`
|
|
2161
|
+
)
|
|
2162
|
+
);
|
|
2163
|
+
}
|
|
2164
|
+
return { kind: "text", value: trimmed };
|
|
2165
|
+
}
|
|
2166
|
+
function unwrapNativeTextSelector(selector) {
|
|
2167
|
+
const trimmed = selector.trim();
|
|
2168
|
+
if (!trimmed.startsWith("text=")) {
|
|
2169
|
+
return null;
|
|
2170
|
+
}
|
|
2171
|
+
return unquoteSelectorValue(trimmed.slice("text=".length));
|
|
2172
|
+
}
|
|
2173
|
+
function qualifyResourceId(value, appPackage) {
|
|
2174
|
+
if (value.includes(":") || !appPackage) {
|
|
2175
|
+
return value;
|
|
2176
|
+
}
|
|
2177
|
+
return `${appPackage}:id/${value}`;
|
|
2178
|
+
}
|
|
2179
|
+
function normalizeXcuiClassName(role) {
|
|
2180
|
+
return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
|
|
2181
|
+
}
|
|
2182
|
+
function shortIosType(type) {
|
|
2183
|
+
return type.startsWith("XCUIElementType") ? type.slice("XCUIElementType".length) : type;
|
|
2184
|
+
}
|
|
2185
|
+
function rankNativeSelectors(fields) {
|
|
2186
|
+
const selectors = [];
|
|
2187
|
+
if (fields.id) {
|
|
2188
|
+
selectors.push(`id=${fields.id}`);
|
|
2189
|
+
}
|
|
2190
|
+
if (fields.label) {
|
|
2191
|
+
selectors.push(`label=${quoteSelectorValue(fields.label)}`);
|
|
2192
|
+
}
|
|
2193
|
+
if (fields.role && fields.name) {
|
|
2194
|
+
selectors.push(`role=${fields.role}[name=${quoteSelectorValue(fields.name)}]`);
|
|
2195
|
+
}
|
|
2196
|
+
if (fields.name) {
|
|
2197
|
+
selectors.push(`text=${quoteSelectorValue(fields.name)}`);
|
|
2198
|
+
}
|
|
2199
|
+
if (selectors.length === 0 && fields.role) {
|
|
2200
|
+
selectors.push(`role=${fields.role}`);
|
|
2201
|
+
}
|
|
2202
|
+
return selectors;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// src/browser/android-driver.ts
|
|
2206
|
+
var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
2207
|
+
"query",
|
|
2208
|
+
"interact",
|
|
2209
|
+
"wait",
|
|
2210
|
+
"screenshot"
|
|
2211
|
+
]);
|
|
2212
|
+
var WAIT_POLL_INTERVAL_MS = 250;
|
|
2213
|
+
var DEFAULT_WAIT_TIMEOUT_MS = 5e3;
|
|
2214
|
+
var ANDROID_KEYCODES = {
|
|
2215
|
+
enter: 66,
|
|
2216
|
+
return: 66,
|
|
2217
|
+
tab: 61,
|
|
2218
|
+
space: 62,
|
|
2219
|
+
backspace: 67,
|
|
2220
|
+
delete: 67,
|
|
2221
|
+
del: 67,
|
|
2222
|
+
escape: 111,
|
|
2223
|
+
esc: 111,
|
|
2224
|
+
back: 4,
|
|
2225
|
+
home: 3,
|
|
2226
|
+
menu: 82,
|
|
2227
|
+
search: 84,
|
|
2228
|
+
up: 19,
|
|
2229
|
+
arrowup: 19,
|
|
2230
|
+
down: 20,
|
|
2231
|
+
arrowdown: 20,
|
|
2232
|
+
left: 21,
|
|
2233
|
+
arrowleft: 21,
|
|
2234
|
+
right: 22,
|
|
2235
|
+
arrowright: 22,
|
|
2236
|
+
pageup: 92,
|
|
2237
|
+
pagedown: 93
|
|
2238
|
+
};
|
|
2239
|
+
function escapeUiSelectorArg(value) {
|
|
2240
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
2241
|
+
}
|
|
2242
|
+
function toAndroidQuery(selector) {
|
|
2243
|
+
switch (selector.kind) {
|
|
2244
|
+
case "focused":
|
|
2245
|
+
return { by: "focused" };
|
|
2246
|
+
case "id":
|
|
2247
|
+
return { by: "id", value: selector.value };
|
|
2248
|
+
case "role":
|
|
2249
|
+
return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
|
|
2250
|
+
case "label":
|
|
2251
|
+
return { by: "accessibilityId", value: selector.value };
|
|
2252
|
+
case "text":
|
|
2253
|
+
return { by: "text", value: selector.value };
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
function parseAndroidSelector(selector) {
|
|
2257
|
+
return toAndroidQuery(parseNativeSelector(selector));
|
|
2258
|
+
}
|
|
2259
|
+
function locator(strategy, selector) {
|
|
2260
|
+
return { strategy, selector, context: "" };
|
|
2261
|
+
}
|
|
2262
|
+
function androidQueryToLocator(query, options = {}) {
|
|
2263
|
+
switch (query.by) {
|
|
2264
|
+
case "id":
|
|
2265
|
+
return locator("id", qualifyResourceId(query.value, options.appPackage));
|
|
2266
|
+
case "accessibilityId":
|
|
2267
|
+
return locator("accessibility id", query.value);
|
|
2268
|
+
case "text":
|
|
2269
|
+
return locator(
|
|
2270
|
+
"-android uiautomator",
|
|
2271
|
+
`new UiSelector().textContains("${escapeUiSelectorArg(query.value)}")`
|
|
2272
|
+
);
|
|
2273
|
+
case "focused":
|
|
2274
|
+
return locator("-android uiautomator", "new UiSelector().focused(true)");
|
|
2275
|
+
case "role": {
|
|
2276
|
+
const className = escapeUiSelectorArg(query.role);
|
|
2277
|
+
if (query.name === void 0 || query.name.length === 0) {
|
|
2278
|
+
return locator("class name", query.role);
|
|
1968
2279
|
}
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
} catch {
|
|
1974
|
-
throw new Error(`Navigation target is not a valid absolute URL: ${urlValue}`);
|
|
1975
|
-
}
|
|
1976
|
-
if (!allowedDomains.includes(url.hostname)) {
|
|
1977
|
-
throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
|
|
2280
|
+
return locator(
|
|
2281
|
+
"-android uiautomator",
|
|
2282
|
+
`new UiSelector().className("${className}").textContains("${escapeUiSelectorArg(query.name)}")`
|
|
2283
|
+
);
|
|
1978
2284
|
}
|
|
1979
2285
|
}
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
2286
|
+
}
|
|
2287
|
+
function unwrapAndroidTextSelector(selector) {
|
|
2288
|
+
return unwrapNativeTextSelector(selector);
|
|
2289
|
+
}
|
|
2290
|
+
function keyCodeFor(key) {
|
|
2291
|
+
const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
|
|
2292
|
+
if (code === void 0) {
|
|
2293
|
+
throw new Error(
|
|
2294
|
+
`Unsupported key "${key}" for the Android target. Supported keys: ${Object.keys(ANDROID_KEYCODES).sort().join(", ")}.`
|
|
2295
|
+
);
|
|
1984
2296
|
}
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
2297
|
+
return code;
|
|
2298
|
+
}
|
|
2299
|
+
function delay(ms) {
|
|
2300
|
+
return new Promise((resolve) => {
|
|
2301
|
+
setTimeout(resolve, ms);
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
function createAndroidDriver(client, options = {}) {
|
|
2305
|
+
const unsupported = (verb) => new Error(`${verb} is not supported by the Android target`);
|
|
2306
|
+
const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
|
|
2307
|
+
async function resolveOne(selector) {
|
|
2308
|
+
const id = await client.findElement(parseAndroidSelector(selector));
|
|
2309
|
+
if (id === null) {
|
|
2310
|
+
throw new Error(`No element matched selector: ${selector}`);
|
|
1988
2311
|
}
|
|
2312
|
+
return id;
|
|
1989
2313
|
}
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
}
|
|
1993
|
-
async function
|
|
1994
|
-
|
|
1995
|
-
if (!selfHealing) {
|
|
1996
|
-
return { selector };
|
|
1997
|
-
}
|
|
1998
|
-
let matched = false;
|
|
1999
|
-
try {
|
|
2000
|
-
matched = await driver.count(selector) > 0;
|
|
2001
|
-
} catch {
|
|
2002
|
-
return { selector };
|
|
2003
|
-
}
|
|
2004
|
-
if (matched) {
|
|
2005
|
-
return { selector };
|
|
2006
|
-
}
|
|
2007
|
-
const healed = await healSelector(healProbe, selector, { enabled: true });
|
|
2008
|
-
if (!healed) {
|
|
2009
|
-
return { selector };
|
|
2010
|
-
}
|
|
2011
|
-
assertAllowedSelector(healed.selector);
|
|
2012
|
-
console.warn(
|
|
2013
|
-
`Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
|
|
2014
|
-
);
|
|
2015
|
-
return { selector: healed.selector, healedFrom: healed.healedFrom };
|
|
2314
|
+
async function clickSelector(selector) {
|
|
2315
|
+
await client.click(await resolveOne(selector));
|
|
2316
|
+
}
|
|
2317
|
+
async function fillSelector(selector, value) {
|
|
2318
|
+
await client.setValue(await resolveOne(selector), value);
|
|
2016
2319
|
}
|
|
2017
2320
|
return {
|
|
2018
|
-
|
|
2321
|
+
capabilities: ANDROID_CAPABILITIES,
|
|
2322
|
+
// navigation -----------------------------------------------------------
|
|
2323
|
+
goto(_url, _options) {
|
|
2324
|
+
return rejectUnsupported("navigate");
|
|
2325
|
+
},
|
|
2326
|
+
currentUrl() {
|
|
2327
|
+
return `android:${options.appLabel ?? ""}`;
|
|
2328
|
+
},
|
|
2329
|
+
// queries --------------------------------------------------------------
|
|
2330
|
+
async count(selector) {
|
|
2331
|
+
return (await client.findElements(parseAndroidSelector(selector))).length;
|
|
2332
|
+
},
|
|
2333
|
+
async textContent(selector) {
|
|
2334
|
+
const id = await client.findElement(parseAndroidSelector(selector));
|
|
2335
|
+
if (id === null) {
|
|
2336
|
+
return null;
|
|
2337
|
+
}
|
|
2338
|
+
return client.getText(id);
|
|
2339
|
+
},
|
|
2340
|
+
// interactions ---------------------------------------------------------
|
|
2341
|
+
click: clickSelector,
|
|
2342
|
+
clickFirst: clickSelector,
|
|
2343
|
+
fill: fillSelector,
|
|
2344
|
+
fillFirst: fillSelector,
|
|
2345
|
+
async press(_selector, key) {
|
|
2346
|
+
await client.pressKeyCode(keyCodeFor(key));
|
|
2347
|
+
},
|
|
2348
|
+
selectOption() {
|
|
2349
|
+
return rejectUnsupported("select");
|
|
2350
|
+
},
|
|
2351
|
+
selectOptionFirst() {
|
|
2352
|
+
return rejectUnsupported("select");
|
|
2353
|
+
},
|
|
2354
|
+
hover() {
|
|
2355
|
+
return rejectUnsupported("hover");
|
|
2356
|
+
},
|
|
2357
|
+
scrollIntoView() {
|
|
2358
|
+
return rejectUnsupported("scrollTo");
|
|
2359
|
+
},
|
|
2360
|
+
setInputFiles() {
|
|
2361
|
+
return rejectUnsupported("setInputFiles");
|
|
2362
|
+
},
|
|
2363
|
+
// semantic locators ----------------------------------------------------
|
|
2364
|
+
async countByRole(role, name) {
|
|
2365
|
+
return (await client.findElements({ by: "role", role, name })).length;
|
|
2366
|
+
},
|
|
2367
|
+
async clickFirstByRole(role, name) {
|
|
2368
|
+
const id = await client.findElement({ by: "role", role, name });
|
|
2369
|
+
if (id === null) {
|
|
2370
|
+
throw new Error(`No element matched role=${role}[name="${name}"]`);
|
|
2371
|
+
}
|
|
2372
|
+
await client.click(id);
|
|
2373
|
+
},
|
|
2374
|
+
async countByLabel(label) {
|
|
2375
|
+
return (await client.findElements({ by: "accessibilityId", value: label })).length;
|
|
2376
|
+
},
|
|
2377
|
+
async fillFirstByLabel(label, value) {
|
|
2378
|
+
const id = await client.findElement({ by: "accessibilityId", value: label });
|
|
2379
|
+
if (id === null) {
|
|
2380
|
+
throw new Error(`No element matched label="${label}"`);
|
|
2381
|
+
}
|
|
2382
|
+
await client.setValue(id, value);
|
|
2383
|
+
},
|
|
2384
|
+
selectOptionFirstByLabel() {
|
|
2385
|
+
return rejectUnsupported("select");
|
|
2386
|
+
},
|
|
2387
|
+
// waiting --------------------------------------------------------------
|
|
2388
|
+
async waitForSelector(selector, waitOptions) {
|
|
2389
|
+
const query = parseAndroidSelector(selector);
|
|
2390
|
+
const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
2391
|
+
const deadline = Date.now() + timeoutMs;
|
|
2392
|
+
for (; ; ) {
|
|
2393
|
+
if ((await client.findElements(query)).length > 0) {
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
if (Date.now() >= deadline) {
|
|
2397
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
|
|
2398
|
+
}
|
|
2399
|
+
await delay(Math.min(WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
|
|
2400
|
+
}
|
|
2401
|
+
},
|
|
2402
|
+
waitForUrl() {
|
|
2403
|
+
return rejectUnsupported("waitForUrl");
|
|
2404
|
+
},
|
|
2405
|
+
waitForNetworkIdle() {
|
|
2406
|
+
return rejectUnsupported("waitForNetworkIdle");
|
|
2407
|
+
},
|
|
2408
|
+
// scripting & artifacts ------------------------------------------------
|
|
2409
|
+
evaluate() {
|
|
2410
|
+
return rejectUnsupported("evalScript");
|
|
2411
|
+
},
|
|
2412
|
+
async screenshot(screenshotOptions) {
|
|
2413
|
+
const png = await client.screenshotPng();
|
|
2414
|
+
import_node_fs5.default.writeFileSync(screenshotOptions.path, png);
|
|
2415
|
+
},
|
|
2416
|
+
// network / dialogs / downloads (all web-only) -------------------------
|
|
2417
|
+
onResponse(_handler) {
|
|
2418
|
+
throw unsupported("onResponse");
|
|
2419
|
+
},
|
|
2420
|
+
route(_url, _handler) {
|
|
2421
|
+
return rejectUnsupported("mockRoute");
|
|
2422
|
+
},
|
|
2423
|
+
unroute() {
|
|
2424
|
+
return rejectUnsupported("unmockRoute");
|
|
2425
|
+
},
|
|
2426
|
+
onDialog(_action) {
|
|
2427
|
+
throw unsupported("onDialog");
|
|
2428
|
+
},
|
|
2429
|
+
waitForDownloadEvent() {
|
|
2430
|
+
return rejectUnsupported("waitForDownload");
|
|
2431
|
+
},
|
|
2432
|
+
parseTextSelector(selector) {
|
|
2433
|
+
return unwrapAndroidTextSelector(selector);
|
|
2434
|
+
}
|
|
2435
|
+
};
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
// src/browser/android-adb.ts
|
|
2439
|
+
var import_node_child_process3 = require("child_process");
|
|
2440
|
+
var execFileAdbRunner = (args, options) => new Promise((resolve) => {
|
|
2441
|
+
(0, import_node_child_process3.execFile)(
|
|
2442
|
+
"adb",
|
|
2443
|
+
args,
|
|
2444
|
+
{ encoding: "utf-8", timeout: options?.timeoutMs, maxBuffer: 16 * 1024 * 1024 },
|
|
2445
|
+
(error, stdout, stderr) => {
|
|
2446
|
+
const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
|
|
2447
|
+
const capturedStderr = stderr ?? "";
|
|
2448
|
+
resolve({
|
|
2449
|
+
stdout: stdout ?? "",
|
|
2450
|
+
stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
|
|
2451
|
+
code
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
);
|
|
2455
|
+
});
|
|
2456
|
+
var spawnAdbProcess = (args) => {
|
|
2457
|
+
const child = (0, import_node_child_process3.spawn)("adb", args, { stdio: "ignore" });
|
|
2458
|
+
child.on("error", () => {
|
|
2459
|
+
});
|
|
2460
|
+
return { kill: () => child.kill() };
|
|
2461
|
+
};
|
|
2462
|
+
function withSerial(serial, args) {
|
|
2463
|
+
return serial ? ["-s", serial, ...args] : args;
|
|
2464
|
+
}
|
|
2465
|
+
function parseAdbDevices(stdout) {
|
|
2466
|
+
const devices = [];
|
|
2467
|
+
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
2468
|
+
const line = rawLine.trim();
|
|
2469
|
+
if (!line || /^list of devices attached/i.test(line)) {
|
|
2470
|
+
continue;
|
|
2471
|
+
}
|
|
2472
|
+
const [serial, state, ...rest] = line.split(/\s+/);
|
|
2473
|
+
if (!serial || !state) {
|
|
2474
|
+
continue;
|
|
2475
|
+
}
|
|
2476
|
+
const description = {};
|
|
2477
|
+
for (const token of rest) {
|
|
2478
|
+
const eq = token.indexOf(":");
|
|
2479
|
+
if (eq > 0) {
|
|
2480
|
+
description[token.slice(0, eq)] = token.slice(eq + 1);
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
devices.push({ serial, state, description });
|
|
2484
|
+
}
|
|
2485
|
+
return devices;
|
|
2486
|
+
}
|
|
2487
|
+
function bootedDevices(devices) {
|
|
2488
|
+
return devices.filter((device) => device.state === "device");
|
|
2489
|
+
}
|
|
2490
|
+
function selectDeviceSerial(devices, requested) {
|
|
2491
|
+
const booted = bootedDevices(devices);
|
|
2492
|
+
if (requested) {
|
|
2493
|
+
const match = devices.find((device) => device.serial === requested);
|
|
2494
|
+
if (!match) {
|
|
2495
|
+
const attached = devices.length > 0 ? devices.map((d) => d.serial).join(", ") : "none";
|
|
2496
|
+
throw new Error(
|
|
2497
|
+
`Android device "${requested}" is not attached. Attached devices: ${attached}. Check \`adb devices -l\`.`
|
|
2498
|
+
);
|
|
2499
|
+
}
|
|
2500
|
+
if (match.state !== "device") {
|
|
2501
|
+
throw new Error(
|
|
2502
|
+
`Android device "${requested}" is present but not ready (state: ${match.state}). Boot or authorize it, then retry.`
|
|
2503
|
+
);
|
|
2504
|
+
}
|
|
2505
|
+
return requested;
|
|
2506
|
+
}
|
|
2507
|
+
if (booted.length === 0) {
|
|
2508
|
+
throw new Error(
|
|
2509
|
+
"No booted Android device found. Start an emulator or connect a device with USB debugging, then confirm it appears in `adb devices -l`."
|
|
2510
|
+
);
|
|
2511
|
+
}
|
|
2512
|
+
if (booted.length > 1) {
|
|
2513
|
+
throw new Error(
|
|
2514
|
+
`Multiple Android devices attached (${booted.map((d) => d.serial).join(", ")}). Set target.deviceSerial to pick one.`
|
|
2515
|
+
);
|
|
2516
|
+
}
|
|
2517
|
+
return booted[0].serial;
|
|
2518
|
+
}
|
|
2519
|
+
async function listDevices(runner) {
|
|
2520
|
+
const result = await runner(["devices", "-l"], { timeoutMs: 1e4 });
|
|
2521
|
+
if (result.code !== 0) {
|
|
2522
|
+
throw new Error(
|
|
2523
|
+
`\`adb devices\` failed (exit ${result.code}). Is adb on PATH and the server running? ` + (result.stderr.trim() || "").slice(0, 400)
|
|
2524
|
+
);
|
|
2525
|
+
}
|
|
2526
|
+
return parseAdbDevices(result.stdout);
|
|
2527
|
+
}
|
|
2528
|
+
function parseForwardPort(stdout) {
|
|
2529
|
+
const port = Number.parseInt(stdout.trim(), 10);
|
|
2530
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
2531
|
+
throw new Error(`Could not parse a forwarded port from adb output: "${stdout.trim()}"`);
|
|
2532
|
+
}
|
|
2533
|
+
return port;
|
|
2534
|
+
}
|
|
2535
|
+
function parseAaptPackage(stdout) {
|
|
2536
|
+
const match = /package:\s*name='([^']+)'/.exec(stdout);
|
|
2537
|
+
return match?.[1] ?? null;
|
|
2538
|
+
}
|
|
2539
|
+
async function forwardDynamicPort(runner, serial, remotePort) {
|
|
2540
|
+
const result = await runner(withSerial(serial, ["forward", "tcp:0", `tcp:${remotePort}`]), {
|
|
2541
|
+
timeoutMs: 1e4
|
|
2542
|
+
});
|
|
2543
|
+
if (result.code !== 0) {
|
|
2544
|
+
throw new Error(`adb forward failed (exit ${result.code}): ${result.stderr.trim()}`);
|
|
2545
|
+
}
|
|
2546
|
+
return parseForwardPort(result.stdout);
|
|
2547
|
+
}
|
|
2548
|
+
async function removeForward(runner, serial, localPort) {
|
|
2549
|
+
await runner(withSerial(serial, ["forward", "--remove", `tcp:${localPort}`]), { timeoutMs: 1e4 }).catch(
|
|
2550
|
+
() => void 0
|
|
2551
|
+
);
|
|
2552
|
+
}
|
|
2553
|
+
async function installApk(runner, serial, apkPath) {
|
|
2554
|
+
const result = await runner(withSerial(serial, ["install", "-r", "-t", "-g", apkPath]), {
|
|
2555
|
+
timeoutMs: 12e4
|
|
2556
|
+
});
|
|
2557
|
+
if (result.code !== 0 || /failure/i.test(result.stdout)) {
|
|
2558
|
+
throw new Error(
|
|
2559
|
+
`Failed to install APK "${apkPath}" (exit ${result.code}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
|
|
2560
|
+
);
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
function formatUnknownError(error) {
|
|
2564
|
+
return error instanceof Error ? error.message : String(error);
|
|
2565
|
+
}
|
|
2566
|
+
function runnerPhaseError(message, error) {
|
|
2567
|
+
return new Error(`${message}: ${formatUnknownError(error)}`, { cause: error });
|
|
2568
|
+
}
|
|
2569
|
+
function parseResolvedComponent(stdout, pkg) {
|
|
2570
|
+
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
2571
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
2572
|
+
if (/^[\w.]+\/[\w.$]+$/.test(lines[i]) && lines[i].startsWith(`${pkg}/`)) {
|
|
2573
|
+
return lines[i];
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
return null;
|
|
2577
|
+
}
|
|
2578
|
+
async function launchPackage(runner, serial, pkg) {
|
|
2579
|
+
let resolved;
|
|
2580
|
+
try {
|
|
2581
|
+
resolved = await runner(
|
|
2582
|
+
withSerial(serial, [
|
|
2583
|
+
"shell",
|
|
2584
|
+
"cmd",
|
|
2585
|
+
"package",
|
|
2586
|
+
"resolve-activity",
|
|
2587
|
+
"--brief",
|
|
2588
|
+
"-c",
|
|
2589
|
+
"android.intent.category.LAUNCHER",
|
|
2590
|
+
pkg
|
|
2591
|
+
]),
|
|
2592
|
+
{ timeoutMs: 3e4 }
|
|
2593
|
+
);
|
|
2594
|
+
} catch (error) {
|
|
2595
|
+
throw runnerPhaseError(
|
|
2596
|
+
`Failed to resolve the launcher activity for Android package "${pkg}" via adb`,
|
|
2597
|
+
error
|
|
2598
|
+
);
|
|
2599
|
+
}
|
|
2600
|
+
const component = parseResolvedComponent(resolved.stdout, pkg);
|
|
2601
|
+
if (resolved.code !== 0 || !component) {
|
|
2602
|
+
throw new Error(
|
|
2603
|
+
`Could not resolve a launcher activity for Android package "${pkg}" (exit ${resolved.code})${(resolved.stdout + resolved.stderr).trim() ? `: ${(resolved.stdout + resolved.stderr).trim().slice(0, 300)}` : ""}. Is it installed?`
|
|
2604
|
+
);
|
|
2605
|
+
}
|
|
2606
|
+
let start;
|
|
2607
|
+
try {
|
|
2608
|
+
start = await runner(withSerial(serial, ["shell", "am", "start", "-n", component]), {
|
|
2609
|
+
timeoutMs: 3e4
|
|
2610
|
+
});
|
|
2611
|
+
} catch (error) {
|
|
2612
|
+
throw runnerPhaseError(
|
|
2613
|
+
`Failed to launch Android package "${pkg}" with \`am start\` (${component}) via adb`,
|
|
2614
|
+
error
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2617
|
+
if (start.code !== 0 || /error|does not exist|cannot start/i.test(start.stdout + start.stderr)) {
|
|
2618
|
+
throw new Error(
|
|
2619
|
+
`Failed to launch Android package "${pkg}" (${component}, exit ${start.code}): ${(start.stdout + start.stderr).trim().slice(0, 400)}.`
|
|
2620
|
+
);
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
async function forceStop(runner, serial, pkg) {
|
|
2624
|
+
await runner(withSerial(serial, ["shell", "am", "force-stop", pkg]), { timeoutMs: 3e4 });
|
|
2625
|
+
}
|
|
2626
|
+
async function clearPackage(runner, serial, pkg) {
|
|
2627
|
+
const result = await runner(withSerial(serial, ["shell", "pm", "clear", pkg]), { timeoutMs: 3e4 });
|
|
2628
|
+
if (result.code !== 0 || !/success/i.test(result.stdout)) {
|
|
2629
|
+
throw new Error(
|
|
2630
|
+
`Failed to clear Android package "${pkg}" for cold start (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 300)}`
|
|
2631
|
+
);
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
function startInstrumentation(spawner, serial) {
|
|
2635
|
+
return spawner(
|
|
2636
|
+
withSerial(serial, [
|
|
2637
|
+
"shell",
|
|
2638
|
+
"am",
|
|
2639
|
+
"instrument",
|
|
2640
|
+
"-w",
|
|
2641
|
+
"-e",
|
|
2642
|
+
"disableAnalytics",
|
|
2643
|
+
"true",
|
|
2644
|
+
"io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner"
|
|
2645
|
+
])
|
|
2646
|
+
);
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// src/browser/android-agent.ts
|
|
2650
|
+
var DEFAULT_AGENT_REQUEST_TIMEOUT_MS = 3e4;
|
|
2651
|
+
var W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
|
|
2652
|
+
var Uia2HttpError = class extends Error {
|
|
2653
|
+
constructor(message, status, webdriverError) {
|
|
2654
|
+
super(message);
|
|
2655
|
+
this.status = status;
|
|
2656
|
+
this.webdriverError = webdriverError;
|
|
2657
|
+
this.name = "Uia2HttpError";
|
|
2658
|
+
}
|
|
2659
|
+
};
|
|
2660
|
+
var Uia2Transport = class {
|
|
2661
|
+
baseUrl;
|
|
2662
|
+
requestTimeoutMs;
|
|
2663
|
+
fetchImpl;
|
|
2664
|
+
constructor(options) {
|
|
2665
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
2666
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_AGENT_REQUEST_TIMEOUT_MS;
|
|
2667
|
+
const injected = options.fetchImpl;
|
|
2668
|
+
if (injected) {
|
|
2669
|
+
this.fetchImpl = injected;
|
|
2670
|
+
} else if (typeof fetch === "function") {
|
|
2671
|
+
this.fetchImpl = (url, init) => fetch(url, init);
|
|
2672
|
+
} else {
|
|
2673
|
+
throw new Error("global fetch is unavailable; Node 20+ is required for the Android target");
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
/**
|
|
2677
|
+
* Send one request and return the parsed `value` field. Rejects with a
|
|
2678
|
+
* {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
|
|
2679
|
+
* per-request deadline elapses.
|
|
2680
|
+
*/
|
|
2681
|
+
async request(method, path25, body, timeoutMs) {
|
|
2682
|
+
const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
|
|
2683
|
+
const controller = new AbortController();
|
|
2684
|
+
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
2685
|
+
timer.unref?.();
|
|
2686
|
+
const url = `${this.baseUrl}${path25}`;
|
|
2687
|
+
let response;
|
|
2688
|
+
try {
|
|
2689
|
+
response = await this.fetchImpl(url, {
|
|
2690
|
+
method,
|
|
2691
|
+
signal: controller.signal,
|
|
2692
|
+
headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
|
|
2693
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
2694
|
+
});
|
|
2695
|
+
} catch (error) {
|
|
2696
|
+
if (controller.signal.aborted) {
|
|
2697
|
+
const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
|
|
2698
|
+
throw new Error(`uiautomator2 request ${method} ${path25} timed out after ${shown}`);
|
|
2699
|
+
}
|
|
2700
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
2701
|
+
} finally {
|
|
2702
|
+
clearTimeout(timer);
|
|
2703
|
+
}
|
|
2704
|
+
const text = await response.text();
|
|
2705
|
+
const parsed = parseJson(text);
|
|
2706
|
+
if (!response.ok) {
|
|
2707
|
+
const wdError = extractWebdriverError(parsed);
|
|
2708
|
+
throw new Uia2HttpError(
|
|
2709
|
+
`uiautomator2 ${method} ${path25} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
|
|
2710
|
+
response.status,
|
|
2711
|
+
wdError
|
|
2712
|
+
);
|
|
2713
|
+
}
|
|
2714
|
+
return parsed?.value;
|
|
2715
|
+
}
|
|
2716
|
+
};
|
|
2717
|
+
function parseJson(text) {
|
|
2718
|
+
if (!text) {
|
|
2719
|
+
return void 0;
|
|
2720
|
+
}
|
|
2721
|
+
try {
|
|
2722
|
+
return JSON.parse(text);
|
|
2723
|
+
} catch {
|
|
2724
|
+
return void 0;
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
function extractWebdriverError(parsed) {
|
|
2728
|
+
const value = parsed?.value;
|
|
2729
|
+
if (value && typeof value === "object") {
|
|
2730
|
+
const record = value;
|
|
2731
|
+
const error = typeof record.error === "string" ? record.error : void 0;
|
|
2732
|
+
const message = typeof record.message === "string" ? record.message : void 0;
|
|
2733
|
+
return error ?? message;
|
|
2734
|
+
}
|
|
2735
|
+
return void 0;
|
|
2736
|
+
}
|
|
2737
|
+
function extractElementId(value) {
|
|
2738
|
+
if (!value || typeof value !== "object") {
|
|
2739
|
+
return null;
|
|
2740
|
+
}
|
|
2741
|
+
const record = value;
|
|
2742
|
+
const id = record[W3C_ELEMENT_KEY] ?? record.ELEMENT;
|
|
2743
|
+
return typeof id === "string" ? id : null;
|
|
2744
|
+
}
|
|
2745
|
+
function isNoSuchElement(error) {
|
|
2746
|
+
if (error instanceof Uia2HttpError) {
|
|
2747
|
+
return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
|
|
2748
|
+
}
|
|
2749
|
+
return false;
|
|
2750
|
+
}
|
|
2751
|
+
async function createUia2Session(transport) {
|
|
2752
|
+
const value = await transport.request("POST", "/session", {
|
|
2753
|
+
capabilities: { alwaysMatch: {}, firstMatch: [{}] }
|
|
2754
|
+
});
|
|
2755
|
+
const record = value ?? {};
|
|
2756
|
+
const sessionId = record.sessionId;
|
|
2757
|
+
if (typeof sessionId === "string" && sessionId.length > 0) {
|
|
2758
|
+
return sessionId;
|
|
2759
|
+
}
|
|
2760
|
+
throw new Error("uiautomator2 did not return a session id");
|
|
2761
|
+
}
|
|
2762
|
+
async function waitForAgentReady(transport, options = { deadlineMs: 3e4 }) {
|
|
2763
|
+
const interval = options.intervalMs ?? 300;
|
|
2764
|
+
const deadline = Date.now() + options.deadlineMs;
|
|
2765
|
+
let lastError;
|
|
2766
|
+
for (; ; ) {
|
|
2767
|
+
try {
|
|
2768
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
2769
|
+
const value = await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
|
|
2770
|
+
const ready = value?.ready;
|
|
2771
|
+
if (ready === void 0 || ready === true) {
|
|
2772
|
+
return;
|
|
2773
|
+
}
|
|
2774
|
+
} catch (error) {
|
|
2775
|
+
lastError = error;
|
|
2776
|
+
}
|
|
2777
|
+
if (Date.now() >= deadline) {
|
|
2778
|
+
const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
|
|
2779
|
+
throw new Error(`uiautomator2 agent did not become ready within ${options.deadlineMs}ms${detail}`);
|
|
2780
|
+
}
|
|
2781
|
+
await sleep(Math.min(interval, Math.max(0, deadline - Date.now())));
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
function sleep(ms) {
|
|
2785
|
+
return new Promise((resolve) => {
|
|
2786
|
+
const timer = setTimeout(resolve, ms);
|
|
2787
|
+
timer.unref?.();
|
|
2788
|
+
});
|
|
2789
|
+
}
|
|
2790
|
+
function createUia2AgentClient(transport, sessionId, options = {}) {
|
|
2791
|
+
const base = `/session/${sessionId}`;
|
|
2792
|
+
async function locate(query, path25) {
|
|
2793
|
+
return transport.request(
|
|
2794
|
+
"POST",
|
|
2795
|
+
`${base}${path25}`,
|
|
2796
|
+
androidQueryToLocator(query, { appPackage: options.appPackage })
|
|
2797
|
+
);
|
|
2798
|
+
}
|
|
2799
|
+
return {
|
|
2800
|
+
async findElement(query) {
|
|
2801
|
+
try {
|
|
2802
|
+
return extractElementId(await locate(query, "/element"));
|
|
2803
|
+
} catch (error) {
|
|
2804
|
+
if (isNoSuchElement(error)) {
|
|
2805
|
+
return null;
|
|
2806
|
+
}
|
|
2807
|
+
throw error;
|
|
2808
|
+
}
|
|
2809
|
+
},
|
|
2810
|
+
async findElements(query) {
|
|
2811
|
+
const value = await locate(query, "/elements");
|
|
2812
|
+
if (!Array.isArray(value)) {
|
|
2813
|
+
return [];
|
|
2814
|
+
}
|
|
2815
|
+
return value.map((entry) => extractElementId(entry)).filter((id) => id !== null);
|
|
2816
|
+
},
|
|
2817
|
+
async click(elementId) {
|
|
2818
|
+
await transport.request("POST", `${base}/element/${elementId}/click`, {});
|
|
2819
|
+
},
|
|
2820
|
+
async setValue(elementId, text) {
|
|
2821
|
+
await transport.request("POST", `${base}/element/${elementId}/value`, { text });
|
|
2822
|
+
},
|
|
2823
|
+
async getText(elementId) {
|
|
2824
|
+
const value = await transport.request("GET", `${base}/element/${elementId}/text`);
|
|
2825
|
+
return typeof value === "string" ? value : value == null ? null : String(value);
|
|
2826
|
+
},
|
|
2827
|
+
async pressKeyCode(keyCode) {
|
|
2828
|
+
await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
|
|
2829
|
+
},
|
|
2830
|
+
async screenshotPng() {
|
|
2831
|
+
const value = await transport.request("GET", `${base}/screenshot`);
|
|
2832
|
+
if (typeof value !== "string") {
|
|
2833
|
+
throw new Error("uiautomator2 screenshot did not return base64 data");
|
|
2834
|
+
}
|
|
2835
|
+
return Buffer.from(value, "base64");
|
|
2836
|
+
},
|
|
2837
|
+
async source() {
|
|
2838
|
+
const value = await transport.request("GET", `${base}/source`);
|
|
2839
|
+
if (typeof value !== "string") {
|
|
2840
|
+
throw new Error("uiautomator2 /source did not return XML text; cannot analyze Android UI hierarchy");
|
|
2841
|
+
}
|
|
2842
|
+
return value;
|
|
2843
|
+
},
|
|
2844
|
+
async close() {
|
|
2845
|
+
await transport.request("DELETE", base).catch(() => void 0);
|
|
2846
|
+
}
|
|
2847
|
+
};
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
// src/browser/android-helper.ts
|
|
2851
|
+
var import_meta2 = {};
|
|
2852
|
+
var UIA2_REMOTE_PORT = 6790;
|
|
2853
|
+
function resolveAgentApks(requireFn = (0, import_node_module.createRequire)(import_meta2.url)) {
|
|
2854
|
+
let pkgJsonPath;
|
|
2855
|
+
try {
|
|
2856
|
+
pkgJsonPath = requireFn.resolve("appium-uiautomator2-server/package.json");
|
|
2857
|
+
} catch {
|
|
2858
|
+
throw new Error(
|
|
2859
|
+
"The Android target requires the `appium-uiautomator2-server` package (its prebuilt APKs). It is an optional dependency of prowl-tools; it may have been skipped (--omit=optional) or failed to install. Restore it for a global Prowl install with: npm install -g appium-uiautomator2-server@10.6.2. If Prowl is installed locally in a project, run: npm install appium-uiautomator2-server@10.6.2"
|
|
2860
|
+
);
|
|
2861
|
+
}
|
|
2862
|
+
const pkgDir = import_node_path5.default.dirname(pkgJsonPath);
|
|
2863
|
+
const version = requireFn(pkgJsonPath).version;
|
|
2864
|
+
const serverApk = import_node_path5.default.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
|
|
2865
|
+
const testApk = import_node_path5.default.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
|
|
2866
|
+
for (const apk of [serverApk, testApk]) {
|
|
2867
|
+
if (!import_node_fs6.default.existsSync(apk)) {
|
|
2868
|
+
throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
return { serverApk, testApk };
|
|
2872
|
+
}
|
|
2873
|
+
function looksLikeApk(app) {
|
|
2874
|
+
return app.toLowerCase().endsWith(".apk");
|
|
2875
|
+
}
|
|
2876
|
+
var execFileAaptResolver = async (apkPath) => {
|
|
2877
|
+
for (const tool of ["aapt", "aapt2"]) {
|
|
2878
|
+
const output = await new Promise((resolve) => {
|
|
2879
|
+
(0, import_node_child_process4.execFile)(
|
|
2880
|
+
tool,
|
|
2881
|
+
["dump", "badging", apkPath],
|
|
2882
|
+
{ encoding: "utf-8", timeout: 2e4, maxBuffer: 8 * 1024 * 1024 },
|
|
2883
|
+
(error, stdout) => resolve(error ? null : stdout)
|
|
2884
|
+
);
|
|
2885
|
+
});
|
|
2886
|
+
const pkg = output ? parseAaptPackage(output) : null;
|
|
2887
|
+
if (pkg) {
|
|
2888
|
+
return pkg;
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
return null;
|
|
2892
|
+
};
|
|
2893
|
+
var defaultAgentConnector = async ({
|
|
2894
|
+
host,
|
|
2895
|
+
port,
|
|
2896
|
+
requestTimeoutMs,
|
|
2897
|
+
readyDeadlineMs,
|
|
2898
|
+
appPackage
|
|
2899
|
+
}) => {
|
|
2900
|
+
const transport = new Uia2Transport({
|
|
2901
|
+
baseUrl: `http://${host}:${port}/wd/hub`,
|
|
2902
|
+
requestTimeoutMs
|
|
2903
|
+
});
|
|
2904
|
+
await waitForAgentReady(transport, { deadlineMs: readyDeadlineMs });
|
|
2905
|
+
const sessionId = await createUia2Session(transport);
|
|
2906
|
+
return createUia2AgentClient(transport, sessionId, { appPackage });
|
|
2907
|
+
};
|
|
2908
|
+
async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
|
|
2909
|
+
if (!looksLikeApk(app)) {
|
|
2910
|
+
assertAndroidAppAllowed(allowedApps, app);
|
|
2911
|
+
return app;
|
|
2912
|
+
}
|
|
2913
|
+
const apkPath = import_node_path5.default.resolve(app);
|
|
2914
|
+
if (!import_node_fs6.default.existsSync(apkPath)) {
|
|
2915
|
+
throw new Error(`APK not found: ${apkPath}`);
|
|
2916
|
+
}
|
|
2917
|
+
const pkg = await aaptResolver(apkPath);
|
|
2918
|
+
if (!pkg) {
|
|
2919
|
+
throw new Error(
|
|
2920
|
+
`Could not determine the package name for "${app}". Put Android build-tools \`aapt\`/\`aapt2\` on PATH, or set target.app to the package name instead of the .apk path.`
|
|
2921
|
+
);
|
|
2922
|
+
}
|
|
2923
|
+
assertAndroidAppAllowed(allowedApps, apkPath, pkg);
|
|
2924
|
+
await installApk(runner, serial, apkPath);
|
|
2925
|
+
return pkg;
|
|
2926
|
+
}
|
|
2927
|
+
async function launchAndroidSession(options) {
|
|
2928
|
+
const runner = options.runner ?? execFileAdbRunner;
|
|
2929
|
+
const spawner = options.spawner ?? spawnAdbProcess;
|
|
2930
|
+
const connector = options.agentConnector ?? defaultAgentConnector;
|
|
2931
|
+
const aaptResolver = options.aaptResolver ?? execFileAaptResolver;
|
|
2932
|
+
const apks = options.apks ?? resolveAgentApks();
|
|
2933
|
+
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS) + 5e3;
|
|
2934
|
+
const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS);
|
|
2935
|
+
const devices = await listDevices(runner);
|
|
2936
|
+
const serial = selectDeviceSerial(devices, options.deviceSerial);
|
|
2937
|
+
const pkg = await resolvePackage(options.app, runner, serial, aaptResolver, options.allowedApps ?? []);
|
|
2938
|
+
await installApk(runner, serial, apks.serverApk);
|
|
2939
|
+
await installApk(runner, serial, apks.testApk);
|
|
2940
|
+
if (options.coldStart) {
|
|
2941
|
+
await clearPackage(runner, serial, pkg);
|
|
2942
|
+
}
|
|
2943
|
+
await launchPackage(runner, serial, pkg);
|
|
2944
|
+
let instrumentation;
|
|
2945
|
+
let localPort;
|
|
2946
|
+
let client;
|
|
2947
|
+
let tornDown = false;
|
|
2948
|
+
const teardown = async () => {
|
|
2949
|
+
if (tornDown) {
|
|
2950
|
+
return;
|
|
2951
|
+
}
|
|
2952
|
+
tornDown = true;
|
|
2953
|
+
if (client) {
|
|
2954
|
+
await client.close().catch(() => void 0);
|
|
2955
|
+
}
|
|
2956
|
+
instrumentation?.kill();
|
|
2957
|
+
const forwardedPort = localPort;
|
|
2958
|
+
localPort = void 0;
|
|
2959
|
+
if (forwardedPort !== void 0) {
|
|
2960
|
+
await removeForward(runner, serial, forwardedPort);
|
|
2961
|
+
}
|
|
2962
|
+
await forceStop(runner, serial, pkg).catch(() => void 0);
|
|
2963
|
+
};
|
|
2964
|
+
try {
|
|
2965
|
+
instrumentation = startInstrumentation(spawner, serial);
|
|
2966
|
+
localPort = await forwardDynamicPort(runner, serial, UIA2_REMOTE_PORT);
|
|
2967
|
+
client = await connector({
|
|
2968
|
+
host: "127.0.0.1",
|
|
2969
|
+
port: localPort,
|
|
2970
|
+
requestTimeoutMs,
|
|
2971
|
+
readyDeadlineMs,
|
|
2972
|
+
appPackage: pkg
|
|
2973
|
+
});
|
|
2974
|
+
const driver = createAndroidDriver(client, { appLabel: pkg });
|
|
2975
|
+
return { client, driver, package: pkg, serial, teardown };
|
|
2976
|
+
} catch (error) {
|
|
2977
|
+
await teardown();
|
|
2978
|
+
throw error;
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
async function closeAndroidSession(session) {
|
|
2982
|
+
await session.teardown();
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
// src/browser/ios-helper.ts
|
|
2986
|
+
var import_node_module2 = require("module");
|
|
2987
|
+
var import_node_child_process6 = require("child_process");
|
|
2988
|
+
var import_node_fs7 = __toESM(require("fs"), 1);
|
|
2989
|
+
var import_node_os2 = __toESM(require("os"), 1);
|
|
2990
|
+
var import_node_path7 = __toESM(require("path"), 1);
|
|
2991
|
+
|
|
2992
|
+
// src/browser/ios-driver.ts
|
|
2993
|
+
var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
2994
|
+
"query",
|
|
2995
|
+
"interact",
|
|
2996
|
+
"wait",
|
|
2997
|
+
"screenshot"
|
|
2998
|
+
]);
|
|
2999
|
+
var WAIT_POLL_INTERVAL_MS2 = 250;
|
|
3000
|
+
var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
|
|
3001
|
+
var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
|
|
3002
|
+
function escapePredicateArg(value) {
|
|
3003
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
3004
|
+
}
|
|
3005
|
+
function toIosQuery(selector) {
|
|
3006
|
+
switch (selector.kind) {
|
|
3007
|
+
case "focused":
|
|
3008
|
+
return { by: "focused" };
|
|
3009
|
+
case "id":
|
|
3010
|
+
return { by: "accessibilityId", value: selector.value };
|
|
3011
|
+
case "role":
|
|
3012
|
+
return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
|
|
3013
|
+
case "label":
|
|
3014
|
+
return { by: "label", value: selector.value };
|
|
3015
|
+
case "text":
|
|
3016
|
+
return { by: "text", value: selector.value };
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
function parseIosSelector(selector) {
|
|
3020
|
+
return toIosQuery(parseNativeSelector(selector));
|
|
3021
|
+
}
|
|
3022
|
+
function iosQueryToLocator(query) {
|
|
3023
|
+
switch (query.by) {
|
|
3024
|
+
case "accessibilityId":
|
|
3025
|
+
return { using: "accessibility id", value: query.value };
|
|
3026
|
+
case "label":
|
|
3027
|
+
return { using: "predicate string", value: `label == "${escapePredicateArg(query.value)}"` };
|
|
3028
|
+
case "text": {
|
|
3029
|
+
const escaped = escapePredicateArg(query.value);
|
|
3030
|
+
return {
|
|
3031
|
+
using: "predicate string",
|
|
3032
|
+
value: `label CONTAINS "${escaped}" OR value CONTAINS "${escaped}"`
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
case "focused":
|
|
3036
|
+
return { using: "predicate string", value: "hasKeyboardFocus == 1" };
|
|
3037
|
+
case "role": {
|
|
3038
|
+
const className = normalizeXcuiClassName(query.role);
|
|
3039
|
+
if (query.name === void 0 || query.name.length === 0) {
|
|
3040
|
+
return { using: "class name", value: className };
|
|
3041
|
+
}
|
|
3042
|
+
const escapedClass = escapePredicateArg(className);
|
|
3043
|
+
const escapedName = escapePredicateArg(query.name);
|
|
3044
|
+
return {
|
|
3045
|
+
using: "predicate string",
|
|
3046
|
+
value: `type == "${escapedClass}" AND (label CONTAINS "${escapedName}" OR value CONTAINS "${escapedName}")`
|
|
3047
|
+
};
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
function unwrapIosTextSelector(selector) {
|
|
3052
|
+
return unwrapNativeTextSelector(selector);
|
|
3053
|
+
}
|
|
3054
|
+
function delay2(ms) {
|
|
3055
|
+
return new Promise((resolve) => {
|
|
3056
|
+
setTimeout(resolve, ms);
|
|
3057
|
+
});
|
|
3058
|
+
}
|
|
3059
|
+
function createIosDriver(client, options) {
|
|
3060
|
+
const unsupported = (verb) => new Error(`${verb} is not supported by the iOS target`);
|
|
3061
|
+
const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
|
|
3062
|
+
async function resolveOne(selector) {
|
|
3063
|
+
const id = await client.findElement(parseIosSelector(selector));
|
|
3064
|
+
if (id === null) {
|
|
3065
|
+
throw new Error(`No element matched selector: ${selector}`);
|
|
3066
|
+
}
|
|
3067
|
+
return id;
|
|
3068
|
+
}
|
|
3069
|
+
async function clickSelector(selector) {
|
|
3070
|
+
await client.click(await resolveOne(selector));
|
|
3071
|
+
}
|
|
3072
|
+
async function fillSelector(selector, value) {
|
|
3073
|
+
await client.setValue(await resolveOne(selector), value);
|
|
3074
|
+
}
|
|
3075
|
+
async function pressKey(key) {
|
|
3076
|
+
const name = key.trim().toLowerCase();
|
|
3077
|
+
if (name === "enter" || name === "return") {
|
|
3078
|
+
await client.sendKeys(["\n"]);
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
if (name === "delete" || name === "backspace" || name === "del") {
|
|
3082
|
+
await client.sendKeys(["\b"]);
|
|
3083
|
+
return;
|
|
3084
|
+
}
|
|
3085
|
+
if (name === "home") {
|
|
3086
|
+
await client.homescreen();
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
throw new Error(
|
|
3090
|
+
`Unsupported key "${key}" for the iOS target. Supported keys: ${IOS_PRESS_KEYS.join(", ")}.`
|
|
3091
|
+
);
|
|
3092
|
+
}
|
|
3093
|
+
return {
|
|
3094
|
+
capabilities: IOS_CAPABILITIES,
|
|
3095
|
+
// navigation -----------------------------------------------------------
|
|
3096
|
+
goto(_url, _options) {
|
|
3097
|
+
return rejectUnsupported("navigate");
|
|
3098
|
+
},
|
|
3099
|
+
currentUrl() {
|
|
3100
|
+
return `ios:${options.appLabel ?? ""}`;
|
|
3101
|
+
},
|
|
3102
|
+
// queries --------------------------------------------------------------
|
|
3103
|
+
async count(selector) {
|
|
3104
|
+
return (await client.findElements(parseIosSelector(selector))).length;
|
|
3105
|
+
},
|
|
3106
|
+
async textContent(selector) {
|
|
3107
|
+
const id = await client.findElement(parseIosSelector(selector));
|
|
3108
|
+
if (id === null) {
|
|
3109
|
+
return null;
|
|
3110
|
+
}
|
|
3111
|
+
return client.getText(id);
|
|
3112
|
+
},
|
|
3113
|
+
// interactions ---------------------------------------------------------
|
|
3114
|
+
click: clickSelector,
|
|
3115
|
+
clickFirst: clickSelector,
|
|
3116
|
+
fill: fillSelector,
|
|
3117
|
+
fillFirst: fillSelector,
|
|
3118
|
+
async press(_selector, key) {
|
|
3119
|
+
await pressKey(key);
|
|
3120
|
+
},
|
|
3121
|
+
selectOption() {
|
|
3122
|
+
return rejectUnsupported("select");
|
|
3123
|
+
},
|
|
3124
|
+
selectOptionFirst() {
|
|
3125
|
+
return rejectUnsupported("select");
|
|
3126
|
+
},
|
|
3127
|
+
hover() {
|
|
3128
|
+
return rejectUnsupported("hover");
|
|
3129
|
+
},
|
|
3130
|
+
scrollIntoView() {
|
|
3131
|
+
return rejectUnsupported("scrollTo");
|
|
3132
|
+
},
|
|
3133
|
+
setInputFiles() {
|
|
3134
|
+
return rejectUnsupported("setInputFiles");
|
|
3135
|
+
},
|
|
3136
|
+
// semantic locators ----------------------------------------------------
|
|
3137
|
+
async countByRole(role, name) {
|
|
3138
|
+
return (await client.findElements({ by: "role", role, name })).length;
|
|
3139
|
+
},
|
|
3140
|
+
async clickFirstByRole(role, name) {
|
|
3141
|
+
const id = await client.findElement({ by: "role", role, name });
|
|
3142
|
+
if (id === null) {
|
|
3143
|
+
throw new Error(`No element matched role=${role}[name="${name}"]`);
|
|
3144
|
+
}
|
|
3145
|
+
await client.click(id);
|
|
3146
|
+
},
|
|
3147
|
+
async countByLabel(label) {
|
|
3148
|
+
return (await client.findElements({ by: "label", value: label })).length;
|
|
3149
|
+
},
|
|
3150
|
+
async fillFirstByLabel(label, value) {
|
|
3151
|
+
const id = await client.findElement({ by: "label", value: label });
|
|
3152
|
+
if (id === null) {
|
|
3153
|
+
throw new Error(`No element matched label="${label}"`);
|
|
3154
|
+
}
|
|
3155
|
+
await client.setValue(id, value);
|
|
3156
|
+
},
|
|
3157
|
+
selectOptionFirstByLabel() {
|
|
3158
|
+
return rejectUnsupported("select");
|
|
3159
|
+
},
|
|
3160
|
+
// waiting --------------------------------------------------------------
|
|
3161
|
+
async waitForSelector(selector, waitOptions) {
|
|
3162
|
+
const query = parseIosSelector(selector);
|
|
3163
|
+
const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
|
|
3164
|
+
const deadline = Date.now() + timeoutMs;
|
|
3165
|
+
for (; ; ) {
|
|
3166
|
+
if ((await client.findElements(query)).length > 0) {
|
|
3167
|
+
return;
|
|
3168
|
+
}
|
|
3169
|
+
if (Date.now() >= deadline) {
|
|
3170
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
|
|
3171
|
+
}
|
|
3172
|
+
await delay2(Math.min(WAIT_POLL_INTERVAL_MS2, Math.max(0, deadline - Date.now())));
|
|
3173
|
+
}
|
|
3174
|
+
},
|
|
3175
|
+
waitForUrl() {
|
|
3176
|
+
return rejectUnsupported("waitForUrl");
|
|
3177
|
+
},
|
|
3178
|
+
waitForNetworkIdle() {
|
|
3179
|
+
return rejectUnsupported("waitForNetworkIdle");
|
|
3180
|
+
},
|
|
3181
|
+
// scripting & artifacts ------------------------------------------------
|
|
3182
|
+
evaluate() {
|
|
3183
|
+
return rejectUnsupported("evalScript");
|
|
3184
|
+
},
|
|
3185
|
+
async screenshot(screenshotOptions) {
|
|
3186
|
+
await options.captureScreenshot(screenshotOptions.path);
|
|
3187
|
+
},
|
|
3188
|
+
// network / dialogs / downloads (all web-only) -------------------------
|
|
3189
|
+
onResponse(_handler) {
|
|
3190
|
+
throw unsupported("onResponse");
|
|
3191
|
+
},
|
|
3192
|
+
route(_url, _handler) {
|
|
3193
|
+
return rejectUnsupported("mockRoute");
|
|
3194
|
+
},
|
|
3195
|
+
unroute() {
|
|
3196
|
+
return rejectUnsupported("unmockRoute");
|
|
3197
|
+
},
|
|
3198
|
+
onDialog(_action) {
|
|
3199
|
+
throw unsupported("onDialog");
|
|
3200
|
+
},
|
|
3201
|
+
waitForDownloadEvent() {
|
|
3202
|
+
return rejectUnsupported("waitForDownload");
|
|
3203
|
+
},
|
|
3204
|
+
parseTextSelector(selector) {
|
|
3205
|
+
return unwrapIosTextSelector(selector);
|
|
3206
|
+
}
|
|
3207
|
+
};
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
// src/browser/ios-simctl.ts
|
|
3211
|
+
var import_node_child_process5 = require("child_process");
|
|
3212
|
+
var import_promises = require("fs/promises");
|
|
3213
|
+
var import_node_net = __toESM(require("net"), 1);
|
|
3214
|
+
var import_node_os = __toESM(require("os"), 1);
|
|
3215
|
+
var import_node_path6 = __toESM(require("path"), 1);
|
|
3216
|
+
var spawnXcrunProcess = (args, options) => {
|
|
3217
|
+
const child = (0, import_node_child_process5.spawn)("xcrun", args, {
|
|
3218
|
+
stdio: "ignore",
|
|
3219
|
+
env: options?.env ? { ...process.env, ...options.env } : process.env
|
|
3220
|
+
});
|
|
3221
|
+
child.on("error", () => {
|
|
3222
|
+
});
|
|
3223
|
+
return {
|
|
3224
|
+
kill: () => {
|
|
3225
|
+
child.kill();
|
|
3226
|
+
}
|
|
3227
|
+
};
|
|
3228
|
+
};
|
|
3229
|
+
var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path6.default.join(import_node_os.default.tmpdir(), "prowl-ios-simulator-locks");
|
|
3230
|
+
var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
|
|
3231
|
+
var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
|
|
3232
|
+
(0, import_node_child_process5.execFile)(
|
|
3233
|
+
"xcrun",
|
|
3234
|
+
args,
|
|
3235
|
+
{
|
|
3236
|
+
encoding: "utf-8",
|
|
3237
|
+
timeout: options?.timeoutMs,
|
|
3238
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
3239
|
+
env: options?.env ? { ...process.env, ...options.env } : process.env
|
|
3240
|
+
},
|
|
3241
|
+
(error, stdout, stderr) => {
|
|
3242
|
+
const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
|
|
3243
|
+
const capturedStderr = stderr ?? "";
|
|
3244
|
+
resolve({
|
|
3245
|
+
stdout: stdout ?? "",
|
|
3246
|
+
stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
|
|
3247
|
+
code
|
|
3248
|
+
});
|
|
3249
|
+
}
|
|
3250
|
+
);
|
|
3251
|
+
});
|
|
3252
|
+
function simulatorLockName(udid) {
|
|
3253
|
+
const safe = udid.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
3254
|
+
return `${safe || "simulator"}.lock`;
|
|
3255
|
+
}
|
|
3256
|
+
function isErrno(error, code) {
|
|
3257
|
+
return error?.code === code;
|
|
3258
|
+
}
|
|
3259
|
+
function isProcessAlive(pid) {
|
|
3260
|
+
try {
|
|
3261
|
+
process.kill(pid, 0);
|
|
3262
|
+
return true;
|
|
3263
|
+
} catch (error) {
|
|
3264
|
+
return isErrno(error, "EPERM");
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
async function removeStaleSimulatorLock(lockPath) {
|
|
3268
|
+
try {
|
|
3269
|
+
const ownerText = await (0, import_promises.readFile)(import_node_path6.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
|
|
3270
|
+
const owner = JSON.parse(ownerText);
|
|
3271
|
+
if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
|
|
3272
|
+
await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
|
|
3273
|
+
return true;
|
|
3274
|
+
}
|
|
3275
|
+
} catch {
|
|
3276
|
+
return false;
|
|
3277
|
+
}
|
|
3278
|
+
return false;
|
|
3279
|
+
}
|
|
3280
|
+
function simulatorReservedError(udid) {
|
|
3281
|
+
return new Error(
|
|
3282
|
+
`iOS simulator "${udid}" is already reserved by another Prowl process. Wait for that run to finish, or boot/select a different simulator with target.udid.`
|
|
3283
|
+
);
|
|
3284
|
+
}
|
|
3285
|
+
async function reserveSimulatorUdid(udid, options = {}) {
|
|
3286
|
+
const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
|
|
3287
|
+
const lockPath = import_node_path6.default.join(lockRoot, simulatorLockName(udid));
|
|
3288
|
+
await (0, import_promises.mkdir)(lockRoot, { recursive: true });
|
|
3289
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
3290
|
+
try {
|
|
3291
|
+
await (0, import_promises.mkdir)(lockPath);
|
|
3292
|
+
} catch (error) {
|
|
3293
|
+
if (!isErrno(error, "EEXIST")) {
|
|
3294
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
3295
|
+
}
|
|
3296
|
+
if (attempt === 0 && await removeStaleSimulatorLock(lockPath)) {
|
|
3297
|
+
continue;
|
|
3298
|
+
}
|
|
3299
|
+
throw simulatorReservedError(udid);
|
|
3300
|
+
}
|
|
3301
|
+
let released = false;
|
|
3302
|
+
const release = async () => {
|
|
3303
|
+
if (released) {
|
|
3304
|
+
return;
|
|
3305
|
+
}
|
|
3306
|
+
released = true;
|
|
3307
|
+
await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
|
|
3308
|
+
};
|
|
3309
|
+
try {
|
|
3310
|
+
await (0, import_promises.writeFile)(
|
|
3311
|
+
import_node_path6.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
|
|
3312
|
+
`${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
|
|
3313
|
+
`,
|
|
3314
|
+
{ flag: "wx" }
|
|
3315
|
+
);
|
|
3316
|
+
} catch (error) {
|
|
3317
|
+
await release().catch(() => void 0);
|
|
3318
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
3319
|
+
}
|
|
3320
|
+
return { udid, release };
|
|
3321
|
+
}
|
|
3322
|
+
throw simulatorReservedError(udid);
|
|
3323
|
+
}
|
|
3324
|
+
function findFreePort() {
|
|
3325
|
+
return new Promise((resolve, reject) => {
|
|
3326
|
+
const server = import_node_net.default.createServer();
|
|
3327
|
+
server.on("error", reject);
|
|
3328
|
+
server.listen(0, "127.0.0.1", () => {
|
|
3329
|
+
const address = server.address();
|
|
3330
|
+
if (address && typeof address === "object") {
|
|
3331
|
+
const { port } = address;
|
|
3332
|
+
server.close(() => resolve(port));
|
|
3333
|
+
} else {
|
|
3334
|
+
server.close(() => reject(new Error("Could not allocate a local port")));
|
|
3335
|
+
}
|
|
3336
|
+
});
|
|
3337
|
+
});
|
|
3338
|
+
}
|
|
3339
|
+
function parseSimctlDevices(json) {
|
|
3340
|
+
let parsed;
|
|
3341
|
+
try {
|
|
3342
|
+
parsed = JSON.parse(json);
|
|
3343
|
+
} catch {
|
|
3344
|
+
throw new Error("Could not parse `simctl list devices --json` output.");
|
|
3345
|
+
}
|
|
3346
|
+
const byRuntime = parsed?.devices;
|
|
3347
|
+
if (!byRuntime || typeof byRuntime !== "object") {
|
|
3348
|
+
return [];
|
|
3349
|
+
}
|
|
3350
|
+
const devices = [];
|
|
3351
|
+
for (const [runtime, entries] of Object.entries(byRuntime)) {
|
|
3352
|
+
if (!Array.isArray(entries)) {
|
|
3353
|
+
continue;
|
|
3354
|
+
}
|
|
3355
|
+
for (const entry of entries) {
|
|
3356
|
+
if (!entry || typeof entry !== "object") {
|
|
3357
|
+
continue;
|
|
3358
|
+
}
|
|
3359
|
+
const record = entry;
|
|
3360
|
+
const udid = typeof record.udid === "string" ? record.udid : void 0;
|
|
3361
|
+
const name = typeof record.name === "string" ? record.name : void 0;
|
|
3362
|
+
const state = typeof record.state === "string" ? record.state : "Unknown";
|
|
3363
|
+
if (!udid || !name) {
|
|
3364
|
+
continue;
|
|
3365
|
+
}
|
|
3366
|
+
devices.push({
|
|
3367
|
+
udid,
|
|
3368
|
+
name,
|
|
3369
|
+
state,
|
|
3370
|
+
runtime,
|
|
3371
|
+
isAvailable: record.isAvailable !== false
|
|
3372
|
+
});
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
return devices;
|
|
3376
|
+
}
|
|
3377
|
+
function bootedSimulators(devices) {
|
|
3378
|
+
return devices.filter((device) => device.state === "Booted");
|
|
3379
|
+
}
|
|
3380
|
+
function describeSimulator(device) {
|
|
3381
|
+
const runtime = device.runtime.replace(/^com\.apple\.CoreSimulator\.SimRuntime\./, "");
|
|
3382
|
+
return `${device.name} [${runtime}] (${device.udid})`;
|
|
3383
|
+
}
|
|
3384
|
+
function selectSimulatorUdid(devices, requested) {
|
|
3385
|
+
const booted = bootedSimulators(devices);
|
|
3386
|
+
if (requested) {
|
|
3387
|
+
const match = devices.find((device) => device.udid === requested);
|
|
3388
|
+
if (!match) {
|
|
3389
|
+
const known = devices.length > 0 ? devices.map((d) => d.udid).join(", ") : "none";
|
|
3390
|
+
throw new Error(
|
|
3391
|
+
`iOS simulator "${requested}" was not found. Known simulators: ${known}. Check \`xcrun simctl list devices\`.`
|
|
3392
|
+
);
|
|
3393
|
+
}
|
|
3394
|
+
if (match.state !== "Booted") {
|
|
3395
|
+
throw new Error(
|
|
3396
|
+
`iOS simulator "${requested}" is not booted (state: ${match.state}). Boot it with \`xcrun simctl boot ${requested}\`, then retry.`
|
|
3397
|
+
);
|
|
3398
|
+
}
|
|
3399
|
+
return requested;
|
|
3400
|
+
}
|
|
3401
|
+
if (booted.length === 0) {
|
|
3402
|
+
throw new Error(
|
|
3403
|
+
"No booted iOS simulator found. Boot one from Xcode or with `xcrun simctl boot <udid>` (see `xcrun simctl list devices available`), then retry."
|
|
3404
|
+
);
|
|
3405
|
+
}
|
|
3406
|
+
if (booted.length > 1) {
|
|
3407
|
+
throw new Error(
|
|
3408
|
+
`Multiple iOS simulators are booted (${booted.map(describeSimulator).join("; ")}). Set target.udid to pick one.`
|
|
3409
|
+
);
|
|
3410
|
+
}
|
|
3411
|
+
return booted[0].udid;
|
|
3412
|
+
}
|
|
3413
|
+
async function listSimulators(runner) {
|
|
3414
|
+
const result = await runner(["simctl", "list", "devices", "--json"], { timeoutMs: 3e4 });
|
|
3415
|
+
if (result.code !== 0) {
|
|
3416
|
+
throw new Error(
|
|
3417
|
+
`\`xcrun simctl list devices\` failed. Is Xcode installed and are the command-line tools selected (\`xcode-select -p\`)? ${(result.stderr.trim() || "").slice(0, 400)}`
|
|
3418
|
+
);
|
|
3419
|
+
}
|
|
3420
|
+
return parseSimctlDevices(result.stdout);
|
|
3421
|
+
}
|
|
3422
|
+
async function installApp(runner, udid, appPath) {
|
|
3423
|
+
const result = await runner(["simctl", "install", udid, appPath], { timeoutMs: 12e4 });
|
|
3424
|
+
if (result.code !== 0) {
|
|
3425
|
+
throw new Error(
|
|
3426
|
+
`Failed to install "${appPath}" onto simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
|
|
3427
|
+
);
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
async function uninstallApp(runner, udid, bundleId) {
|
|
3431
|
+
await runner(["simctl", "uninstall", udid, bundleId], { timeoutMs: 6e4 }).catch(() => void 0);
|
|
3432
|
+
}
|
|
3433
|
+
async function launchApp(runner, udid, bundleId, childEnv = {}) {
|
|
3434
|
+
const env = {};
|
|
3435
|
+
for (const [key, value] of Object.entries(childEnv)) {
|
|
3436
|
+
env[`SIMCTL_CHILD_${key}`] = value;
|
|
3437
|
+
}
|
|
3438
|
+
const result = await runner(["simctl", "launch", udid, bundleId], {
|
|
3439
|
+
timeoutMs: 6e4,
|
|
3440
|
+
env: Object.keys(env).length > 0 ? env : void 0
|
|
3441
|
+
});
|
|
3442
|
+
if (result.code !== 0) {
|
|
3443
|
+
throw new Error(
|
|
3444
|
+
`Failed to launch "${bundleId}" on simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}. Is it installed?`
|
|
3445
|
+
);
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
async function terminateApp(runner, udid, bundleId) {
|
|
3449
|
+
await runner(["simctl", "terminate", udid, bundleId], { timeoutMs: 3e4 }).catch(() => void 0);
|
|
3450
|
+
}
|
|
3451
|
+
async function captureScreenshot(runner, udid, outPath) {
|
|
3452
|
+
const result = await runner(["simctl", "io", udid, "screenshot", outPath], { timeoutMs: 3e4 });
|
|
3453
|
+
if (result.code !== 0) {
|
|
3454
|
+
throw new Error(
|
|
3455
|
+
`Failed to capture a simulator screenshot (${udid}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 300)}`
|
|
3456
|
+
);
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
function parseXcodeVersion(stdout) {
|
|
3460
|
+
const match = /Xcode\s+([\d.]+)/i.exec(stdout);
|
|
3461
|
+
return match?.[1] ?? null;
|
|
3462
|
+
}
|
|
3463
|
+
async function xcodeVersion(runner) {
|
|
3464
|
+
const result = await runner(["xcodebuild", "-version"], { timeoutMs: 3e4 });
|
|
3465
|
+
if (result.code !== 0) {
|
|
3466
|
+
return null;
|
|
3467
|
+
}
|
|
3468
|
+
return parseXcodeVersion(result.stdout);
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
// src/browser/ios-agent.ts
|
|
3472
|
+
var DEFAULT_WDA_REQUEST_TIMEOUT_MS = 3e4;
|
|
3473
|
+
var W3C_ELEMENT_KEY2 = "element-6066-11e4-a52e-4f735466cecf";
|
|
3474
|
+
var WdaHttpError = class extends Error {
|
|
3475
|
+
constructor(message, status, webdriverError) {
|
|
3476
|
+
super(message);
|
|
3477
|
+
this.status = status;
|
|
3478
|
+
this.webdriverError = webdriverError;
|
|
3479
|
+
this.name = "WdaHttpError";
|
|
3480
|
+
}
|
|
3481
|
+
};
|
|
3482
|
+
var WdaTransport = class {
|
|
3483
|
+
baseUrl;
|
|
3484
|
+
requestTimeoutMs;
|
|
3485
|
+
fetchImpl;
|
|
3486
|
+
constructor(options) {
|
|
3487
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
3488
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_WDA_REQUEST_TIMEOUT_MS;
|
|
3489
|
+
const injected = options.fetchImpl;
|
|
3490
|
+
if (injected) {
|
|
3491
|
+
this.fetchImpl = injected;
|
|
3492
|
+
} else if (typeof fetch === "function") {
|
|
3493
|
+
this.fetchImpl = (url, init) => fetch(url, init);
|
|
3494
|
+
} else {
|
|
3495
|
+
throw new Error("global fetch is unavailable; Node 20+ is required for the iOS target");
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
/**
|
|
3499
|
+
* Send one request and return the full parsed JSON body. Rejects with a
|
|
3500
|
+
* {@link WdaHttpError} on a non-2xx response, or a timeout error when the
|
|
3501
|
+
* per-request deadline elapses.
|
|
3502
|
+
*/
|
|
3503
|
+
async requestFull(method, path25, body, timeoutMs) {
|
|
3504
|
+
const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
|
|
3505
|
+
const controller = new AbortController();
|
|
3506
|
+
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
3507
|
+
timer.unref?.();
|
|
3508
|
+
const url = `${this.baseUrl}${path25}`;
|
|
3509
|
+
const timeoutError = () => {
|
|
3510
|
+
const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
|
|
3511
|
+
return new Error(`WebDriverAgent request ${method} ${path25} timed out after ${shown}`);
|
|
3512
|
+
};
|
|
3513
|
+
let response;
|
|
3514
|
+
try {
|
|
3515
|
+
response = await this.fetchImpl(url, {
|
|
3516
|
+
method,
|
|
3517
|
+
signal: controller.signal,
|
|
3518
|
+
headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
|
|
3519
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
3520
|
+
});
|
|
3521
|
+
} catch (error) {
|
|
3522
|
+
clearTimeout(timer);
|
|
3523
|
+
if (controller.signal.aborted) {
|
|
3524
|
+
throw timeoutError();
|
|
3525
|
+
}
|
|
3526
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
3527
|
+
}
|
|
3528
|
+
let text;
|
|
3529
|
+
try {
|
|
3530
|
+
text = await response.text();
|
|
3531
|
+
} catch (error) {
|
|
3532
|
+
if (controller.signal.aborted) {
|
|
3533
|
+
throw timeoutError();
|
|
3534
|
+
}
|
|
3535
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
3536
|
+
} finally {
|
|
3537
|
+
clearTimeout(timer);
|
|
3538
|
+
}
|
|
3539
|
+
const parsed = parseJson2(text);
|
|
3540
|
+
if (!response.ok) {
|
|
3541
|
+
const wdError = extractWebdriverError2(parsed);
|
|
3542
|
+
throw new WdaHttpError(
|
|
3543
|
+
`WebDriverAgent ${method} ${path25} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
|
|
3544
|
+
response.status,
|
|
3545
|
+
wdError
|
|
3546
|
+
);
|
|
3547
|
+
}
|
|
3548
|
+
return parsed;
|
|
3549
|
+
}
|
|
3550
|
+
/** Like {@link requestFull} but returns just the `value` field. */
|
|
3551
|
+
async request(method, path25, body, timeoutMs) {
|
|
3552
|
+
const parsed = await this.requestFull(method, path25, body, timeoutMs);
|
|
3553
|
+
return parsed?.value;
|
|
3554
|
+
}
|
|
3555
|
+
};
|
|
3556
|
+
function parseJson2(text) {
|
|
3557
|
+
if (!text) {
|
|
3558
|
+
return void 0;
|
|
3559
|
+
}
|
|
3560
|
+
try {
|
|
3561
|
+
return JSON.parse(text);
|
|
3562
|
+
} catch {
|
|
3563
|
+
return void 0;
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
function extractWebdriverError2(parsed) {
|
|
3567
|
+
const value = parsed?.value;
|
|
3568
|
+
if (value && typeof value === "object") {
|
|
3569
|
+
const record = value;
|
|
3570
|
+
const error = typeof record.error === "string" ? record.error : void 0;
|
|
3571
|
+
const message = typeof record.message === "string" ? record.message : void 0;
|
|
3572
|
+
return error ?? message;
|
|
3573
|
+
}
|
|
3574
|
+
return void 0;
|
|
3575
|
+
}
|
|
3576
|
+
function extractElementId2(value) {
|
|
3577
|
+
if (!value || typeof value !== "object") {
|
|
3578
|
+
return null;
|
|
3579
|
+
}
|
|
3580
|
+
const record = value;
|
|
3581
|
+
const id = record[W3C_ELEMENT_KEY2] ?? record.ELEMENT;
|
|
3582
|
+
return typeof id === "string" ? id : null;
|
|
3583
|
+
}
|
|
3584
|
+
function isNoSuchElement2(error) {
|
|
3585
|
+
if (error instanceof WdaHttpError) {
|
|
3586
|
+
return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
|
|
3587
|
+
}
|
|
3588
|
+
return false;
|
|
3589
|
+
}
|
|
3590
|
+
async function createWdaSession(transport, bundleId) {
|
|
3591
|
+
const body = await transport.requestFull("POST", "/session", {
|
|
3592
|
+
capabilities: { alwaysMatch: { bundleId }, firstMatch: [{}] }
|
|
3593
|
+
});
|
|
3594
|
+
const envelope = body ?? {};
|
|
3595
|
+
const value = envelope.value ?? {};
|
|
3596
|
+
const sessionId = typeof value.sessionId === "string" && value.sessionId || typeof envelope.sessionId === "string" && envelope.sessionId || "";
|
|
3597
|
+
if (sessionId.length > 0) {
|
|
3598
|
+
return sessionId;
|
|
3599
|
+
}
|
|
3600
|
+
throw new Error("WebDriverAgent did not return a session id");
|
|
3601
|
+
}
|
|
3602
|
+
async function waitForWdaReady(transport, options = { deadlineMs: 6e4 }) {
|
|
3603
|
+
const interval = options.intervalMs ?? 300;
|
|
3604
|
+
const deadline = Date.now() + options.deadlineMs;
|
|
3605
|
+
let lastError;
|
|
3606
|
+
for (; ; ) {
|
|
3607
|
+
try {
|
|
3608
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
3609
|
+
await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
|
|
3610
|
+
return;
|
|
3611
|
+
} catch (error) {
|
|
3612
|
+
lastError = error;
|
|
3613
|
+
}
|
|
3614
|
+
if (Date.now() >= deadline) {
|
|
3615
|
+
const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
|
|
3616
|
+
throw new Error(`WebDriverAgent did not become ready within ${options.deadlineMs}ms${detail}`);
|
|
3617
|
+
}
|
|
3618
|
+
await sleep2(Math.min(interval, Math.max(0, deadline - Date.now())));
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
function sleep2(ms) {
|
|
3622
|
+
return new Promise((resolve) => {
|
|
3623
|
+
setTimeout(resolve, ms);
|
|
3624
|
+
});
|
|
3625
|
+
}
|
|
3626
|
+
function createWdaAgentClient(transport, sessionId) {
|
|
3627
|
+
const base = `/session/${sessionId}`;
|
|
3628
|
+
async function locate(query, path25) {
|
|
3629
|
+
return transport.request("POST", `${base}${path25}`, iosQueryToLocator(query));
|
|
3630
|
+
}
|
|
3631
|
+
return {
|
|
3632
|
+
async findElement(query) {
|
|
3633
|
+
try {
|
|
3634
|
+
return extractElementId2(await locate(query, "/element"));
|
|
3635
|
+
} catch (error) {
|
|
3636
|
+
if (isNoSuchElement2(error)) {
|
|
3637
|
+
return null;
|
|
3638
|
+
}
|
|
3639
|
+
throw error;
|
|
3640
|
+
}
|
|
3641
|
+
},
|
|
3642
|
+
async findElements(query) {
|
|
3643
|
+
const value = await locate(query, "/elements");
|
|
3644
|
+
if (!Array.isArray(value)) {
|
|
3645
|
+
return [];
|
|
3646
|
+
}
|
|
3647
|
+
return value.map((entry) => extractElementId2(entry)).filter((id) => id !== null);
|
|
3648
|
+
},
|
|
3649
|
+
async click(elementId) {
|
|
3650
|
+
await transport.request("POST", `${base}/element/${elementId}/click`, {});
|
|
3651
|
+
},
|
|
3652
|
+
async setValue(elementId, text) {
|
|
3653
|
+
await transport.request("POST", `${base}/element/${elementId}/value`, { text });
|
|
3654
|
+
},
|
|
3655
|
+
async getText(elementId) {
|
|
3656
|
+
const value = await transport.request("GET", `${base}/element/${elementId}/text`);
|
|
3657
|
+
return typeof value === "string" ? value : value == null ? null : String(value);
|
|
3658
|
+
},
|
|
3659
|
+
async sendKeys(keys) {
|
|
3660
|
+
await transport.request("POST", `${base}/wda/keys`, { value: keys });
|
|
3661
|
+
},
|
|
3662
|
+
async homescreen() {
|
|
3663
|
+
await transport.request("POST", "/wda/homescreen", {});
|
|
3664
|
+
},
|
|
3665
|
+
async source() {
|
|
3666
|
+
const value = await transport.request("GET", "/source");
|
|
3667
|
+
if (typeof value !== "string") {
|
|
3668
|
+
throw new Error("WebDriverAgent /source did not return XML text; cannot analyze iOS UI hierarchy");
|
|
3669
|
+
}
|
|
3670
|
+
return value;
|
|
3671
|
+
},
|
|
3672
|
+
async close() {
|
|
3673
|
+
await transport.request("DELETE", base).catch(() => void 0);
|
|
3674
|
+
}
|
|
3675
|
+
};
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3678
|
+
// src/browser/ios-helper.ts
|
|
3679
|
+
var import_meta3 = {};
|
|
3680
|
+
var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
|
|
3681
|
+
var WDA_USE_PORT_ENV = "USE_PORT";
|
|
3682
|
+
var PREPARED_XCTESTRUN_PREFIX = "prowl-wda-xctestrun-";
|
|
3683
|
+
var WDA_STARTUP_ATTEMPTS = 2;
|
|
3684
|
+
var defaultIosAgentConnector = async ({
|
|
3685
|
+
host,
|
|
3686
|
+
port,
|
|
3687
|
+
bundleId,
|
|
3688
|
+
requestTimeoutMs,
|
|
3689
|
+
readyDeadlineMs
|
|
3690
|
+
}) => {
|
|
3691
|
+
const transport = new WdaTransport({ baseUrl: `http://${host}:${port}`, requestTimeoutMs });
|
|
3692
|
+
await waitForWdaReady(transport, { deadlineMs: readyDeadlineMs });
|
|
3693
|
+
const sessionId = await createWdaSession(transport, bundleId);
|
|
3694
|
+
return createWdaAgentClient(transport, sessionId);
|
|
3695
|
+
};
|
|
3696
|
+
function resolveWdaProject(requireFn = (0, import_node_module2.createRequire)(import_meta3.url)) {
|
|
3697
|
+
let pkgJsonPath;
|
|
3698
|
+
try {
|
|
3699
|
+
pkgJsonPath = requireFn.resolve("appium-webdriveragent/package.json");
|
|
3700
|
+
} catch {
|
|
3701
|
+
throw new Error(
|
|
3702
|
+
"The iOS target requires the `appium-webdriveragent` package (its WDA Xcode project). It is an optional dependency of prowl-tools; it may have been skipped (--omit=optional) or failed to install. Restore it for a global Prowl install with: npm install -g appium-webdriveragent@16.4.0. If Prowl is installed locally in a project, run: npm install appium-webdriveragent@16.4.0"
|
|
3703
|
+
);
|
|
3704
|
+
}
|
|
3705
|
+
const pkgDir = import_node_path7.default.dirname(pkgJsonPath);
|
|
3706
|
+
const version = requireFn(pkgJsonPath).version;
|
|
3707
|
+
const projectPath = import_node_path7.default.join(pkgDir, "WebDriverAgent.xcodeproj");
|
|
3708
|
+
if (!import_node_fs7.default.existsSync(projectPath)) {
|
|
3709
|
+
throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
|
|
3710
|
+
}
|
|
3711
|
+
return { projectPath, version };
|
|
3712
|
+
}
|
|
3713
|
+
function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os2.default.homedir()) {
|
|
3714
|
+
return import_node_path7.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
|
|
3715
|
+
}
|
|
3716
|
+
function productsDir(derivedDataPath) {
|
|
3717
|
+
return import_node_path7.default.join(derivedDataPath, "Build", "Products");
|
|
3718
|
+
}
|
|
3719
|
+
function findXctestrunIn(dir) {
|
|
3720
|
+
let entries;
|
|
3721
|
+
try {
|
|
3722
|
+
entries = import_node_fs7.default.readdirSync(dir);
|
|
3723
|
+
} catch {
|
|
3724
|
+
return null;
|
|
3725
|
+
}
|
|
3726
|
+
const match = entries.filter((name) => name.endsWith(".xctestrun") && !name.startsWith(PREPARED_XCTESTRUN_PREFIX)).sort()[0];
|
|
3727
|
+
return match ? import_node_path7.default.join(dir, match) : null;
|
|
3728
|
+
}
|
|
3729
|
+
function resolveOverrideXctestrun(override) {
|
|
3730
|
+
if (!import_node_fs7.default.existsSync(override)) {
|
|
3731
|
+
throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
|
|
3732
|
+
}
|
|
3733
|
+
if (override.endsWith(".xctestrun")) {
|
|
3734
|
+
return override;
|
|
3735
|
+
}
|
|
3736
|
+
const candidates = [];
|
|
3737
|
+
const stat = import_node_fs7.default.statSync(override);
|
|
3738
|
+
if (override.endsWith(".app")) {
|
|
3739
|
+
candidates.push(import_node_path7.default.dirname(import_node_path7.default.dirname(override)));
|
|
3740
|
+
} else if (stat.isDirectory()) {
|
|
3741
|
+
candidates.push(override, productsDir(override));
|
|
3742
|
+
}
|
|
3743
|
+
for (const dir of candidates) {
|
|
3744
|
+
const found = findXctestrunIn(dir);
|
|
3745
|
+
if (found) {
|
|
3746
|
+
return found;
|
|
3747
|
+
}
|
|
3748
|
+
}
|
|
3749
|
+
throw new Error(
|
|
3750
|
+
`PROWL_WDA_RUNNER (${override}) does not resolve to a WebDriverAgent .xctestrun. Point it at the generated \`*.xctestrun\`, at the \`-derivedDataPath\` directory from \`xcodebuild build-for-testing\`, or at that build's runner \`.app\`.`
|
|
3751
|
+
);
|
|
3752
|
+
}
|
|
3753
|
+
async function resolveWdaTestRun(options = {}) {
|
|
3754
|
+
const runner = options.runner ?? execFileXcrunRunner;
|
|
3755
|
+
const env = options.env ?? process.env;
|
|
3756
|
+
const homeDir = options.homeDir ?? import_node_os2.default.homedir();
|
|
3757
|
+
const log = options.logger ?? ((message) => process.stderr.write(`${message}
|
|
3758
|
+
`));
|
|
3759
|
+
const override = env.PROWL_WDA_RUNNER;
|
|
3760
|
+
if (override) {
|
|
3761
|
+
return resolveOverrideXctestrun(override);
|
|
3762
|
+
}
|
|
3763
|
+
const { projectPath, version } = resolveWdaProject(options.requireFn);
|
|
3764
|
+
const xcode = await xcodeVersion(runner);
|
|
3765
|
+
if (!xcode) {
|
|
3766
|
+
throw new Error(
|
|
3767
|
+
"Could not determine the Xcode version (`xcrun xcodebuild -version`). The iOS target requires Xcode on macOS. Install it and run `xcode-select --switch`, then retry."
|
|
3768
|
+
);
|
|
3769
|
+
}
|
|
3770
|
+
const cacheDir = wdaCacheDir(version, xcode, homeDir);
|
|
3771
|
+
const cached = findXctestrunIn(productsDir(cacheDir));
|
|
3772
|
+
if (cached) {
|
|
3773
|
+
return cached;
|
|
3774
|
+
}
|
|
3775
|
+
log(
|
|
3776
|
+
`Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
|
|
3777
|
+
);
|
|
3778
|
+
import_node_fs7.default.mkdirSync(cacheDir, { recursive: true });
|
|
3779
|
+
const result = await runner(
|
|
3780
|
+
[
|
|
3781
|
+
"xcodebuild",
|
|
3782
|
+
"build-for-testing",
|
|
3783
|
+
"-project",
|
|
3784
|
+
projectPath,
|
|
3785
|
+
"-scheme",
|
|
3786
|
+
"WebDriverAgentRunner",
|
|
3787
|
+
"-destination",
|
|
3788
|
+
"generic/platform=iOS Simulator",
|
|
3789
|
+
"-derivedDataPath",
|
|
3790
|
+
cacheDir,
|
|
3791
|
+
"CODE_SIGNING_ALLOWED=NO"
|
|
3792
|
+
],
|
|
3793
|
+
{ timeoutMs: 12e5 }
|
|
3794
|
+
);
|
|
3795
|
+
if (result.code !== 0) {
|
|
3796
|
+
throw new Error(
|
|
3797
|
+
`Failed to build WebDriverAgent with \`xcodebuild build-for-testing\`. Ensure a full Xcode (not just the command-line tools) is installed and selected (\`xcode-select -p\`). Details: ${(result.stderr.trim() || result.stdout.trim()).slice(-800)}`
|
|
3798
|
+
);
|
|
3799
|
+
}
|
|
3800
|
+
const built = findXctestrunIn(productsDir(cacheDir));
|
|
3801
|
+
if (!built) {
|
|
3802
|
+
throw new Error(
|
|
3803
|
+
`WebDriverAgent build succeeded but no .xctestrun was found under ${productsDir(cacheDir)}. This may indicate an Xcode layout change; set PROWL_WDA_RUNNER to a prebuilt runner/xctestrun.`
|
|
3804
|
+
);
|
|
3805
|
+
}
|
|
3806
|
+
return built;
|
|
3807
|
+
}
|
|
3808
|
+
function injectUsePortIntoXctestrun(plist, port) {
|
|
3809
|
+
const clone = structuredClone(plist);
|
|
3810
|
+
let injected = 0;
|
|
3811
|
+
const visit = (node) => {
|
|
3812
|
+
if (Array.isArray(node)) {
|
|
3813
|
+
for (const entry of node) {
|
|
3814
|
+
visit(entry);
|
|
3815
|
+
}
|
|
3816
|
+
return;
|
|
3817
|
+
}
|
|
3818
|
+
if (!node || typeof node !== "object") {
|
|
3819
|
+
return;
|
|
3820
|
+
}
|
|
3821
|
+
const obj = node;
|
|
3822
|
+
const isTarget = typeof obj.TestBundlePath === "string" || typeof obj.TestHostPath === "string";
|
|
3823
|
+
const existingEnv = obj.EnvironmentVariables && typeof obj.EnvironmentVariables === "object" && !Array.isArray(obj.EnvironmentVariables) ? obj.EnvironmentVariables : void 0;
|
|
3824
|
+
if (isTarget || existingEnv) {
|
|
3825
|
+
const env = existingEnv ?? {};
|
|
3826
|
+
env[WDA_USE_PORT_ENV] = String(port);
|
|
3827
|
+
obj.EnvironmentVariables = env;
|
|
3828
|
+
injected += 1;
|
|
3829
|
+
}
|
|
3830
|
+
for (const value of Object.values(obj)) {
|
|
3831
|
+
visit(value);
|
|
3832
|
+
}
|
|
3833
|
+
};
|
|
3834
|
+
visit(clone);
|
|
3835
|
+
if (injected === 0) {
|
|
3836
|
+
throw new Error(
|
|
3837
|
+
"Could not find a test target in the WebDriverAgent .xctestrun to set USE_PORT. The xctestrun format may have changed; set PROWL_WDA_RUNNER to a compatible runner."
|
|
3838
|
+
);
|
|
3839
|
+
}
|
|
3840
|
+
return clone;
|
|
3841
|
+
}
|
|
3842
|
+
function runPlutil(args) {
|
|
3843
|
+
return new Promise((resolve, reject) => {
|
|
3844
|
+
(0, import_node_child_process6.execFile)(
|
|
3845
|
+
"plutil",
|
|
3846
|
+
args,
|
|
3847
|
+
{ encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 },
|
|
3848
|
+
(error, stdout, stderr) => {
|
|
3849
|
+
if (error) {
|
|
3850
|
+
reject(
|
|
3851
|
+
new Error(
|
|
3852
|
+
`\`plutil ${args.join(" ")}\` failed: ${(stderr || error.message).slice(0, 400)}`
|
|
3853
|
+
)
|
|
3854
|
+
);
|
|
3855
|
+
return;
|
|
3856
|
+
}
|
|
3857
|
+
resolve(stdout ?? "");
|
|
3858
|
+
}
|
|
3859
|
+
);
|
|
3860
|
+
});
|
|
3861
|
+
}
|
|
3862
|
+
var defaultWdaTestRunPreparer = async ({ xctestrunPath, port }) => {
|
|
3863
|
+
const json = await runPlutil(["-convert", "json", "-o", "-", xctestrunPath]);
|
|
3864
|
+
let parsed;
|
|
3865
|
+
try {
|
|
3866
|
+
parsed = JSON.parse(json);
|
|
3867
|
+
} catch {
|
|
3868
|
+
throw new Error(`Could not parse the WebDriverAgent .xctestrun as JSON: ${xctestrunPath}`);
|
|
3869
|
+
}
|
|
3870
|
+
const injected = injectUsePortIntoXctestrun(parsed, port);
|
|
3871
|
+
const stem = `${PREPARED_XCTESTRUN_PREFIX}${port}-${process.pid}`;
|
|
3872
|
+
const outPath = import_node_path7.default.join(import_node_path7.default.dirname(xctestrunPath), `${stem}.xctestrun`);
|
|
3873
|
+
const jsonPath = import_node_path7.default.join(import_node_os2.default.tmpdir(), `${stem}.json`);
|
|
3874
|
+
import_node_fs7.default.writeFileSync(jsonPath, JSON.stringify(injected));
|
|
3875
|
+
try {
|
|
3876
|
+
await runPlutil(["-convert", "xml1", jsonPath, "-o", outPath]);
|
|
3877
|
+
} finally {
|
|
3878
|
+
import_node_fs7.default.rmSync(jsonPath, { force: true });
|
|
3879
|
+
}
|
|
3880
|
+
return outPath;
|
|
3881
|
+
};
|
|
3882
|
+
function cleanupPreparedTestRun(preparedPath) {
|
|
3883
|
+
if (!preparedPath) {
|
|
3884
|
+
return;
|
|
3885
|
+
}
|
|
3886
|
+
if (!import_node_path7.default.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
|
|
3887
|
+
return;
|
|
3888
|
+
}
|
|
3889
|
+
import_node_fs7.default.rmSync(preparedPath, { force: true });
|
|
3890
|
+
}
|
|
3891
|
+
function wdaTestRunArgs(xctestrunPath, udid) {
|
|
3892
|
+
return [
|
|
3893
|
+
"xcodebuild",
|
|
3894
|
+
"test-without-building",
|
|
3895
|
+
"-xctestrun",
|
|
3896
|
+
xctestrunPath,
|
|
3897
|
+
"-destination",
|
|
3898
|
+
`id=${udid}`
|
|
3899
|
+
];
|
|
3900
|
+
}
|
|
3901
|
+
async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
|
|
3902
|
+
if (!looksLikeIosAppPath(app)) {
|
|
3903
|
+
assertIosAppAllowed(allowedApps, app);
|
|
3904
|
+
if (coldStart) {
|
|
3905
|
+
throw new Error(
|
|
3906
|
+
`coldStart requires target.app to be a built .app bundle path (a bare bundle id like "${app}" cannot be reinstalled). Point target.app at the .app, or drop coldStart.`
|
|
3907
|
+
);
|
|
3908
|
+
}
|
|
3909
|
+
return app;
|
|
3910
|
+
}
|
|
3911
|
+
const appPath = import_node_path7.default.resolve(app);
|
|
3912
|
+
if (!import_node_fs7.default.existsSync(appPath)) {
|
|
3913
|
+
throw new Error(`.app bundle not found: ${appPath}`);
|
|
3914
|
+
}
|
|
3915
|
+
const bundleId = readIosBundleIdentifier(appPath);
|
|
3916
|
+
if (!bundleId) {
|
|
3917
|
+
throw new Error(
|
|
3918
|
+
`Could not read CFBundleIdentifier from "${appPath}" (root Info.plist). Ensure target.app points at a built iOS .app bundle.`
|
|
3919
|
+
);
|
|
3920
|
+
}
|
|
3921
|
+
assertIosAppAllowed(allowedApps, appPath);
|
|
3922
|
+
if (coldStart) {
|
|
3923
|
+
await uninstallApp(runner, udid, bundleId);
|
|
3924
|
+
}
|
|
3925
|
+
await installApp(runner, udid, appPath);
|
|
3926
|
+
return bundleId;
|
|
3927
|
+
}
|
|
3928
|
+
async function launchIosSession(options) {
|
|
3929
|
+
const runner = options.runner ?? execFileXcrunRunner;
|
|
3930
|
+
const spawner = options.spawner ?? spawnXcrunProcess;
|
|
3931
|
+
const preparer = options.testRunPreparer ?? defaultWdaTestRunPreparer;
|
|
3932
|
+
const portAllocator = options.portAllocator ?? findFreePort;
|
|
3933
|
+
const connector = options.agentConnector ?? defaultIosAgentConnector;
|
|
3934
|
+
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
|
|
3935
|
+
const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, 6e4);
|
|
3936
|
+
const devices = await listSimulators(runner);
|
|
3937
|
+
const udid = selectSimulatorUdid(devices, options.udid);
|
|
3938
|
+
const reservation = await reserveSimulatorUdid(udid, { lockRoot: options.simulatorLockRoot });
|
|
3939
|
+
let reservationReleased = false;
|
|
3940
|
+
const releaseReservation = async () => {
|
|
3941
|
+
if (reservationReleased) {
|
|
3942
|
+
return;
|
|
3943
|
+
}
|
|
3944
|
+
reservationReleased = true;
|
|
3945
|
+
await reservation.release();
|
|
3946
|
+
};
|
|
3947
|
+
try {
|
|
3948
|
+
const bundleId = await resolveBundleId(
|
|
3949
|
+
options.app,
|
|
3950
|
+
runner,
|
|
3951
|
+
udid,
|
|
3952
|
+
options.coldStart ?? false,
|
|
3953
|
+
options.allowedApps ?? []
|
|
3954
|
+
);
|
|
3955
|
+
const baseTestRun = options.wdaTestRun ?? await resolveWdaTestRun({ runner, logger: options.logger });
|
|
3956
|
+
for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
|
|
3957
|
+
let client;
|
|
3958
|
+
let wdaProcess;
|
|
3959
|
+
let preparedTestRun;
|
|
3960
|
+
let tornDown = false;
|
|
3961
|
+
let terminateTarget = false;
|
|
3962
|
+
let stage = "prepare";
|
|
3963
|
+
const teardownAttempt = async () => {
|
|
3964
|
+
if (tornDown) {
|
|
3965
|
+
return;
|
|
3966
|
+
}
|
|
3967
|
+
tornDown = true;
|
|
3968
|
+
if (client) {
|
|
3969
|
+
await client.close().catch(() => void 0);
|
|
3970
|
+
}
|
|
3971
|
+
wdaProcess?.kill();
|
|
3972
|
+
await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
|
|
3973
|
+
if (terminateTarget) {
|
|
3974
|
+
await terminateApp(runner, udid, bundleId);
|
|
3975
|
+
}
|
|
3976
|
+
cleanupPreparedTestRun(preparedTestRun);
|
|
3977
|
+
};
|
|
3978
|
+
try {
|
|
3979
|
+
const port = await portAllocator();
|
|
3980
|
+
preparedTestRun = await preparer({ xctestrunPath: baseTestRun, port });
|
|
3981
|
+
stage = "wda-launch";
|
|
3982
|
+
wdaProcess = spawner(wdaTestRunArgs(preparedTestRun, udid));
|
|
3983
|
+
stage = "target-launch";
|
|
3984
|
+
terminateTarget = true;
|
|
3985
|
+
await launchApp(runner, udid, bundleId);
|
|
3986
|
+
stage = "connect";
|
|
3987
|
+
client = await connector({ host: "127.0.0.1", port, bundleId, requestTimeoutMs, readyDeadlineMs });
|
|
3988
|
+
const driver = createIosDriver(client, {
|
|
3989
|
+
appLabel: bundleId,
|
|
3990
|
+
captureScreenshot: (outPath) => captureScreenshot(runner, udid, outPath)
|
|
3991
|
+
});
|
|
3992
|
+
const teardown = async () => {
|
|
3993
|
+
await teardownAttempt();
|
|
3994
|
+
await releaseReservation();
|
|
3995
|
+
};
|
|
3996
|
+
return { client, driver, bundleId, udid, teardown };
|
|
3997
|
+
} catch (error) {
|
|
3998
|
+
await teardownAttempt();
|
|
3999
|
+
if (stage === "target-launch" || attempt >= WDA_STARTUP_ATTEMPTS) {
|
|
4000
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
}
|
|
4004
|
+
throw new Error("WebDriverAgent startup failed without an error.");
|
|
4005
|
+
} catch (error) {
|
|
4006
|
+
await releaseReservation().catch(() => void 0);
|
|
4007
|
+
throw error;
|
|
4008
|
+
}
|
|
4009
|
+
}
|
|
4010
|
+
async function closeIosSession(session) {
|
|
4011
|
+
await session.teardown();
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
// src/runner/steps.ts
|
|
4015
|
+
var import_node_fs9 = __toESM(require("fs"), 1);
|
|
4016
|
+
var import_node_path9 = __toESM(require("path"), 1);
|
|
4017
|
+
init_loader();
|
|
4018
|
+
|
|
4019
|
+
// src/runner/healing.ts
|
|
4020
|
+
var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
|
|
4021
|
+
function extractSelectorIntent(selector) {
|
|
4022
|
+
const raw = [];
|
|
4023
|
+
for (const match of selector.matchAll(/[#.]([A-Za-z_][\w-]*)/g)) {
|
|
4024
|
+
raw.push(match[1]);
|
|
4025
|
+
}
|
|
4026
|
+
for (const match of selector.matchAll(/\[[A-Za-z_:-]+\s*[~|^$*]?=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\]/g)) {
|
|
4027
|
+
const value = match[1] ?? match[2] ?? match[3];
|
|
4028
|
+
if (value) raw.push(value);
|
|
4029
|
+
}
|
|
4030
|
+
const words = [];
|
|
4031
|
+
for (const token of raw) {
|
|
4032
|
+
for (const part of splitToken(token)) {
|
|
4033
|
+
const lower = part.toLowerCase();
|
|
4034
|
+
if (lower.length > 0 && !words.includes(lower)) {
|
|
4035
|
+
words.push(lower);
|
|
4036
|
+
}
|
|
4037
|
+
}
|
|
4038
|
+
}
|
|
4039
|
+
return { words, label: words.join(" ") };
|
|
4040
|
+
}
|
|
4041
|
+
function splitToken(token) {
|
|
4042
|
+
return token.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[\s\-_.:]+/).filter((part) => part.length > 0);
|
|
4043
|
+
}
|
|
4044
|
+
function buildHealCandidates(selector) {
|
|
4045
|
+
const { words, label } = extractSelectorIntent(selector);
|
|
4046
|
+
if (words.length === 0) return [];
|
|
4047
|
+
const escaped = label.replace(/"/g, '\\"');
|
|
4048
|
+
const candidates = [];
|
|
4049
|
+
candidates.push({ selector: `text=${label}`, strategy: "text" });
|
|
4050
|
+
candidates.push({ selector: `[aria-label*="${escaped}" i]`, strategy: "aria" });
|
|
4051
|
+
for (const tag of INTERACTIVE_TAGS) {
|
|
4052
|
+
candidates.push({ selector: `${tag}:has-text("${escaped}")`, strategy: "structural" });
|
|
4053
|
+
}
|
|
4054
|
+
return candidates;
|
|
4055
|
+
}
|
|
4056
|
+
async function healSelector(probe, selector, options) {
|
|
4057
|
+
if (!options.enabled) return null;
|
|
4058
|
+
for (const candidate of buildHealCandidates(selector)) {
|
|
4059
|
+
let count;
|
|
4060
|
+
try {
|
|
4061
|
+
const locator2 = probe.locator(candidate.selector);
|
|
4062
|
+
count = await locator2.count();
|
|
4063
|
+
} catch {
|
|
4064
|
+
continue;
|
|
4065
|
+
}
|
|
4066
|
+
if (count === 1) {
|
|
4067
|
+
return { selector: candidate.selector, healedFrom: selector, strategy: candidate.strategy };
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
4070
|
+
return null;
|
|
4071
|
+
}
|
|
4072
|
+
|
|
4073
|
+
// src/runner/policy.ts
|
|
4074
|
+
var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
|
|
4075
|
+
function createRunPolicy(driver, options) {
|
|
4076
|
+
const { forbiddenSelectors, allowedDomains, maxSteps, selfHealing } = options;
|
|
4077
|
+
const allowedApps = options.allowedApps ?? [];
|
|
4078
|
+
function matchesForbiddenPattern(selector, forbidden) {
|
|
4079
|
+
const selectorText = driver.parseTextSelector(selector);
|
|
4080
|
+
if (selectorText === null) {
|
|
4081
|
+
return false;
|
|
4082
|
+
}
|
|
4083
|
+
const forbiddenText = driver.parseTextSelector(forbidden);
|
|
4084
|
+
if (forbiddenText !== null) {
|
|
4085
|
+
return selectorText.includes(forbiddenText);
|
|
4086
|
+
}
|
|
4087
|
+
return selectorText.includes(forbidden);
|
|
4088
|
+
}
|
|
4089
|
+
function isForbiddenSelector(selector) {
|
|
4090
|
+
return forbiddenSelectors.some(
|
|
4091
|
+
(forbidden) => selector.includes(forbidden) || matchesForbiddenPattern(selector, forbidden)
|
|
4092
|
+
);
|
|
4093
|
+
}
|
|
4094
|
+
function assertAllowedSelector(selector) {
|
|
4095
|
+
if (isForbiddenSelector(selector)) {
|
|
4096
|
+
throw new Error(`Forbidden selector: ${selector}`);
|
|
4097
|
+
}
|
|
4098
|
+
}
|
|
4099
|
+
function assertWithinMaxSteps(stepCount, huntName) {
|
|
4100
|
+
if (stepCount > maxSteps) {
|
|
4101
|
+
if (huntName) {
|
|
4102
|
+
throw new Error(`Hunt "${huntName}" has ${stepCount} steps. Max allowed is ${maxSteps}.`);
|
|
4103
|
+
}
|
|
4104
|
+
throw new Error(`Hunt has ${stepCount} steps. Max allowed is ${maxSteps}.`);
|
|
4105
|
+
}
|
|
4106
|
+
}
|
|
4107
|
+
function ensureUrlAllowed(urlValue) {
|
|
4108
|
+
for (const protocol of ALWAYS_ALLOWED_PROTOCOLS) {
|
|
4109
|
+
if (urlValue.startsWith(protocol)) {
|
|
4110
|
+
return;
|
|
4111
|
+
}
|
|
4112
|
+
}
|
|
4113
|
+
let url;
|
|
4114
|
+
try {
|
|
4115
|
+
url = new URL(urlValue);
|
|
4116
|
+
} catch {
|
|
4117
|
+
throw new Error(`Navigation target is not a valid absolute URL: ${urlValue}`);
|
|
4118
|
+
}
|
|
4119
|
+
if (!allowedDomains.includes(url.hostname)) {
|
|
4120
|
+
throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
function ensureAppAllowed(app) {
|
|
4124
|
+
if (!allowedApps.includes(app)) {
|
|
4125
|
+
throw new Error(`Interaction with disallowed app: ${app}`);
|
|
4126
|
+
}
|
|
4127
|
+
}
|
|
4128
|
+
function ensureLocationAllowed(activeDriver) {
|
|
4129
|
+
if (activeDriver.capabilities.has("navigate")) {
|
|
4130
|
+
ensureUrlAllowed(activeDriver.currentUrl());
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
4133
|
+
const healProbe = {
|
|
4134
|
+
locator: (selector) => ({ count: () => driver.count(selector) })
|
|
4135
|
+
};
|
|
4136
|
+
async function resolveActionSelector(selector) {
|
|
4137
|
+
assertAllowedSelector(selector);
|
|
4138
|
+
if (!selfHealing) {
|
|
4139
|
+
return { selector };
|
|
4140
|
+
}
|
|
4141
|
+
let matched = false;
|
|
4142
|
+
try {
|
|
4143
|
+
matched = await driver.count(selector) > 0;
|
|
4144
|
+
} catch {
|
|
4145
|
+
return { selector };
|
|
4146
|
+
}
|
|
4147
|
+
if (matched) {
|
|
4148
|
+
return { selector };
|
|
4149
|
+
}
|
|
4150
|
+
const healed = await healSelector(healProbe, selector, { enabled: true });
|
|
4151
|
+
if (!healed) {
|
|
4152
|
+
return { selector };
|
|
4153
|
+
}
|
|
4154
|
+
assertAllowedSelector(healed.selector);
|
|
4155
|
+
console.warn(
|
|
4156
|
+
`Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
|
|
4157
|
+
);
|
|
4158
|
+
return { selector: healed.selector, healedFrom: healed.healedFrom };
|
|
4159
|
+
}
|
|
4160
|
+
return {
|
|
4161
|
+
assertWithinMaxSteps,
|
|
2019
4162
|
ensureUrlAllowed,
|
|
2020
4163
|
ensureAppAllowed,
|
|
2021
4164
|
ensureLocationAllowed,
|
|
@@ -2024,6 +4167,246 @@ function createRunPolicy(driver, options) {
|
|
|
2024
4167
|
};
|
|
2025
4168
|
}
|
|
2026
4169
|
|
|
4170
|
+
// src/generator/ai.ts
|
|
4171
|
+
var DEFAULT_BASE_URL = {
|
|
4172
|
+
anthropic: "https://api.anthropic.com",
|
|
4173
|
+
openai: "https://api.openai.com"
|
|
4174
|
+
};
|
|
4175
|
+
var AI_REQUEST_TIMEOUT_MS = 3e4;
|
|
4176
|
+
function apiRoot(config) {
|
|
4177
|
+
return config.baseUrl ?? DEFAULT_BASE_URL[config.provider];
|
|
4178
|
+
}
|
|
4179
|
+
function defaultModelFor(provider) {
|
|
4180
|
+
return provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
|
|
4181
|
+
}
|
|
4182
|
+
function resolveAiEnv() {
|
|
4183
|
+
const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
|
|
4184
|
+
if (provider !== "anthropic" && provider !== "openai") {
|
|
4185
|
+
throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
|
|
4186
|
+
}
|
|
4187
|
+
const model = process.env.PROWL_AI_MODEL ?? defaultModelFor(provider);
|
|
4188
|
+
const baseUrl = normalizeBaseUrl(process.env.PROWL_AI_BASE_URL) ?? DEFAULT_BASE_URL[provider];
|
|
4189
|
+
return { provider, model, baseUrl, apiKey: process.env.PROWL_AI_KEY };
|
|
4190
|
+
}
|
|
4191
|
+
function normalizeBaseUrl(value) {
|
|
4192
|
+
if (value === void 0) return void 0;
|
|
4193
|
+
const trimmed = value.trim().replace(/\/+$/, "");
|
|
4194
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
4195
|
+
}
|
|
4196
|
+
function resolveAiConfig() {
|
|
4197
|
+
const env = resolveAiEnv();
|
|
4198
|
+
if (!env.apiKey) {
|
|
4199
|
+
throw new Error(
|
|
4200
|
+
"PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
|
|
4201
|
+
);
|
|
4202
|
+
}
|
|
4203
|
+
return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
|
|
4204
|
+
}
|
|
4205
|
+
function tryResolveAiConfig() {
|
|
4206
|
+
const env = resolveAiEnv();
|
|
4207
|
+
if (!env.apiKey) {
|
|
4208
|
+
return null;
|
|
4209
|
+
}
|
|
4210
|
+
return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
|
|
4211
|
+
}
|
|
4212
|
+
async function generateWithAi(prompt, config) {
|
|
4213
|
+
if (config.provider === "anthropic") {
|
|
4214
|
+
return generateWithAnthropic(prompt, config);
|
|
4215
|
+
}
|
|
4216
|
+
return generateWithOpenAi(prompt, config);
|
|
4217
|
+
}
|
|
4218
|
+
async function generateWithAnthropic(prompt, config) {
|
|
4219
|
+
const data = await postJson(
|
|
4220
|
+
"Anthropic",
|
|
4221
|
+
`${apiRoot(config)}/v1/messages`,
|
|
4222
|
+
anthropicHeaders(config),
|
|
4223
|
+
{
|
|
4224
|
+
model: config.model,
|
|
4225
|
+
max_tokens: 4096,
|
|
4226
|
+
messages: [
|
|
4227
|
+
{ role: "user", content: prompt }
|
|
4228
|
+
]
|
|
4229
|
+
}
|
|
4230
|
+
);
|
|
4231
|
+
return extractAnthropicText(data);
|
|
4232
|
+
}
|
|
4233
|
+
async function generateWithOpenAi(prompt, config) {
|
|
4234
|
+
const data = await postJson(
|
|
4235
|
+
"OpenAI",
|
|
4236
|
+
`${apiRoot(config)}/v1/chat/completions`,
|
|
4237
|
+
openAiHeaders(config),
|
|
4238
|
+
{
|
|
4239
|
+
model: config.model,
|
|
4240
|
+
messages: [
|
|
4241
|
+
{ role: "user", content: prompt }
|
|
4242
|
+
],
|
|
4243
|
+
max_tokens: 4096
|
|
4244
|
+
}
|
|
4245
|
+
);
|
|
4246
|
+
return extractOpenAiText(data);
|
|
4247
|
+
}
|
|
4248
|
+
function buildVisionPrompt(assertion) {
|
|
4249
|
+
return [
|
|
4250
|
+
"You are a meticulous QA reviewer. You are given a screenshot of an application",
|
|
4251
|
+
"and a single assertion describing what should be true about it.",
|
|
4252
|
+
"",
|
|
4253
|
+
"Assertion:",
|
|
4254
|
+
assertion,
|
|
4255
|
+
"",
|
|
4256
|
+
"Decide whether the assertion holds for the screenshot. Judge ONLY what is",
|
|
4257
|
+
"visible; do not assume behavior you cannot see. Be strict: if the assertion",
|
|
4258
|
+
"is not clearly satisfied, it fails.",
|
|
4259
|
+
"",
|
|
4260
|
+
"Reply with ONLY a single JSON object on one line, no markdown, no code fences:",
|
|
4261
|
+
'{"pass": <true|false>, "reason": "<one concise sentence explaining the verdict>"}'
|
|
4262
|
+
].join("\n");
|
|
4263
|
+
}
|
|
4264
|
+
function parseVisionVerdict(raw) {
|
|
4265
|
+
const cleaned = stripCodeFences(raw).trim();
|
|
4266
|
+
const candidate = extractJsonObject(cleaned);
|
|
4267
|
+
if (candidate === null) {
|
|
4268
|
+
throw new Error(
|
|
4269
|
+
`Could not parse a JSON verdict from the AI response: ${truncateForError(raw)}`
|
|
4270
|
+
);
|
|
4271
|
+
}
|
|
4272
|
+
let parsed;
|
|
4273
|
+
try {
|
|
4274
|
+
parsed = JSON.parse(candidate);
|
|
4275
|
+
} catch {
|
|
4276
|
+
throw new Error(
|
|
4277
|
+
`AI verdict was not valid JSON: ${truncateForError(raw)}`
|
|
4278
|
+
);
|
|
4279
|
+
}
|
|
4280
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
4281
|
+
throw new Error(`AI verdict was not a JSON object: ${truncateForError(raw)}`);
|
|
4282
|
+
}
|
|
4283
|
+
const record = parsed;
|
|
4284
|
+
if (typeof record.pass !== "boolean") {
|
|
4285
|
+
throw new Error(
|
|
4286
|
+
`AI verdict is missing a boolean "pass" field: ${truncateForError(raw)}`
|
|
4287
|
+
);
|
|
4288
|
+
}
|
|
4289
|
+
const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : record.pass ? "Assertion satisfied." : "Assertion not satisfied.";
|
|
4290
|
+
return { pass: record.pass, reason };
|
|
4291
|
+
}
|
|
4292
|
+
function stripCodeFences(text) {
|
|
4293
|
+
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
4294
|
+
return fence ? fence[1] : text;
|
|
4295
|
+
}
|
|
4296
|
+
function extractJsonObject(text) {
|
|
4297
|
+
const trimmed = text.trim();
|
|
4298
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
4299
|
+
return trimmed;
|
|
4300
|
+
}
|
|
4301
|
+
const first = trimmed.indexOf("{");
|
|
4302
|
+
const last = trimmed.lastIndexOf("}");
|
|
4303
|
+
if (first === -1 || last === -1 || last <= first) {
|
|
4304
|
+
return null;
|
|
4305
|
+
}
|
|
4306
|
+
return trimmed.slice(first, last + 1);
|
|
4307
|
+
}
|
|
4308
|
+
function truncateForError(text) {
|
|
4309
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
4310
|
+
return collapsed.length > 200 ? `${collapsed.slice(0, 199)}\u2026` : collapsed;
|
|
4311
|
+
}
|
|
4312
|
+
async function assertWithAiVision(input, config) {
|
|
4313
|
+
const raw = config.provider === "anthropic" ? await visionWithAnthropic(input, config) : await visionWithOpenAi(input, config);
|
|
4314
|
+
return parseVisionVerdict(raw);
|
|
4315
|
+
}
|
|
4316
|
+
async function visionWithAnthropic(input, config) {
|
|
4317
|
+
const data = await postJson(
|
|
4318
|
+
"Anthropic",
|
|
4319
|
+
`${apiRoot(config)}/v1/messages`,
|
|
4320
|
+
anthropicHeaders(config),
|
|
4321
|
+
{
|
|
4322
|
+
model: config.model,
|
|
4323
|
+
max_tokens: 1024,
|
|
4324
|
+
temperature: 0,
|
|
4325
|
+
messages: [
|
|
4326
|
+
{
|
|
4327
|
+
role: "user",
|
|
4328
|
+
content: [
|
|
4329
|
+
{
|
|
4330
|
+
type: "image",
|
|
4331
|
+
source: {
|
|
4332
|
+
type: "base64",
|
|
4333
|
+
media_type: input.mediaType,
|
|
4334
|
+
data: input.imageBase64
|
|
4335
|
+
}
|
|
4336
|
+
},
|
|
4337
|
+
{ type: "text", text: buildVisionPrompt(input.assertion) }
|
|
4338
|
+
]
|
|
4339
|
+
}
|
|
4340
|
+
]
|
|
4341
|
+
}
|
|
4342
|
+
);
|
|
4343
|
+
return extractAnthropicText(data);
|
|
4344
|
+
}
|
|
4345
|
+
async function visionWithOpenAi(input, config) {
|
|
4346
|
+
const data = await postJson(
|
|
4347
|
+
"OpenAI",
|
|
4348
|
+
`${apiRoot(config)}/v1/chat/completions`,
|
|
4349
|
+
openAiHeaders(config),
|
|
4350
|
+
{
|
|
4351
|
+
model: config.model,
|
|
4352
|
+
max_tokens: 1024,
|
|
4353
|
+
temperature: 0,
|
|
4354
|
+
messages: [
|
|
4355
|
+
{
|
|
4356
|
+
role: "user",
|
|
4357
|
+
content: [
|
|
4358
|
+
{ type: "text", text: buildVisionPrompt(input.assertion) },
|
|
4359
|
+
{
|
|
4360
|
+
type: "image_url",
|
|
4361
|
+
image_url: { url: `data:${input.mediaType};base64,${input.imageBase64}` }
|
|
4362
|
+
}
|
|
4363
|
+
]
|
|
4364
|
+
}
|
|
4365
|
+
]
|
|
4366
|
+
}
|
|
4367
|
+
);
|
|
4368
|
+
return extractOpenAiText(data);
|
|
4369
|
+
}
|
|
4370
|
+
async function postJson(provider, url, headers, body) {
|
|
4371
|
+
const response = await fetch(url, {
|
|
4372
|
+
method: "POST",
|
|
4373
|
+
headers,
|
|
4374
|
+
body: JSON.stringify(body),
|
|
4375
|
+
signal: AbortSignal.timeout(AI_REQUEST_TIMEOUT_MS)
|
|
4376
|
+
});
|
|
4377
|
+
if (!response.ok) {
|
|
4378
|
+
const responseBody = await response.text();
|
|
4379
|
+
throw new Error(`${provider} API error (${response.status}): ${responseBody}`);
|
|
4380
|
+
}
|
|
4381
|
+
return await response.json();
|
|
4382
|
+
}
|
|
4383
|
+
function anthropicHeaders(config) {
|
|
4384
|
+
return {
|
|
4385
|
+
"Content-Type": "application/json",
|
|
4386
|
+
"x-api-key": config.apiKey,
|
|
4387
|
+
"anthropic-version": "2023-06-01"
|
|
4388
|
+
};
|
|
4389
|
+
}
|
|
4390
|
+
function openAiHeaders(config) {
|
|
4391
|
+
return {
|
|
4392
|
+
"Content-Type": "application/json",
|
|
4393
|
+
"Authorization": `Bearer ${config.apiKey}`
|
|
4394
|
+
};
|
|
4395
|
+
}
|
|
4396
|
+
function extractAnthropicText(data) {
|
|
4397
|
+
const textBlock = data.content?.find((c) => c.type === "text");
|
|
4398
|
+
if (!textBlock?.text) {
|
|
4399
|
+
throw new Error("Anthropic API returned no text content");
|
|
4400
|
+
}
|
|
4401
|
+
return textBlock.text;
|
|
4402
|
+
}
|
|
4403
|
+
function extractOpenAiText(data) {
|
|
4404
|
+
if (!data.choices?.[0]?.message?.content) {
|
|
4405
|
+
throw new Error("OpenAI API returned no content");
|
|
4406
|
+
}
|
|
4407
|
+
return data.choices[0].message.content;
|
|
4408
|
+
}
|
|
4409
|
+
|
|
2027
4410
|
// src/runner/steps.ts
|
|
2028
4411
|
function getStepType(step) {
|
|
2029
4412
|
if ("navigate" in step) return "navigate";
|
|
@@ -2052,6 +4435,7 @@ function getStepType(step) {
|
|
|
2052
4435
|
if ("evalScript" in step) return "evalScript";
|
|
2053
4436
|
if ("runScript" in step) return "runScript";
|
|
2054
4437
|
if ("assertScreenshot" in step) return "assertScreenshot";
|
|
4438
|
+
if ("assertWithAI" in step) return "assertWithAI";
|
|
2055
4439
|
if ("copyText" in step) return "copyText";
|
|
2056
4440
|
if ("waitForDownload" in step) return "waitForDownload";
|
|
2057
4441
|
return "step";
|
|
@@ -2111,6 +4495,9 @@ function applyRuntimeVars(step, vars) {
|
|
|
2111
4495
|
}
|
|
2112
4496
|
};
|
|
2113
4497
|
}
|
|
4498
|
+
if ("assertWithAI" in step) {
|
|
4499
|
+
return { assertWithAI: sub(step.assertWithAI) };
|
|
4500
|
+
}
|
|
2114
4501
|
if ("copyText" in step) {
|
|
2115
4502
|
return { copyText: { selector: sub(step.copyText.selector), as: step.copyText.as } };
|
|
2116
4503
|
}
|
|
@@ -2361,7 +4748,7 @@ async function runInlineAssert(driver, policy, assertion) {
|
|
|
2361
4748
|
throw new Error("assert step is missing an assertion type");
|
|
2362
4749
|
}
|
|
2363
4750
|
function screenshotPath(screenshotsDir, fileName) {
|
|
2364
|
-
return
|
|
4751
|
+
return import_node_path9.default.join(screenshotsDir, fileName);
|
|
2365
4752
|
}
|
|
2366
4753
|
function stepPath(prefix, index) {
|
|
2367
4754
|
return prefix ? `${prefix}.${index}` : `${index}`;
|
|
@@ -2378,12 +4765,12 @@ function validateDownloadFilename(suggestedFilename) {
|
|
|
2378
4765
|
const safeFilename = suggestedFilename.trim();
|
|
2379
4766
|
const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
|
|
2380
4767
|
const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
|
|
2381
|
-
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !==
|
|
4768
|
+
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path9.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
|
|
2382
4769
|
throw new Error(`Invalid download filename: "${suggestedFilename}"`);
|
|
2383
4770
|
}
|
|
2384
4771
|
return safeFilename;
|
|
2385
4772
|
}
|
|
2386
|
-
async function
|
|
4773
|
+
async function captureScreenshot2(taker, filePath) {
|
|
2387
4774
|
try {
|
|
2388
4775
|
await taker.screenshot({ path: filePath, fullPage: true });
|
|
2389
4776
|
} catch (error) {
|
|
@@ -2558,7 +4945,7 @@ var STEP_HANDLERS = {
|
|
|
2558
4945
|
if (!("setInputFiles" in h.step)) unknownStep();
|
|
2559
4946
|
const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
|
|
2560
4947
|
const rawFiles = h.step.setInputFiles.files;
|
|
2561
|
-
const resolveFile = (f) =>
|
|
4948
|
+
const resolveFile = (f) => import_node_path9.default.isAbsolute(f) ? f : import_node_path9.default.join(h.context.configDir, f);
|
|
2562
4949
|
const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
|
|
2563
4950
|
await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
|
|
2564
4951
|
h.policy.ensureLocationAllowed(h.driver);
|
|
@@ -2595,7 +4982,7 @@ var STEP_HANDLERS = {
|
|
|
2595
4982
|
redactedFillSteps: subRedacted,
|
|
2596
4983
|
randomVars
|
|
2597
4984
|
} = interpolateHunt(subHunt, process.env, h.context.randomVars);
|
|
2598
|
-
const subTargetType = h.driver.capabilities.has("navigate") ? "web" : "macos";
|
|
4985
|
+
const subTargetType = h.context.targetType ?? (h.driver.capabilities.has("navigate") ? "web" : "macos");
|
|
2599
4986
|
assertStepsSupportedByTarget(interpolatedSubHunt.steps, subTargetType);
|
|
2600
4987
|
assertHuntAssertionsSupportedByTarget(interpolatedSubHunt.assertions, subTargetType);
|
|
2601
4988
|
h.policy.assertWithinMaxSteps(interpolatedSubHunt.steps.length, huntName);
|
|
@@ -2920,15 +5307,15 @@ var STEP_HANDLERS = {
|
|
|
2920
5307
|
if (!responseFile) {
|
|
2921
5308
|
throw new Error("mock.response must include either body or file");
|
|
2922
5309
|
}
|
|
2923
|
-
const candidateFilePath =
|
|
2924
|
-
const resolvedConfigDir =
|
|
2925
|
-
const resolvedFilePath =
|
|
2926
|
-
const relativePath =
|
|
2927
|
-
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${
|
|
5310
|
+
const candidateFilePath = import_node_path9.default.isAbsolute(responseFile) ? responseFile : import_node_path9.default.join(h.context.configDir, responseFile);
|
|
5311
|
+
const resolvedConfigDir = import_node_path9.default.resolve(h.context.configDir);
|
|
5312
|
+
const resolvedFilePath = import_node_path9.default.resolve(candidateFilePath);
|
|
5313
|
+
const relativePath = import_node_path9.default.relative(resolvedConfigDir, resolvedFilePath);
|
|
5314
|
+
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path9.default.sep}`) && !import_node_path9.default.isAbsolute(relativePath);
|
|
2928
5315
|
if (!isWithinConfigDir) {
|
|
2929
5316
|
throw new Error("mock.response.file must resolve within config directory");
|
|
2930
5317
|
}
|
|
2931
|
-
responseBody = await
|
|
5318
|
+
responseBody = await import_node_fs9.default.promises.readFile(resolvedFilePath, "utf-8");
|
|
2932
5319
|
}
|
|
2933
5320
|
const contentType = mock.response.contentType ?? "application/json";
|
|
2934
5321
|
const status = mock.response.status;
|
|
@@ -2991,8 +5378,8 @@ var STEP_HANDLERS = {
|
|
|
2991
5378
|
capabilities: ["evaluate"],
|
|
2992
5379
|
run: async (h) => {
|
|
2993
5380
|
if (!("runScript" in h.step)) unknownStep();
|
|
2994
|
-
const filePath =
|
|
2995
|
-
const fileContents =
|
|
5381
|
+
const filePath = import_node_path9.default.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : import_node_path9.default.join(h.context.configDir, h.step.runScript.file);
|
|
5382
|
+
const fileContents = import_node_fs9.default.readFileSync(filePath, "utf-8");
|
|
2996
5383
|
await h.driver.evaluate(fileContents);
|
|
2997
5384
|
return {
|
|
2998
5385
|
kind: "result",
|
|
@@ -3013,19 +5400,19 @@ var STEP_HANDLERS = {
|
|
|
3013
5400
|
const name = h.step.assertScreenshot.name;
|
|
3014
5401
|
const threshold = h.step.assertScreenshot.threshold ?? 0.1;
|
|
3015
5402
|
const baselineDir = ensureBaselineDir2(h.context.configDir);
|
|
3016
|
-
const baselinePath =
|
|
3017
|
-
const currentScreenshotPath =
|
|
3018
|
-
|
|
5403
|
+
const baselinePath = import_node_path9.default.join(baselineDir, `${name}.png`);
|
|
5404
|
+
const currentScreenshotPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
|
|
5405
|
+
import_node_fs9.default.mkdirSync(import_node_path9.default.dirname(currentScreenshotPath), { recursive: true });
|
|
3019
5406
|
await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
|
|
3020
|
-
h.screenshots.push(
|
|
3021
|
-
if (!
|
|
3022
|
-
|
|
5407
|
+
h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-current.png`));
|
|
5408
|
+
if (!import_node_fs9.default.existsSync(baselinePath)) {
|
|
5409
|
+
import_node_fs9.default.copyFileSync(currentScreenshotPath, baselinePath);
|
|
3023
5410
|
return {
|
|
3024
5411
|
kind: "result",
|
|
3025
5412
|
result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
|
|
3026
5413
|
};
|
|
3027
5414
|
}
|
|
3028
|
-
const diffPath =
|
|
5415
|
+
const diffPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
|
|
3029
5416
|
const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
|
|
3030
5417
|
if (comparison.match) {
|
|
3031
5418
|
return {
|
|
@@ -3038,12 +5425,54 @@ var STEP_HANDLERS = {
|
|
|
3038
5425
|
}
|
|
3039
5426
|
};
|
|
3040
5427
|
}
|
|
3041
|
-
h.screenshots.push(
|
|
5428
|
+
h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-diff.png`));
|
|
3042
5429
|
throw new Error(
|
|
3043
5430
|
`Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
|
|
3044
5431
|
);
|
|
3045
5432
|
}
|
|
3046
5433
|
},
|
|
5434
|
+
assertWithAI: {
|
|
5435
|
+
capabilities: ["screenshot"],
|
|
5436
|
+
run: async (h) => {
|
|
5437
|
+
if (!("assertWithAI" in h.step)) unknownStep();
|
|
5438
|
+
const assertion = h.step.assertWithAI;
|
|
5439
|
+
const resolve = h.context.resolveAiConfig ?? tryResolveAiConfig;
|
|
5440
|
+
const aiConfig = resolve();
|
|
5441
|
+
if (!aiConfig) {
|
|
5442
|
+
return {
|
|
5443
|
+
kind: "result",
|
|
5444
|
+
result: {
|
|
5445
|
+
type: "assertWithAI",
|
|
5446
|
+
status: "warn",
|
|
5447
|
+
durationMs: Date.now() - h.stepStart,
|
|
5448
|
+
value: `skipped: no AI provider configured (set PROWL_AI_KEY to enable) \u2014 "${assertion}"`
|
|
5449
|
+
}
|
|
5450
|
+
};
|
|
5451
|
+
}
|
|
5452
|
+
const fileName = `assertWithAI_step_${h.index + 1}.png`;
|
|
5453
|
+
const relative = await h.addScreenshot(fileName);
|
|
5454
|
+
const screenshotFullPath = import_node_path9.default.join(h.context.runDir, relative);
|
|
5455
|
+
const imageBase64 = import_node_fs9.default.readFileSync(screenshotFullPath).toString("base64");
|
|
5456
|
+
const assertVision = h.context.assertVision ?? assertWithAiVision;
|
|
5457
|
+
const verdict = await assertVision(
|
|
5458
|
+
{ imageBase64, mediaType: "image/png", assertion },
|
|
5459
|
+
aiConfig
|
|
5460
|
+
);
|
|
5461
|
+
if (verdict.pass) {
|
|
5462
|
+
return {
|
|
5463
|
+
kind: "result",
|
|
5464
|
+
result: {
|
|
5465
|
+
type: "assertWithAI",
|
|
5466
|
+
status: "pass",
|
|
5467
|
+
durationMs: Date.now() - h.stepStart,
|
|
5468
|
+
value: verdict.reason,
|
|
5469
|
+
screenshot: relative
|
|
5470
|
+
}
|
|
5471
|
+
};
|
|
5472
|
+
}
|
|
5473
|
+
throw new Error(`AI assertion failed: ${verdict.reason}`);
|
|
5474
|
+
}
|
|
5475
|
+
},
|
|
3047
5476
|
copyText: {
|
|
3048
5477
|
capabilities: ["query"],
|
|
3049
5478
|
run: async (h) => {
|
|
@@ -3080,7 +5509,7 @@ var STEP_HANDLERS = {
|
|
|
3080
5509
|
`Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
|
|
3081
5510
|
);
|
|
3082
5511
|
}
|
|
3083
|
-
const savePath =
|
|
5512
|
+
const savePath = import_node_path9.default.join(h.context.runDir, suggestedFilename);
|
|
3084
5513
|
await download.saveAs(savePath);
|
|
3085
5514
|
return {
|
|
3086
5515
|
kind: "result",
|
|
@@ -3109,8 +5538,8 @@ async function executeSteps(context) {
|
|
|
3109
5538
|
maxSteps: context.maxSteps,
|
|
3110
5539
|
selfHealing: context.selfHealing
|
|
3111
5540
|
});
|
|
3112
|
-
const screenshotsDir =
|
|
3113
|
-
|
|
5541
|
+
const screenshotsDir = import_node_path9.default.join(context.runDir, "screenshots");
|
|
5542
|
+
import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
|
|
3114
5543
|
const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
|
|
3115
5544
|
policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
|
|
3116
5545
|
const results = [];
|
|
@@ -3119,8 +5548,8 @@ async function executeSteps(context) {
|
|
|
3119
5548
|
context.runStartedAtMs = runStartedAtMs;
|
|
3120
5549
|
const addScreenshot = async (fileName) => {
|
|
3121
5550
|
const fullPath = screenshotPath(screenshotsDir, fileName);
|
|
3122
|
-
await
|
|
3123
|
-
const relative =
|
|
5551
|
+
await captureScreenshot2(driver, fullPath);
|
|
5552
|
+
const relative = import_node_path9.default.join("screenshots", fileName);
|
|
3124
5553
|
screenshots.push(relative);
|
|
3125
5554
|
return relative;
|
|
3126
5555
|
};
|
|
@@ -3208,12 +5637,12 @@ async function executeSteps(context) {
|
|
|
3208
5637
|
return { results, screenshots, failed: false };
|
|
3209
5638
|
}
|
|
3210
5639
|
async function captureFinalScreenshot(page, runDir) {
|
|
3211
|
-
const screenshotsDir =
|
|
3212
|
-
|
|
5640
|
+
const screenshotsDir = import_node_path9.default.join(runDir, "screenshots");
|
|
5641
|
+
import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
|
|
3213
5642
|
const fileName = "final.png";
|
|
3214
5643
|
const filePath = screenshotPath(screenshotsDir, fileName);
|
|
3215
|
-
await
|
|
3216
|
-
return
|
|
5644
|
+
await captureScreenshot2(page, filePath);
|
|
5645
|
+
return import_node_path9.default.join("screenshots", fileName);
|
|
3217
5646
|
}
|
|
3218
5647
|
|
|
3219
5648
|
// src/runner/assertions.ts
|
|
@@ -3373,18 +5802,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
|
|
|
3373
5802
|
}
|
|
3374
5803
|
|
|
3375
5804
|
// src/reporter/result.ts
|
|
3376
|
-
var
|
|
3377
|
-
var
|
|
5805
|
+
var import_node_fs10 = __toESM(require("fs"), 1);
|
|
5806
|
+
var import_node_path10 = __toESM(require("path"), 1);
|
|
3378
5807
|
function writeResult(runDir, result) {
|
|
3379
5808
|
const fileName = "result.json";
|
|
3380
|
-
const fullPath =
|
|
3381
|
-
|
|
5809
|
+
const fullPath = import_node_path10.default.join(runDir, fileName);
|
|
5810
|
+
import_node_fs10.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
|
|
3382
5811
|
return fileName;
|
|
3383
5812
|
}
|
|
3384
5813
|
|
|
3385
5814
|
// src/reporter/summary.ts
|
|
3386
|
-
var
|
|
3387
|
-
var
|
|
5815
|
+
var import_node_fs11 = __toESM(require("fs"), 1);
|
|
5816
|
+
var import_node_path11 = __toESM(require("path"), 1);
|
|
3388
5817
|
function escapeMd(text) {
|
|
3389
5818
|
return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
|
|
3390
5819
|
}
|
|
@@ -3462,15 +5891,15 @@ function writeSummary(runDir, result) {
|
|
|
3462
5891
|
}
|
|
3463
5892
|
}
|
|
3464
5893
|
const fileName = "summary.md";
|
|
3465
|
-
const fullPath =
|
|
3466
|
-
|
|
5894
|
+
const fullPath = import_node_path11.default.join(runDir, fileName);
|
|
5895
|
+
import_node_fs11.default.writeFileSync(fullPath, `${lines.join("\n")}
|
|
3467
5896
|
`);
|
|
3468
5897
|
return fileName;
|
|
3469
5898
|
}
|
|
3470
5899
|
|
|
3471
5900
|
// src/reporter/junit.ts
|
|
3472
|
-
var
|
|
3473
|
-
var
|
|
5901
|
+
var import_node_fs12 = __toESM(require("fs"), 1);
|
|
5902
|
+
var import_node_path12 = __toESM(require("path"), 1);
|
|
3474
5903
|
function escapeXml(text) {
|
|
3475
5904
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3476
5905
|
}
|
|
@@ -3514,8 +5943,8 @@ function writeJunit(runDir, result) {
|
|
|
3514
5943
|
lines.push(" </testsuite>");
|
|
3515
5944
|
lines.push("</testsuites>");
|
|
3516
5945
|
const fileName = "junit.xml";
|
|
3517
|
-
const fullPath =
|
|
3518
|
-
|
|
5946
|
+
const fullPath = import_node_path12.default.join(runDir, fileName);
|
|
5947
|
+
import_node_fs12.default.writeFileSync(fullPath, `${lines.join("\n")}
|
|
3519
5948
|
`);
|
|
3520
5949
|
return fileName;
|
|
3521
5950
|
}
|
|
@@ -3549,15 +5978,15 @@ function timestamp(prefix) {
|
|
|
3549
5978
|
}
|
|
3550
5979
|
|
|
3551
5980
|
// src/runner/history.ts
|
|
3552
|
-
var
|
|
3553
|
-
var
|
|
5981
|
+
var import_node_fs13 = __toESM(require("fs"), 1);
|
|
5982
|
+
var import_node_path13 = __toESM(require("path"), 1);
|
|
3554
5983
|
var HISTORY_FILE = "history.json";
|
|
3555
5984
|
var LOCK_FILE_SUFFIX = ".lock";
|
|
3556
5985
|
var LOCK_RETRY_MS = 10;
|
|
3557
5986
|
var LOCK_TIMEOUT_MS = 5e3;
|
|
3558
5987
|
var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
3559
5988
|
function historyPath(configDir) {
|
|
3560
|
-
return
|
|
5989
|
+
return import_node_path13.default.join(configDir, HISTORY_FILE);
|
|
3561
5990
|
}
|
|
3562
5991
|
function isHistoryEntry(value) {
|
|
3563
5992
|
if (!value || typeof value !== "object") {
|
|
@@ -3568,11 +5997,11 @@ function isHistoryEntry(value) {
|
|
|
3568
5997
|
}
|
|
3569
5998
|
function readHistory(configDir) {
|
|
3570
5999
|
const filePath = historyPath(configDir);
|
|
3571
|
-
if (!
|
|
6000
|
+
if (!import_node_fs13.default.existsSync(filePath)) {
|
|
3572
6001
|
return { entries: [] };
|
|
3573
6002
|
}
|
|
3574
6003
|
try {
|
|
3575
|
-
const raw =
|
|
6004
|
+
const raw = import_node_fs13.default.readFileSync(filePath, "utf-8");
|
|
3576
6005
|
const parsed = JSON.parse(raw);
|
|
3577
6006
|
if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
|
|
3578
6007
|
const validatedEntries = parsed.entries.filter(isHistoryEntry);
|
|
@@ -3611,12 +6040,12 @@ function sleepSync(ms) {
|
|
|
3611
6040
|
function withHistoryLock(configDir, fn) {
|
|
3612
6041
|
const filePath = historyPath(configDir);
|
|
3613
6042
|
const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
|
|
3614
|
-
|
|
6043
|
+
import_node_fs13.default.mkdirSync(import_node_path13.default.dirname(filePath), { recursive: true });
|
|
3615
6044
|
const startedAt = Date.now();
|
|
3616
6045
|
while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
|
|
3617
6046
|
let fd;
|
|
3618
6047
|
try {
|
|
3619
|
-
fd =
|
|
6048
|
+
fd = import_node_fs13.default.openSync(lockPath, "wx");
|
|
3620
6049
|
} catch (error) {
|
|
3621
6050
|
if (error.code === "EEXIST") {
|
|
3622
6051
|
sleepSync(LOCK_RETRY_MS);
|
|
@@ -3628,10 +6057,10 @@ function withHistoryLock(configDir, fn) {
|
|
|
3628
6057
|
return fn();
|
|
3629
6058
|
} finally {
|
|
3630
6059
|
try {
|
|
3631
|
-
|
|
6060
|
+
import_node_fs13.default.closeSync(fd);
|
|
3632
6061
|
} catch {
|
|
3633
6062
|
}
|
|
3634
|
-
|
|
6063
|
+
import_node_fs13.default.rmSync(lockPath, { force: true });
|
|
3635
6064
|
}
|
|
3636
6065
|
}
|
|
3637
6066
|
throw new Error(
|
|
@@ -3644,9 +6073,9 @@ function appendEntry(configDir, entry, maxRuns) {
|
|
|
3644
6073
|
const current = readHistory(configDir);
|
|
3645
6074
|
const next = pruneEntries([...current.entries, entry], maxRuns);
|
|
3646
6075
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
3647
|
-
|
|
6076
|
+
import_node_fs13.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
|
|
3648
6077
|
`);
|
|
3649
|
-
|
|
6078
|
+
import_node_fs13.default.renameSync(tempPath, filePath);
|
|
3650
6079
|
});
|
|
3651
6080
|
}
|
|
3652
6081
|
|
|
@@ -3659,11 +6088,11 @@ function parseViewportFlag(value) {
|
|
|
3659
6088
|
return value;
|
|
3660
6089
|
}
|
|
3661
6090
|
function resolvePath(configDir, inputPath) {
|
|
3662
|
-
if (
|
|
6091
|
+
if (import_node_path14.default.isAbsolute(inputPath)) {
|
|
3663
6092
|
return inputPath;
|
|
3664
6093
|
}
|
|
3665
|
-
const projectRoot =
|
|
3666
|
-
return
|
|
6094
|
+
const projectRoot = import_node_path14.default.dirname(configDir);
|
|
6095
|
+
return import_node_path14.default.join(projectRoot, inputPath);
|
|
3667
6096
|
}
|
|
3668
6097
|
function buildRunResult(options) {
|
|
3669
6098
|
return {
|
|
@@ -3682,12 +6111,12 @@ function buildRunResult(options) {
|
|
|
3682
6111
|
}
|
|
3683
6112
|
function writeConsoleLog(runDir, entries) {
|
|
3684
6113
|
const fileName = "console.log";
|
|
3685
|
-
const filePath =
|
|
6114
|
+
const filePath = import_node_path14.default.join(runDir, fileName);
|
|
3686
6115
|
const lines = entries.map((entry) => {
|
|
3687
6116
|
const location = entry.location ? ` (${entry.location})` : "";
|
|
3688
6117
|
return `[${entry.type}] ${entry.text}${location}`;
|
|
3689
6118
|
});
|
|
3690
|
-
|
|
6119
|
+
import_node_fs14.default.writeFileSync(filePath, `${lines.join("\n")}
|
|
3691
6120
|
`);
|
|
3692
6121
|
return fileName;
|
|
3693
6122
|
}
|
|
@@ -3695,8 +6124,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
3695
6124
|
const headless = options.headed ? false : config.browser.headless;
|
|
3696
6125
|
const slowMo = options.slowMo ?? config.browser.slowMo;
|
|
3697
6126
|
const maxSteps = config.guardrails.maxSteps;
|
|
3698
|
-
const runDir =
|
|
3699
|
-
|
|
6127
|
+
const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
|
|
6128
|
+
import_node_fs14.default.mkdirSync(runDir, { recursive: true });
|
|
3700
6129
|
const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
|
|
3701
6130
|
const engine = options.browser ?? config.browser.engine;
|
|
3702
6131
|
const channel = options.channel ?? config.browser.channel;
|
|
@@ -3813,7 +6242,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
3813
6242
|
}
|
|
3814
6243
|
return { result, runDir, steps: interpolatedHunt.steps };
|
|
3815
6244
|
}
|
|
3816
|
-
function
|
|
6245
|
+
function delay3(ms) {
|
|
3817
6246
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3818
6247
|
}
|
|
3819
6248
|
async function runHunt(options) {
|
|
@@ -3821,6 +6250,12 @@ async function runHunt(options) {
|
|
|
3821
6250
|
if (config.target.type === "macos") {
|
|
3822
6251
|
return runMacHunt(options, config, configDir, config.target);
|
|
3823
6252
|
}
|
|
6253
|
+
if (config.target.type === "android") {
|
|
6254
|
+
return runAndroidHunt(options, config, configDir, config.target);
|
|
6255
|
+
}
|
|
6256
|
+
if (config.target.type === "ios") {
|
|
6257
|
+
return runIosHunt(options, config, configDir, config.target);
|
|
6258
|
+
}
|
|
3824
6259
|
const hunt = loadHunt(options.huntName, configDir);
|
|
3825
6260
|
const {
|
|
3826
6261
|
hunt: interpolatedHunt,
|
|
@@ -3842,7 +6277,7 @@ async function runHunt(options) {
|
|
|
3842
6277
|
let lastResult;
|
|
3843
6278
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3844
6279
|
if (attempt > 0 && retryDelay > 0) {
|
|
3845
|
-
await
|
|
6280
|
+
await delay3(retryDelay);
|
|
3846
6281
|
}
|
|
3847
6282
|
lastResult = await executeHuntAttempt(
|
|
3848
6283
|
options,
|
|
@@ -3871,19 +6306,17 @@ async function runHunt(options) {
|
|
|
3871
6306
|
}
|
|
3872
6307
|
return lastResult;
|
|
3873
6308
|
}
|
|
3874
|
-
async function
|
|
6309
|
+
async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
|
|
3875
6310
|
const maxSteps = config.guardrails.maxSteps;
|
|
3876
|
-
const runDir =
|
|
3877
|
-
|
|
3878
|
-
const session = await
|
|
3879
|
-
app: target.app,
|
|
3880
|
-
timeoutMs: config.browser.timeout,
|
|
3881
|
-
clientFactory: options.macClientFactory
|
|
3882
|
-
});
|
|
6311
|
+
const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
|
|
6312
|
+
import_node_fs14.default.mkdirSync(runDir, { recursive: true });
|
|
6313
|
+
const session = await native.launchSession();
|
|
3883
6314
|
let result;
|
|
3884
6315
|
try {
|
|
3885
|
-
const
|
|
3886
|
-
const
|
|
6316
|
+
const driver = native.sessionDriver(session);
|
|
6317
|
+
const appIdentity = native.sessionAppIdentity(session);
|
|
6318
|
+
const targetLabel = `${native.targetType}:${appIdentity}`;
|
|
6319
|
+
const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
|
|
3887
6320
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3888
6321
|
const startTime = Date.now();
|
|
3889
6322
|
let stepResults = [];
|
|
@@ -3891,7 +6324,8 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
|
|
|
3891
6324
|
let stepFailed = false;
|
|
3892
6325
|
try {
|
|
3893
6326
|
const stepExecution = await executeSteps({
|
|
3894
|
-
driver
|
|
6327
|
+
driver,
|
|
6328
|
+
targetType: native.targetType,
|
|
3895
6329
|
steps: interpolatedHunt.steps,
|
|
3896
6330
|
targetUrl: targetLabel,
|
|
3897
6331
|
runDir,
|
|
@@ -3918,7 +6352,7 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
|
|
|
3918
6352
|
}
|
|
3919
6353
|
let finalScreenshot;
|
|
3920
6354
|
try {
|
|
3921
|
-
finalScreenshot = await captureFinalScreenshot(
|
|
6355
|
+
finalScreenshot = await captureFinalScreenshot(driver, runDir);
|
|
3922
6356
|
} catch {
|
|
3923
6357
|
finalScreenshot = void 0;
|
|
3924
6358
|
}
|
|
@@ -3939,16 +6373,116 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
|
|
|
3939
6373
|
});
|
|
3940
6374
|
result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
|
|
3941
6375
|
} finally {
|
|
3942
|
-
await
|
|
6376
|
+
await native.closeSession(session);
|
|
3943
6377
|
}
|
|
3944
6378
|
return { result, runDir, steps: interpolatedHunt.steps };
|
|
3945
6379
|
}
|
|
6380
|
+
async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
6381
|
+
return executeNativeHuntAttempt(
|
|
6382
|
+
options,
|
|
6383
|
+
config,
|
|
6384
|
+
configDir,
|
|
6385
|
+
interpolatedHunt,
|
|
6386
|
+
redactedFillSteps,
|
|
6387
|
+
randomVars,
|
|
6388
|
+
allowedApps,
|
|
6389
|
+
{
|
|
6390
|
+
targetType: "macos",
|
|
6391
|
+
targetApp: target.app,
|
|
6392
|
+
launchSession: () => launchMacSession({
|
|
6393
|
+
app: target.app,
|
|
6394
|
+
timeoutMs: config.browser.timeout,
|
|
6395
|
+
clientFactory: options.macClientFactory
|
|
6396
|
+
}),
|
|
6397
|
+
closeSession: closeMacSession,
|
|
6398
|
+
sessionDriver: (session) => session.driver,
|
|
6399
|
+
sessionAppIdentity: (session) => session.bundleId
|
|
6400
|
+
}
|
|
6401
|
+
);
|
|
6402
|
+
}
|
|
3946
6403
|
async function runMacHunt(options, config, configDir, target) {
|
|
6404
|
+
return runNativeHunt(options, config, configDir, target, {
|
|
6405
|
+
targetType: "macos",
|
|
6406
|
+
assertAppAllowed: (allowedApps, nativeTarget) => assertTargetAppAllowed(allowedApps, nativeTarget.app),
|
|
6407
|
+
attempt: executeMacHuntAttempt
|
|
6408
|
+
});
|
|
6409
|
+
}
|
|
6410
|
+
async function executeAndroidHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
6411
|
+
const launch = options.androidSessionFactory ?? launchAndroidSession;
|
|
6412
|
+
return executeNativeHuntAttempt(
|
|
6413
|
+
options,
|
|
6414
|
+
config,
|
|
6415
|
+
configDir,
|
|
6416
|
+
interpolatedHunt,
|
|
6417
|
+
redactedFillSteps,
|
|
6418
|
+
randomVars,
|
|
6419
|
+
allowedApps,
|
|
6420
|
+
{
|
|
6421
|
+
targetType: "android",
|
|
6422
|
+
targetApp: target.app,
|
|
6423
|
+
launchSession: () => launch({
|
|
6424
|
+
app: target.app,
|
|
6425
|
+
deviceSerial: target.deviceSerial,
|
|
6426
|
+
coldStart: target.coldStart,
|
|
6427
|
+
timeoutMs: config.browser.timeout,
|
|
6428
|
+
allowedApps
|
|
6429
|
+
}),
|
|
6430
|
+
closeSession: closeAndroidSession,
|
|
6431
|
+
sessionDriver: (session) => session.driver,
|
|
6432
|
+
sessionAppIdentity: (session) => session.package
|
|
6433
|
+
}
|
|
6434
|
+
);
|
|
6435
|
+
}
|
|
6436
|
+
async function runAndroidHunt(options, config, configDir, target) {
|
|
6437
|
+
return runNativeHunt(options, config, configDir, target, {
|
|
6438
|
+
targetType: "android",
|
|
6439
|
+
assertAppAllowed: (allowedApps, nativeTarget) => {
|
|
6440
|
+
if (!nativeTarget.app.toLowerCase().endsWith(".apk")) {
|
|
6441
|
+
assertAndroidAppAllowed(allowedApps, nativeTarget.app);
|
|
6442
|
+
}
|
|
6443
|
+
},
|
|
6444
|
+
attempt: executeAndroidHuntAttempt
|
|
6445
|
+
});
|
|
6446
|
+
}
|
|
6447
|
+
async function executeIosHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
6448
|
+
const launch = options.iosSessionFactory ?? launchIosSession;
|
|
6449
|
+
return executeNativeHuntAttempt(
|
|
6450
|
+
options,
|
|
6451
|
+
config,
|
|
6452
|
+
configDir,
|
|
6453
|
+
interpolatedHunt,
|
|
6454
|
+
redactedFillSteps,
|
|
6455
|
+
randomVars,
|
|
6456
|
+
allowedApps,
|
|
6457
|
+
{
|
|
6458
|
+
targetType: "ios",
|
|
6459
|
+
targetApp: target.app,
|
|
6460
|
+
launchSession: () => launch({
|
|
6461
|
+
app: target.app,
|
|
6462
|
+
udid: target.udid,
|
|
6463
|
+
coldStart: target.coldStart,
|
|
6464
|
+
timeoutMs: config.browser.timeout,
|
|
6465
|
+
allowedApps
|
|
6466
|
+
}),
|
|
6467
|
+
closeSession: closeIosSession,
|
|
6468
|
+
sessionDriver: (session) => session.driver,
|
|
6469
|
+
sessionAppIdentity: (session) => session.bundleId
|
|
6470
|
+
}
|
|
6471
|
+
);
|
|
6472
|
+
}
|
|
6473
|
+
async function runIosHunt(options, config, configDir, target) {
|
|
6474
|
+
return runNativeHunt(options, config, configDir, target, {
|
|
6475
|
+
targetType: "ios",
|
|
6476
|
+
assertAppAllowed: (allowedApps, nativeTarget) => assertIosAppAllowed(allowedApps, nativeTarget.app),
|
|
6477
|
+
attempt: executeIosHuntAttempt
|
|
6478
|
+
});
|
|
6479
|
+
}
|
|
6480
|
+
async function runNativeHunt(options, config, configDir, target, native) {
|
|
3947
6481
|
const hunt = loadHunt(options.huntName, configDir);
|
|
3948
6482
|
const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
|
|
3949
|
-
assertStepsSupportedByTarget(interpolatedHunt.steps,
|
|
3950
|
-
assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions,
|
|
3951
|
-
|
|
6483
|
+
assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
|
|
6484
|
+
assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
|
|
6485
|
+
native.assertAppAllowed(config.guardrails.allowedApps, target);
|
|
3952
6486
|
const maxSteps = config.guardrails.maxSteps;
|
|
3953
6487
|
if (interpolatedHunt.steps.length > maxSteps) {
|
|
3954
6488
|
throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
|
|
@@ -3958,9 +6492,9 @@ async function runMacHunt(options, config, configDir, target) {
|
|
|
3958
6492
|
let lastResult;
|
|
3959
6493
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3960
6494
|
if (attempt > 0 && retryDelay > 0) {
|
|
3961
|
-
await
|
|
6495
|
+
await delay3(retryDelay);
|
|
3962
6496
|
}
|
|
3963
|
-
lastResult = await
|
|
6497
|
+
lastResult = await native.attempt(
|
|
3964
6498
|
options,
|
|
3965
6499
|
config,
|
|
3966
6500
|
configDir,
|
|
@@ -3988,7 +6522,7 @@ async function runMacHunt(options, config, configDir, target) {
|
|
|
3988
6522
|
}
|
|
3989
6523
|
function recordHistory(configDir, outcome, maxRuns) {
|
|
3990
6524
|
try {
|
|
3991
|
-
const relativeRunDir =
|
|
6525
|
+
const relativeRunDir = import_node_path14.default.relative(configDir, outcome.runDir);
|
|
3992
6526
|
appendEntry(
|
|
3993
6527
|
configDir,
|
|
3994
6528
|
{
|
|
@@ -4081,6 +6615,7 @@ function describeStep(step) {
|
|
|
4081
6615
|
const th = step.assertScreenshot.threshold !== void 0 ? ` (threshold: ${step.assertScreenshot.threshold})` : "";
|
|
4082
6616
|
return `assertScreenshot "${step.assertScreenshot.name}"${th}`;
|
|
4083
6617
|
}
|
|
6618
|
+
if ("assertWithAI" in step) return `assertWithAI "${truncate(step.assertWithAI, 60)}"`;
|
|
4084
6619
|
return "unknown step";
|
|
4085
6620
|
}
|
|
4086
6621
|
function truncate(text, max) {
|
|
@@ -4096,6 +6631,9 @@ function printStepResult(result, step, _index) {
|
|
|
4096
6631
|
const duration = import_chalk.default.gray(`(${result.durationMs}ms)`);
|
|
4097
6632
|
if (result.status === "pass") {
|
|
4098
6633
|
console.log(` ${import_chalk.default.green("\u2713")} ${label} ${duration}`);
|
|
6634
|
+
} else if (result.status === "warn") {
|
|
6635
|
+
const note = result.value ? import_chalk.default.gray(` \u2014 ${result.value}`) : "";
|
|
6636
|
+
console.log(` ${import_chalk.default.yellow("\u25CB")} ${label} ${duration}${note}`);
|
|
4099
6637
|
} else {
|
|
4100
6638
|
const error = result.error ? import_chalk.default.gray(` \u2014 ${result.error}`) : "";
|
|
4101
6639
|
console.log(` ${import_chalk.default.red("\u2717")} ${label} ${duration}${error}`);
|
|
@@ -4213,37 +6751,37 @@ function buildRunCommand() {
|
|
|
4213
6751
|
}
|
|
4214
6752
|
|
|
4215
6753
|
// src/cli/commands/init.ts
|
|
4216
|
-
var
|
|
4217
|
-
var
|
|
6754
|
+
var import_node_fs15 = __toESM(require("fs"), 1);
|
|
6755
|
+
var import_node_path15 = __toESM(require("path"), 1);
|
|
4218
6756
|
var import_node_url2 = require("url");
|
|
4219
6757
|
var import_commander2 = require("commander");
|
|
4220
6758
|
var import_chalk4 = __toESM(require("chalk"), 1);
|
|
4221
6759
|
init_loader();
|
|
4222
|
-
var
|
|
6760
|
+
var import_meta4 = {};
|
|
4223
6761
|
function getPackageRoot2() {
|
|
4224
|
-
const currentFile = (0, import_node_url2.fileURLToPath)(
|
|
4225
|
-
let dir =
|
|
4226
|
-
const root =
|
|
6762
|
+
const currentFile = (0, import_node_url2.fileURLToPath)(import_meta4.url);
|
|
6763
|
+
let dir = import_node_path15.default.dirname(currentFile);
|
|
6764
|
+
const root = import_node_path15.default.parse(dir).root;
|
|
4227
6765
|
while (dir !== root) {
|
|
4228
|
-
if (
|
|
6766
|
+
if (import_node_fs15.default.existsSync(import_node_path15.default.join(dir, "package.json"))) {
|
|
4229
6767
|
return dir;
|
|
4230
6768
|
}
|
|
4231
|
-
dir =
|
|
6769
|
+
dir = import_node_path15.default.dirname(dir);
|
|
4232
6770
|
}
|
|
4233
|
-
if (
|
|
6771
|
+
if (import_node_fs15.default.existsSync(import_node_path15.default.join(root, "package.json"))) {
|
|
4234
6772
|
return root;
|
|
4235
6773
|
}
|
|
4236
6774
|
throw new Error("Cannot find package root. Reinstall prowl-tools.");
|
|
4237
6775
|
}
|
|
4238
6776
|
function copyFile(source, destination) {
|
|
4239
|
-
|
|
4240
|
-
|
|
6777
|
+
import_node_fs15.default.mkdirSync(import_node_path15.default.dirname(destination), { recursive: true });
|
|
6778
|
+
import_node_fs15.default.copyFileSync(source, destination);
|
|
4241
6779
|
}
|
|
4242
6780
|
function buildInitCommand() {
|
|
4243
6781
|
const command = new import_commander2.Command("init").option("--force", `Overwrite existing ${CONFIG_DIR} directory`).action((options) => {
|
|
4244
6782
|
const root = process.cwd();
|
|
4245
|
-
const prowlDir =
|
|
4246
|
-
if (
|
|
6783
|
+
const prowlDir = import_node_path15.default.join(root, CONFIG_DIR);
|
|
6784
|
+
if (import_node_fs15.default.existsSync(prowlDir) && !options.force) {
|
|
4247
6785
|
console.error(
|
|
4248
6786
|
import_chalk4.default.red(
|
|
4249
6787
|
`${CONFIG_DIR} already exists. Run with --force to reinitialize prowl configuration without deleting existing files.`
|
|
@@ -4253,20 +6791,20 @@ function buildInitCommand() {
|
|
|
4253
6791
|
return;
|
|
4254
6792
|
}
|
|
4255
6793
|
const packageRoot = getPackageRoot2();
|
|
4256
|
-
const examplesDir =
|
|
4257
|
-
const exampleConfig =
|
|
4258
|
-
const exampleHuntsDir =
|
|
4259
|
-
if (!
|
|
6794
|
+
const examplesDir = import_node_path15.default.join(packageRoot, "examples");
|
|
6795
|
+
const exampleConfig = import_node_path15.default.join(examplesDir, "config.yml");
|
|
6796
|
+
const exampleHuntsDir = import_node_path15.default.join(examplesDir, "hunts");
|
|
6797
|
+
if (!import_node_fs15.default.existsSync(exampleConfig) || !import_node_fs15.default.existsSync(exampleHuntsDir)) {
|
|
4260
6798
|
console.error(import_chalk4.default.red("Examples not found in package. Reinstall prowl-tools."));
|
|
4261
6799
|
process.exitCode = 1;
|
|
4262
6800
|
return;
|
|
4263
6801
|
}
|
|
4264
|
-
copyFile(exampleConfig,
|
|
4265
|
-
const huntFiles =
|
|
6802
|
+
copyFile(exampleConfig, import_node_path15.default.join(prowlDir, "config.yml"));
|
|
6803
|
+
const huntFiles = import_node_fs15.default.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
|
|
4266
6804
|
for (const huntFile of huntFiles) {
|
|
4267
6805
|
copyFile(
|
|
4268
|
-
|
|
4269
|
-
|
|
6806
|
+
import_node_path15.default.join(exampleHuntsDir, huntFile),
|
|
6807
|
+
import_node_path15.default.join(prowlDir, "hunts", huntFile)
|
|
4270
6808
|
);
|
|
4271
6809
|
}
|
|
4272
6810
|
const gitignore = [
|
|
@@ -4280,7 +6818,7 @@ function buildInitCommand() {
|
|
|
4280
6818
|
".env",
|
|
4281
6819
|
""
|
|
4282
6820
|
].join("\n");
|
|
4283
|
-
|
|
6821
|
+
import_node_fs15.default.writeFileSync(import_node_path15.default.join(prowlDir, ".gitignore"), gitignore);
|
|
4284
6822
|
console.log(welcomeBanner());
|
|
4285
6823
|
console.log(import_chalk4.default.green(` Initialized ${CONFIG_DIR} directory.`));
|
|
4286
6824
|
console.log(import_chalk4.default.gray(" Run ") + import_chalk4.default.bold("prowl run hello") + import_chalk4.default.gray(" to get started."));
|
|
@@ -4290,17 +6828,17 @@ function buildInitCommand() {
|
|
|
4290
6828
|
}
|
|
4291
6829
|
|
|
4292
6830
|
// src/cli/commands/login.ts
|
|
4293
|
-
var
|
|
6831
|
+
var import_node_path16 = __toESM(require("path"), 1);
|
|
4294
6832
|
var import_node_readline = __toESM(require("readline"), 1);
|
|
4295
6833
|
var import_chalk5 = __toESM(require("chalk"), 1);
|
|
4296
6834
|
var import_commander3 = require("commander");
|
|
4297
6835
|
init_loader();
|
|
4298
6836
|
function resolvePath2(configDir, inputPath) {
|
|
4299
|
-
if (
|
|
6837
|
+
if (import_node_path16.default.isAbsolute(inputPath)) {
|
|
4300
6838
|
return inputPath;
|
|
4301
6839
|
}
|
|
4302
|
-
const projectRoot =
|
|
4303
|
-
return
|
|
6840
|
+
const projectRoot = import_node_path16.default.dirname(configDir);
|
|
6841
|
+
return import_node_path16.default.join(projectRoot, inputPath);
|
|
4304
6842
|
}
|
|
4305
6843
|
function waitForEnter(prompt) {
|
|
4306
6844
|
return new Promise((resolve) => {
|
|
@@ -4316,7 +6854,7 @@ function buildLoginCommand() {
|
|
|
4316
6854
|
let session = null;
|
|
4317
6855
|
try {
|
|
4318
6856
|
const { config, configDir } = loadConfig(options.config);
|
|
4319
|
-
if (config.target.type
|
|
6857
|
+
if (config.target.type !== "web") {
|
|
4320
6858
|
throw new Error("`prowl login` captures browser auth state and only applies to web targets.");
|
|
4321
6859
|
}
|
|
4322
6860
|
const targetUrl = options.url ?? config.target.url;
|
|
@@ -4387,18 +6925,18 @@ function buildListCommand() {
|
|
|
4387
6925
|
}
|
|
4388
6926
|
|
|
4389
6927
|
// src/cli/commands/watch.ts
|
|
4390
|
-
var
|
|
6928
|
+
var import_node_fs16 = __toESM(require("fs"), 1);
|
|
4391
6929
|
var import_chalk7 = __toESM(require("chalk"), 1);
|
|
4392
6930
|
var import_commander5 = require("commander");
|
|
4393
6931
|
init_loader();
|
|
4394
6932
|
|
|
4395
6933
|
// src/cli/watch-utils.ts
|
|
4396
|
-
var
|
|
6934
|
+
var import_node_path17 = __toESM(require("path"), 1);
|
|
4397
6935
|
function getWatchTargets(configDir, huntName) {
|
|
4398
6936
|
return [
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
6937
|
+
import_node_path17.default.join(configDir, "hunts", `${huntName}.yml`),
|
|
6938
|
+
import_node_path17.default.join(configDir, "config.yml"),
|
|
6939
|
+
import_node_path17.default.join(configDir, ".env")
|
|
4402
6940
|
];
|
|
4403
6941
|
}
|
|
4404
6942
|
function createDebouncer(delayMs, fn) {
|
|
@@ -4467,14 +7005,14 @@ function buildWatchCommand() {
|
|
|
4467
7005
|
});
|
|
4468
7006
|
const unwatch = [];
|
|
4469
7007
|
for (const target of watchTargets) {
|
|
4470
|
-
|
|
7008
|
+
import_node_fs16.default.watchFile(target, { interval: 150 }, (curr, prev) => {
|
|
4471
7009
|
if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) {
|
|
4472
7010
|
return;
|
|
4473
7011
|
}
|
|
4474
7012
|
console.log(import_chalk7.default.gray(`Change detected: ${target}`));
|
|
4475
7013
|
debounced.trigger();
|
|
4476
7014
|
});
|
|
4477
|
-
unwatch.push(() =>
|
|
7015
|
+
unwatch.push(() => import_node_fs16.default.unwatchFile(target));
|
|
4478
7016
|
}
|
|
4479
7017
|
const stop = () => {
|
|
4480
7018
|
if (stopped) {
|
|
@@ -4500,12 +7038,12 @@ var import_commander6 = require("commander");
|
|
|
4500
7038
|
var import_chalk9 = __toESM(require("chalk"), 1);
|
|
4501
7039
|
|
|
4502
7040
|
// src/runner/suite.ts
|
|
4503
|
-
var
|
|
7041
|
+
var import_node_path20 = __toESM(require("path"), 1);
|
|
4504
7042
|
init_loader();
|
|
4505
7043
|
|
|
4506
7044
|
// src/reporter/ci-summary.ts
|
|
4507
|
-
var
|
|
4508
|
-
var
|
|
7045
|
+
var import_node_fs17 = __toESM(require("fs"), 1);
|
|
7046
|
+
var import_node_path18 = __toESM(require("path"), 1);
|
|
4509
7047
|
var import_chalk8 = __toESM(require("chalk"), 1);
|
|
4510
7048
|
function countCiResults(results) {
|
|
4511
7049
|
return {
|
|
@@ -4569,9 +7107,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
|
|
|
4569
7107
|
...flaky.length > 0 ? { flaky } : {},
|
|
4570
7108
|
...clusters.length > 0 ? { clusters } : {}
|
|
4571
7109
|
};
|
|
4572
|
-
|
|
4573
|
-
const filePath =
|
|
4574
|
-
|
|
7110
|
+
import_node_fs17.default.mkdirSync(ciRunDir, { recursive: true });
|
|
7111
|
+
const filePath = import_node_path18.default.join(ciRunDir, "ci-result.json");
|
|
7112
|
+
import_node_fs17.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
|
|
4575
7113
|
return filePath;
|
|
4576
7114
|
}
|
|
4577
7115
|
|
|
@@ -4705,8 +7243,8 @@ function clusterFailures(failures) {
|
|
|
4705
7243
|
}
|
|
4706
7244
|
|
|
4707
7245
|
// src/backlog/index.ts
|
|
4708
|
-
var
|
|
4709
|
-
var
|
|
7246
|
+
var import_node_fs18 = __toESM(require("fs"), 1);
|
|
7247
|
+
var import_node_path19 = __toESM(require("path"), 1);
|
|
4710
7248
|
|
|
4711
7249
|
// src/backlog/parse.ts
|
|
4712
7250
|
var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
|
|
@@ -4796,7 +7334,7 @@ ${after}`;
|
|
|
4796
7334
|
// src/backlog/index.ts
|
|
4797
7335
|
function readFileOrEmpty(filePath) {
|
|
4798
7336
|
try {
|
|
4799
|
-
return
|
|
7337
|
+
return import_node_fs18.default.readFileSync(filePath, "utf-8");
|
|
4800
7338
|
} catch (error) {
|
|
4801
7339
|
const err = error;
|
|
4802
7340
|
if (err.code === "ENOENT") return "";
|
|
@@ -4812,7 +7350,7 @@ function buildFailure(hunt) {
|
|
|
4812
7350
|
if (!hunt.runDir) return failure;
|
|
4813
7351
|
let run;
|
|
4814
7352
|
try {
|
|
4815
|
-
const resultJson = readFileOrEmpty(
|
|
7353
|
+
const resultJson = readFileOrEmpty(import_node_path19.default.join(hunt.runDir, "result.json"));
|
|
4816
7354
|
if (!resultJson) return failure;
|
|
4817
7355
|
run = JSON.parse(resultJson);
|
|
4818
7356
|
} catch (error) {
|
|
@@ -4841,8 +7379,8 @@ function extractFailures(suiteResult) {
|
|
|
4841
7379
|
}
|
|
4842
7380
|
function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
4843
7381
|
const projectRoot = options.projectRoot ?? process.cwd();
|
|
4844
|
-
const backlogPath = options.backlogPath ??
|
|
4845
|
-
const resolvedPath = options.resolvedPath ??
|
|
7382
|
+
const backlogPath = options.backlogPath ?? import_node_path19.default.join(projectRoot, "docs", "backlog.md");
|
|
7383
|
+
const resolvedPath = options.resolvedPath ?? import_node_path19.default.join(projectRoot, "docs", "resolved.md");
|
|
4846
7384
|
const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
4847
7385
|
const summary = { created: [], regressions: [], skipped: [], backlogPath };
|
|
4848
7386
|
const failures = extractFailures(suiteResult);
|
|
@@ -4874,8 +7412,8 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
|
4874
7412
|
}
|
|
4875
7413
|
}
|
|
4876
7414
|
if (ticketsToAdd.length > 0) {
|
|
4877
|
-
|
|
4878
|
-
|
|
7415
|
+
import_node_fs18.default.mkdirSync(import_node_path19.default.dirname(backlogPath), { recursive: true });
|
|
7416
|
+
import_node_fs18.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
|
|
4879
7417
|
}
|
|
4880
7418
|
return summary;
|
|
4881
7419
|
}
|
|
@@ -5036,7 +7574,7 @@ async function runSuite(options = {}) {
|
|
|
5036
7574
|
const clusters = clusterFailures(
|
|
5037
7575
|
extractFailures({ result: { hunts: results }, resultPath: null })
|
|
5038
7576
|
).filter((cluster) => cluster.count > 1);
|
|
5039
|
-
const ciRunDir =
|
|
7577
|
+
const ciRunDir = import_node_path20.default.join(configDir, "runs", timestamp("ci"));
|
|
5040
7578
|
const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
|
|
5041
7579
|
const { passed, failed, skipped } = countCiResults(results);
|
|
5042
7580
|
return {
|
|
@@ -5154,8 +7692,8 @@ function buildCiCommand() {
|
|
|
5154
7692
|
}
|
|
5155
7693
|
|
|
5156
7694
|
// src/cli/commands/update-baselines.ts
|
|
5157
|
-
var
|
|
5158
|
-
var
|
|
7695
|
+
var import_node_fs19 = __toESM(require("fs"), 1);
|
|
7696
|
+
var import_node_path21 = __toESM(require("path"), 1);
|
|
5159
7697
|
var import_commander7 = require("commander");
|
|
5160
7698
|
var import_chalk10 = __toESM(require("chalk"), 1);
|
|
5161
7699
|
init_loader();
|
|
@@ -5163,33 +7701,33 @@ function buildUpdateBaselinesCommand() {
|
|
|
5163
7701
|
const command = new import_commander7.Command("update-baselines").description("Accept current screenshots as new visual regression baselines").option("--run <dir>", "Specific run directory to use").option("--name <name>", "Update only a specific baseline by name").option("--config <path>", "Custom config path").option("-y, --yes", "Skip confirmation prompt").action(async (options) => {
|
|
5164
7702
|
try {
|
|
5165
7703
|
const { configDir } = loadConfig(options.config);
|
|
5166
|
-
const baselinesDir =
|
|
5167
|
-
|
|
7704
|
+
const baselinesDir = import_node_path21.default.join(configDir, "baselines");
|
|
7705
|
+
import_node_fs19.default.mkdirSync(baselinesDir, { recursive: true });
|
|
5168
7706
|
let runDir;
|
|
5169
7707
|
if (options.run) {
|
|
5170
|
-
runDir =
|
|
7708
|
+
runDir = import_node_path21.default.isAbsolute(options.run) ? options.run : import_node_path21.default.resolve(options.run);
|
|
5171
7709
|
} else {
|
|
5172
|
-
const runsDir =
|
|
5173
|
-
if (!
|
|
7710
|
+
const runsDir = import_node_path21.default.join(configDir, "runs");
|
|
7711
|
+
if (!import_node_fs19.default.existsSync(runsDir)) {
|
|
5174
7712
|
console.error(import_chalk10.default.red(" No runs directory found. Run a hunt first."));
|
|
5175
7713
|
process.exitCode = 1;
|
|
5176
7714
|
return;
|
|
5177
7715
|
}
|
|
5178
|
-
const entries =
|
|
7716
|
+
const entries = import_node_fs19.default.readdirSync(runsDir).filter((e) => import_node_fs19.default.statSync(import_node_path21.default.join(runsDir, e)).isDirectory()).sort().reverse();
|
|
5179
7717
|
if (entries.length === 0) {
|
|
5180
7718
|
console.error(import_chalk10.default.red(" No run directories found. Run a hunt first."));
|
|
5181
7719
|
process.exitCode = 1;
|
|
5182
7720
|
return;
|
|
5183
7721
|
}
|
|
5184
|
-
runDir =
|
|
7722
|
+
runDir = import_node_path21.default.join(runsDir, entries[0]);
|
|
5185
7723
|
}
|
|
5186
|
-
const screenshotsDir =
|
|
5187
|
-
if (!
|
|
7724
|
+
const screenshotsDir = import_node_path21.default.join(runDir, "screenshots");
|
|
7725
|
+
if (!import_node_fs19.default.existsSync(screenshotsDir)) {
|
|
5188
7726
|
console.error(import_chalk10.default.red(` No screenshots found in ${runDir}`));
|
|
5189
7727
|
process.exitCode = 1;
|
|
5190
7728
|
return;
|
|
5191
7729
|
}
|
|
5192
|
-
const screenshots =
|
|
7730
|
+
const screenshots = import_node_fs19.default.readdirSync(screenshotsDir).filter((f) => f.endsWith("-current.png"));
|
|
5193
7731
|
if (screenshots.length === 0) {
|
|
5194
7732
|
console.log(import_chalk10.default.yellow(" No assertScreenshot results found in this run."));
|
|
5195
7733
|
return;
|
|
@@ -5203,10 +7741,10 @@ function buildUpdateBaselinesCommand() {
|
|
|
5203
7741
|
let updated = 0;
|
|
5204
7742
|
for (const file of filtered) {
|
|
5205
7743
|
const baselineName = file.replace("-current.png", ".png");
|
|
5206
|
-
const sourcePath =
|
|
5207
|
-
const destPath =
|
|
5208
|
-
const exists =
|
|
5209
|
-
|
|
7744
|
+
const sourcePath = import_node_path21.default.join(screenshotsDir, file);
|
|
7745
|
+
const destPath = import_node_path21.default.join(baselinesDir, baselineName);
|
|
7746
|
+
const exists = import_node_fs19.default.existsSync(destPath);
|
|
7747
|
+
import_node_fs19.default.copyFileSync(sourcePath, destPath);
|
|
5210
7748
|
updated++;
|
|
5211
7749
|
const status = exists ? import_chalk10.default.yellow("updated") : import_chalk10.default.green("created");
|
|
5212
7750
|
console.log(` ${status} ${baselineName}`);
|
|
@@ -5334,30 +7872,372 @@ async function analyzePage(page) {
|
|
|
5334
7872
|
...el.formIndex >= 0 ? { formGroup: el.formIndex } : {}
|
|
5335
7873
|
};
|
|
5336
7874
|
});
|
|
5337
|
-
const links = raw.elements.filter((el) => el.tag === "a" && el.href).map((el) => {
|
|
5338
|
-
let selector;
|
|
5339
|
-
if (el.testId) {
|
|
5340
|
-
selector = `[data-testid="${el.testId}"]`;
|
|
5341
|
-
} else if (el.href) {
|
|
5342
|
-
selector = `a[href="${el.href}"]`;
|
|
5343
|
-
} else {
|
|
5344
|
-
selector = `a`;
|
|
7875
|
+
const links = raw.elements.filter((el) => el.tag === "a" && el.href).map((el) => {
|
|
7876
|
+
let selector;
|
|
7877
|
+
if (el.testId) {
|
|
7878
|
+
selector = `[data-testid="${el.testId}"]`;
|
|
7879
|
+
} else if (el.href) {
|
|
7880
|
+
selector = `a[href="${el.href}"]`;
|
|
7881
|
+
} else {
|
|
7882
|
+
selector = `a`;
|
|
7883
|
+
}
|
|
7884
|
+
return {
|
|
7885
|
+
text: el.text || "",
|
|
7886
|
+
href: el.href,
|
|
7887
|
+
selector
|
|
7888
|
+
};
|
|
7889
|
+
});
|
|
7890
|
+
const forms = raw.forms;
|
|
7891
|
+
return {
|
|
7892
|
+
url: raw.url,
|
|
7893
|
+
title: raw.title,
|
|
7894
|
+
elements,
|
|
7895
|
+
forms,
|
|
7896
|
+
links
|
|
7897
|
+
};
|
|
7898
|
+
}
|
|
7899
|
+
|
|
7900
|
+
// src/analyzer/mac.ts
|
|
7901
|
+
var INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
|
|
7902
|
+
"AXButton",
|
|
7903
|
+
"AXTextField",
|
|
7904
|
+
"AXSecureTextField",
|
|
7905
|
+
"AXTextArea",
|
|
7906
|
+
"AXCheckBox",
|
|
7907
|
+
"AXRadioButton",
|
|
7908
|
+
"AXPopUpButton",
|
|
7909
|
+
"AXMenuButton",
|
|
7910
|
+
"AXLink",
|
|
7911
|
+
"AXMenuItem",
|
|
7912
|
+
"AXComboBox",
|
|
7913
|
+
"AXSlider",
|
|
7914
|
+
"AXDisclosureTriangle"
|
|
7915
|
+
]);
|
|
7916
|
+
var DEFAULT_ANALYZE_TREE_DEPTH = 20;
|
|
7917
|
+
function str(value) {
|
|
7918
|
+
if (typeof value !== "string") {
|
|
7919
|
+
return void 0;
|
|
7920
|
+
}
|
|
7921
|
+
const trimmed = value.trim();
|
|
7922
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
7923
|
+
}
|
|
7924
|
+
function toNode(raw) {
|
|
7925
|
+
const node = raw ?? {};
|
|
7926
|
+
const children = Array.isArray(node.children) ? node.children.map(toNode) : void 0;
|
|
7927
|
+
return {
|
|
7928
|
+
role: str(node.role),
|
|
7929
|
+
title: str(node.title),
|
|
7930
|
+
description: str(node.description),
|
|
7931
|
+
value: str(node.value),
|
|
7932
|
+
identifier: str(node.identifier),
|
|
7933
|
+
enabled: typeof node.enabled === "boolean" ? node.enabled : void 0,
|
|
7934
|
+
...children ? { children } : {}
|
|
7935
|
+
};
|
|
7936
|
+
}
|
|
7937
|
+
function quote(value) {
|
|
7938
|
+
return `"${value}"`;
|
|
7939
|
+
}
|
|
7940
|
+
function rankMacSelectors(node) {
|
|
7941
|
+
const selectors = [];
|
|
7942
|
+
const identifier = node.identifier;
|
|
7943
|
+
const exactLabel = node.title ?? node.description;
|
|
7944
|
+
const name = node.title ?? node.description ?? node.value;
|
|
7945
|
+
if (identifier) {
|
|
7946
|
+
selectors.push(`id=${identifier}`);
|
|
7947
|
+
}
|
|
7948
|
+
if (exactLabel) {
|
|
7949
|
+
selectors.push(`label=${quote(exactLabel)}`);
|
|
7950
|
+
}
|
|
7951
|
+
if (node.role && name) {
|
|
7952
|
+
selectors.push(`role=${node.role}[name=${quote(name)}]`);
|
|
7953
|
+
}
|
|
7954
|
+
if (name) {
|
|
7955
|
+
selectors.push(`text=${quote(name)}`);
|
|
7956
|
+
}
|
|
7957
|
+
if (selectors.length === 0 && node.role) {
|
|
7958
|
+
selectors.push(`role=${node.role}`);
|
|
7959
|
+
}
|
|
7960
|
+
return selectors;
|
|
7961
|
+
}
|
|
7962
|
+
function toElement(node, source) {
|
|
7963
|
+
return {
|
|
7964
|
+
role: node.role ?? "?",
|
|
7965
|
+
...node.title ? { title: node.title } : {},
|
|
7966
|
+
...node.description ? { description: node.description } : {},
|
|
7967
|
+
...node.value ? { value: node.value } : {},
|
|
7968
|
+
...node.identifier ? { identifier: node.identifier } : {},
|
|
7969
|
+
...node.enabled !== void 0 ? { enabled: node.enabled } : {},
|
|
7970
|
+
source,
|
|
7971
|
+
selectors: rankMacSelectors(node)
|
|
7972
|
+
};
|
|
7973
|
+
}
|
|
7974
|
+
function collectInteractive(root) {
|
|
7975
|
+
const out = [];
|
|
7976
|
+
const visit = (node) => {
|
|
7977
|
+
if (node.role && INTERACTIVE_ROLES.has(node.role)) {
|
|
7978
|
+
out.push(toElement(node, "window"));
|
|
7979
|
+
}
|
|
7980
|
+
for (const child of node.children ?? []) {
|
|
7981
|
+
visit(child);
|
|
7982
|
+
}
|
|
7983
|
+
};
|
|
7984
|
+
visit(root);
|
|
7985
|
+
return out;
|
|
7986
|
+
}
|
|
7987
|
+
function toWindow(node) {
|
|
7988
|
+
const [best] = rankMacSelectors(node);
|
|
7989
|
+
return {
|
|
7990
|
+
...node.title ? { title: node.title } : {},
|
|
7991
|
+
...node.identifier ? { identifier: node.identifier } : {},
|
|
7992
|
+
selector: best ?? "role=AXWindow"
|
|
7993
|
+
};
|
|
7994
|
+
}
|
|
7995
|
+
async function analyzeMacApp(client, options) {
|
|
7996
|
+
const depth = options.treeDepth ?? DEFAULT_ANALYZE_TREE_DEPTH;
|
|
7997
|
+
const treeResult = await client.request("tree", { depth });
|
|
7998
|
+
const elements = collectInteractive(toNode(treeResult.tree));
|
|
7999
|
+
const windowsResult = await client.request("windows");
|
|
8000
|
+
const rawWindows = Array.isArray(windowsResult.windows) ? windowsResult.windows : [];
|
|
8001
|
+
const windows = rawWindows.map((raw) => toWindow(toNode(raw)));
|
|
8002
|
+
const menuItems = await readStatusMenu(client, options.menuTimeoutSeconds);
|
|
8003
|
+
return { app: options.app, elements, windows, menuItems };
|
|
8004
|
+
}
|
|
8005
|
+
async function readStatusMenu(client, menuTimeoutSeconds) {
|
|
8006
|
+
let statusItems;
|
|
8007
|
+
try {
|
|
8008
|
+
const status = await client.request("statusItems");
|
|
8009
|
+
statusItems = Array.isArray(status.items) ? status.items : [];
|
|
8010
|
+
} catch {
|
|
8011
|
+
return [];
|
|
8012
|
+
}
|
|
8013
|
+
if (statusItems.length === 0) {
|
|
8014
|
+
return [];
|
|
8015
|
+
}
|
|
8016
|
+
const params = menuTimeoutSeconds !== void 0 ? { timeout: menuTimeoutSeconds } : {};
|
|
8017
|
+
try {
|
|
8018
|
+
const menu = await client.request("openMenu", params);
|
|
8019
|
+
const rawItems = Array.isArray(menu.items) ? menu.items : [];
|
|
8020
|
+
return rawItems.map((raw) => toNode(raw)).filter((node) => node.role !== "AXMenuItem" || node.title || node.identifier || node.description).map((node) => toElement(node, "menu"));
|
|
8021
|
+
} catch {
|
|
8022
|
+
return [];
|
|
8023
|
+
} finally {
|
|
8024
|
+
await client.request("closeMenu").catch(() => void 0);
|
|
8025
|
+
}
|
|
8026
|
+
}
|
|
8027
|
+
|
|
8028
|
+
// src/analyzer/android.ts
|
|
8029
|
+
var ANDROID_INTERACTIVE_CLASSES = /* @__PURE__ */ new Set([
|
|
8030
|
+
"android.widget.Button",
|
|
8031
|
+
"android.widget.ImageButton",
|
|
8032
|
+
"android.widget.EditText",
|
|
8033
|
+
"android.widget.CheckBox",
|
|
8034
|
+
"android.widget.RadioButton",
|
|
8035
|
+
"android.widget.Switch",
|
|
8036
|
+
"android.widget.ToggleButton",
|
|
8037
|
+
"android.widget.Spinner",
|
|
8038
|
+
"android.widget.SeekBar",
|
|
8039
|
+
"android.widget.RatingBar",
|
|
8040
|
+
"android.widget.CompoundButton",
|
|
8041
|
+
"android.widget.AutoCompleteTextView",
|
|
8042
|
+
"android.widget.MultiAutoCompleteTextView",
|
|
8043
|
+
"android.widget.CheckedTextView",
|
|
8044
|
+
"androidx.appcompat.widget.SwitchCompat",
|
|
8045
|
+
"androidx.appcompat.widget.AppCompatButton",
|
|
8046
|
+
"androidx.appcompat.widget.AppCompatEditText"
|
|
8047
|
+
]);
|
|
8048
|
+
function str2(value) {
|
|
8049
|
+
if (value === void 0) {
|
|
8050
|
+
return void 0;
|
|
8051
|
+
}
|
|
8052
|
+
const trimmed = value.trim();
|
|
8053
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
8054
|
+
}
|
|
8055
|
+
function bool(value) {
|
|
8056
|
+
if (value === void 0) {
|
|
8057
|
+
return void 0;
|
|
8058
|
+
}
|
|
8059
|
+
return value === "true";
|
|
8060
|
+
}
|
|
8061
|
+
function toAndroidNode(element) {
|
|
8062
|
+
const a = element.attrs;
|
|
8063
|
+
return {
|
|
8064
|
+
className: str2(a["class"]),
|
|
8065
|
+
resourceId: str2(a["resource-id"]),
|
|
8066
|
+
contentDesc: str2(a["content-desc"]),
|
|
8067
|
+
text: str2(a.text),
|
|
8068
|
+
package: str2(a.package),
|
|
8069
|
+
clickable: bool(a.clickable),
|
|
8070
|
+
longClickable: bool(a["long-clickable"]),
|
|
8071
|
+
checkable: bool(a.checkable),
|
|
8072
|
+
checked: bool(a.checked),
|
|
8073
|
+
scrollable: bool(a.scrollable),
|
|
8074
|
+
focusable: bool(a.focusable),
|
|
8075
|
+
focused: bool(a.focused),
|
|
8076
|
+
enabled: bool(a.enabled),
|
|
8077
|
+
children: element.children.map(toAndroidNode)
|
|
8078
|
+
};
|
|
8079
|
+
}
|
|
8080
|
+
function parseAndroidHierarchy(xml) {
|
|
8081
|
+
const root = parseXml(xml);
|
|
8082
|
+
return root ? toAndroidNode(root) : null;
|
|
8083
|
+
}
|
|
8084
|
+
function isAndroidInteractive(node) {
|
|
8085
|
+
if (node.clickable || node.longClickable || node.checkable || node.scrollable) {
|
|
8086
|
+
return true;
|
|
8087
|
+
}
|
|
8088
|
+
return node.className !== void 0 && ANDROID_INTERACTIVE_CLASSES.has(node.className);
|
|
8089
|
+
}
|
|
8090
|
+
function rankAndroidSelectors(node) {
|
|
8091
|
+
return rankNativeSelectors({
|
|
8092
|
+
id: node.resourceId,
|
|
8093
|
+
label: node.contentDesc,
|
|
8094
|
+
role: node.className,
|
|
8095
|
+
name: node.text
|
|
8096
|
+
});
|
|
8097
|
+
}
|
|
8098
|
+
function toElement2(node) {
|
|
8099
|
+
return {
|
|
8100
|
+
className: node.className ?? "?",
|
|
8101
|
+
...node.resourceId ? { resourceId: node.resourceId } : {},
|
|
8102
|
+
...node.contentDesc ? { contentDesc: node.contentDesc } : {},
|
|
8103
|
+
...node.text ? { text: node.text } : {},
|
|
8104
|
+
...node.clickable !== void 0 ? { clickable: node.clickable } : {},
|
|
8105
|
+
...node.checkable !== void 0 ? { checkable: node.checkable } : {},
|
|
8106
|
+
...node.scrollable !== void 0 ? { scrollable: node.scrollable } : {},
|
|
8107
|
+
...node.enabled !== void 0 ? { enabled: node.enabled } : {},
|
|
8108
|
+
selectors: rankAndroidSelectors(node)
|
|
8109
|
+
};
|
|
8110
|
+
}
|
|
8111
|
+
function collectInteractive2(root) {
|
|
8112
|
+
const out = [];
|
|
8113
|
+
const visit = (node) => {
|
|
8114
|
+
if (isAndroidInteractive(node)) {
|
|
8115
|
+
out.push(toElement2(node));
|
|
5345
8116
|
}
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
5349
|
-
|
|
5350
|
-
|
|
8117
|
+
for (const child of node.children) {
|
|
8118
|
+
visit(child);
|
|
8119
|
+
}
|
|
8120
|
+
};
|
|
8121
|
+
visit(root);
|
|
8122
|
+
return out;
|
|
8123
|
+
}
|
|
8124
|
+
async function analyzeAndroidApp(client, options) {
|
|
8125
|
+
const xml = await client.source();
|
|
8126
|
+
const root = parseAndroidHierarchy(xml);
|
|
8127
|
+
const elements = root ? collectInteractive2(root) : [];
|
|
8128
|
+
return { app: options.app, elements };
|
|
8129
|
+
}
|
|
8130
|
+
|
|
8131
|
+
// src/analyzer/ios.ts
|
|
8132
|
+
var IOS_INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
|
|
8133
|
+
"XCUIElementTypeButton",
|
|
8134
|
+
"XCUIElementTypeCell",
|
|
8135
|
+
"XCUIElementTypeTextField",
|
|
8136
|
+
"XCUIElementTypeSecureTextField",
|
|
8137
|
+
"XCUIElementTypeSearchField",
|
|
8138
|
+
"XCUIElementTypeSwitch",
|
|
8139
|
+
"XCUIElementTypeToggle",
|
|
8140
|
+
"XCUIElementTypeLink",
|
|
8141
|
+
"XCUIElementTypeMenuItem",
|
|
8142
|
+
"XCUIElementTypeSlider",
|
|
8143
|
+
"XCUIElementTypeStepper",
|
|
8144
|
+
"XCUIElementTypeTextView",
|
|
8145
|
+
"XCUIElementTypePickerWheel",
|
|
8146
|
+
"XCUIElementTypeTab",
|
|
8147
|
+
"XCUIElementTypeSegmentedControl",
|
|
8148
|
+
"XCUIElementTypeCheckBox",
|
|
8149
|
+
"XCUIElementTypeRadioButton",
|
|
8150
|
+
"XCUIElementTypeKey"
|
|
8151
|
+
]);
|
|
8152
|
+
var IOS_WINDOW_TYPE = "XCUIElementTypeWindow";
|
|
8153
|
+
function str3(value) {
|
|
8154
|
+
if (value === void 0) {
|
|
8155
|
+
return void 0;
|
|
8156
|
+
}
|
|
8157
|
+
const trimmed = value.trim();
|
|
8158
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
8159
|
+
}
|
|
8160
|
+
function bool2(value) {
|
|
8161
|
+
if (value === void 0) {
|
|
8162
|
+
return void 0;
|
|
8163
|
+
}
|
|
8164
|
+
return value === "true" || value === "1";
|
|
8165
|
+
}
|
|
8166
|
+
function toIosNode(element) {
|
|
8167
|
+
const a = element.attrs;
|
|
8168
|
+
return {
|
|
8169
|
+
type: str3(a.type) ?? str3(element.tag),
|
|
8170
|
+
name: str3(a.name),
|
|
8171
|
+
label: str3(a.label),
|
|
8172
|
+
value: str3(a.value),
|
|
8173
|
+
enabled: bool2(a.enabled),
|
|
8174
|
+
visible: bool2(a.visible),
|
|
8175
|
+
children: element.children.map(toIosNode)
|
|
8176
|
+
};
|
|
8177
|
+
}
|
|
8178
|
+
function parseIosHierarchy(xml) {
|
|
8179
|
+
const root = parseXml(xml);
|
|
8180
|
+
return root ? toIosNode(root) : null;
|
|
8181
|
+
}
|
|
8182
|
+
function hasAccessibilityId(node) {
|
|
8183
|
+
return node.name !== void 0 && node.name !== node.label;
|
|
8184
|
+
}
|
|
8185
|
+
function rankIosSelectors(node) {
|
|
8186
|
+
return rankNativeSelectors({
|
|
8187
|
+
id: hasAccessibilityId(node) ? node.name : void 0,
|
|
8188
|
+
label: node.label,
|
|
8189
|
+
role: node.type ? shortIosType(node.type) : void 0,
|
|
8190
|
+
name: node.label ?? node.value
|
|
5351
8191
|
});
|
|
5352
|
-
|
|
8192
|
+
}
|
|
8193
|
+
function toElement3(node) {
|
|
5353
8194
|
return {
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
8195
|
+
type: node.type ?? "?",
|
|
8196
|
+
...node.name ? { name: node.name } : {},
|
|
8197
|
+
...node.label ? { label: node.label } : {},
|
|
8198
|
+
...node.value ? { value: node.value } : {},
|
|
8199
|
+
...node.enabled !== void 0 ? { enabled: node.enabled } : {},
|
|
8200
|
+
...node.visible !== void 0 ? { visible: node.visible } : {},
|
|
8201
|
+
selectors: rankIosSelectors(node)
|
|
5359
8202
|
};
|
|
5360
8203
|
}
|
|
8204
|
+
function toWindow2(node) {
|
|
8205
|
+
const [best] = rankIosSelectors(node);
|
|
8206
|
+
return {
|
|
8207
|
+
...node.name ? { name: node.name } : {},
|
|
8208
|
+
...node.label ? { label: node.label } : {},
|
|
8209
|
+
selector: best ?? `role=${shortIosType(IOS_WINDOW_TYPE)}`
|
|
8210
|
+
};
|
|
8211
|
+
}
|
|
8212
|
+
function isIosInteractive(node) {
|
|
8213
|
+
return node.type !== void 0 && IOS_INTERACTIVE_TYPES.has(node.type);
|
|
8214
|
+
}
|
|
8215
|
+
function collect(root) {
|
|
8216
|
+
const elements = [];
|
|
8217
|
+
const windows = [];
|
|
8218
|
+
const visit = (node) => {
|
|
8219
|
+
if (node.type === IOS_WINDOW_TYPE) {
|
|
8220
|
+
windows.push(toWindow2(node));
|
|
8221
|
+
}
|
|
8222
|
+
if (isIosInteractive(node)) {
|
|
8223
|
+
elements.push(toElement3(node));
|
|
8224
|
+
}
|
|
8225
|
+
for (const child of node.children) {
|
|
8226
|
+
visit(child);
|
|
8227
|
+
}
|
|
8228
|
+
};
|
|
8229
|
+
visit(root);
|
|
8230
|
+
return { elements, windows };
|
|
8231
|
+
}
|
|
8232
|
+
async function analyzeIosApp(client, options) {
|
|
8233
|
+
const xml = await client.source();
|
|
8234
|
+
const root = parseIosHierarchy(xml);
|
|
8235
|
+
if (!root) {
|
|
8236
|
+
return { app: options.app, elements: [], windows: [] };
|
|
8237
|
+
}
|
|
8238
|
+
const { elements, windows } = collect(root);
|
|
8239
|
+
return { app: options.app, elements, windows };
|
|
8240
|
+
}
|
|
5361
8241
|
|
|
5362
8242
|
// src/cli/commands/analyze.ts
|
|
5363
8243
|
init_loader();
|
|
@@ -5368,73 +8248,313 @@ function parseViewportFlag2(value) {
|
|
|
5368
8248
|
}
|
|
5369
8249
|
return value;
|
|
5370
8250
|
}
|
|
5371
|
-
function
|
|
5372
|
-
const
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
8251
|
+
function tryLoadConfig(configPath) {
|
|
8252
|
+
const configLocation = configPath !== void 0 ? configPath : findConfigPath(process.cwd());
|
|
8253
|
+
if (configLocation === null) {
|
|
8254
|
+
return null;
|
|
8255
|
+
}
|
|
8256
|
+
try {
|
|
8257
|
+
return loadConfig(configLocation).config;
|
|
8258
|
+
} catch (error) {
|
|
8259
|
+
const message = error instanceof Error ? error.message : "Unknown configuration error";
|
|
8260
|
+
throw new Error(`Failed to load config at ${configLocation}: ${message}`);
|
|
8261
|
+
}
|
|
8262
|
+
}
|
|
8263
|
+
function looksLikeApk2(app) {
|
|
8264
|
+
return /\.apk$/i.test(app.trim());
|
|
8265
|
+
}
|
|
8266
|
+
function pickNativePlatform(platformFlag, config, app) {
|
|
8267
|
+
if (platformFlag !== void 0) {
|
|
8268
|
+
const normalized = platformFlag.trim().toLowerCase();
|
|
8269
|
+
if (normalized === "macos" || normalized === "android" || normalized === "ios") {
|
|
8270
|
+
return normalized;
|
|
8271
|
+
}
|
|
8272
|
+
throw new Error(`Unknown --platform "${platformFlag}". Use one of: macos, android, ios.`);
|
|
8273
|
+
}
|
|
8274
|
+
const configType = config?.target.type;
|
|
8275
|
+
if (configType === "macos" || configType === "android" || configType === "ios") {
|
|
8276
|
+
return configType;
|
|
8277
|
+
}
|
|
8278
|
+
return looksLikeApk2(app) ? "android" : "macos";
|
|
8279
|
+
}
|
|
8280
|
+
async function runWebAnalyze(url, options) {
|
|
8281
|
+
const engine = parseBrowserEngine(options.browser);
|
|
8282
|
+
const channel = options.channel;
|
|
8283
|
+
const viewport = options.viewport ? resolveViewport(parseViewportFlag2(options.viewport)) : { width: 1280, height: 720 };
|
|
8284
|
+
const session = await launchBrowser({
|
|
8285
|
+
headless: !options.headed,
|
|
8286
|
+
slowMo: 0,
|
|
8287
|
+
timeout: 3e4,
|
|
8288
|
+
trace: false,
|
|
8289
|
+
recordHar: false,
|
|
8290
|
+
runDir: process.cwd(),
|
|
8291
|
+
engine,
|
|
8292
|
+
channel,
|
|
8293
|
+
viewport
|
|
8294
|
+
});
|
|
8295
|
+
const driver = createPlaywrightDriver(session.page);
|
|
8296
|
+
try {
|
|
8297
|
+
await driver.goto(url, { waitUntil: "networkidle" });
|
|
8298
|
+
const result = await analyzePage(driver);
|
|
8299
|
+
if (options.json) {
|
|
8300
|
+
console.log(JSON.stringify(result, null, 2));
|
|
8301
|
+
} else {
|
|
8302
|
+
console.log(`
|
|
5396
8303
|
${import_chalk11.default.bold("Page Analysis:")} ${result.title}`);
|
|
5397
|
-
|
|
8304
|
+
console.log(` ${import_chalk11.default.gray("URL:")} ${result.url}
|
|
5398
8305
|
`);
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
|
|
5424
|
-
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
8306
|
+
if (result.forms.length > 0) {
|
|
8307
|
+
console.log(import_chalk11.default.bold(" Forms:"));
|
|
8308
|
+
for (const form of result.forms) {
|
|
8309
|
+
const method = form.method ? import_chalk11.default.cyan(form.method) : "";
|
|
8310
|
+
const action = form.action ? import_chalk11.default.gray(form.action) : "";
|
|
8311
|
+
console.log(` [${form.index}] ${method} ${action} (${form.fieldCount} fields)`);
|
|
8312
|
+
}
|
|
8313
|
+
console.log();
|
|
8314
|
+
}
|
|
8315
|
+
if (result.elements.length > 0) {
|
|
8316
|
+
console.log(import_chalk11.default.bold(" Interactive Elements:"));
|
|
8317
|
+
for (const el of result.elements) {
|
|
8318
|
+
const tag = import_chalk11.default.cyan(el.tag);
|
|
8319
|
+
const type = el.type ? import_chalk11.default.gray(`[${el.type}]`) : "";
|
|
8320
|
+
const bestSelector = el.selectors.testId ?? el.selectors.label ?? el.selectors.ariaLabel ?? el.selectors.css ?? el.selectors.name ?? "";
|
|
8321
|
+
const selectorStr = bestSelector ? import_chalk11.default.yellow(bestSelector) : import_chalk11.default.gray("(no selector)");
|
|
8322
|
+
const req = el.required ? import_chalk11.default.red(" *") : "";
|
|
8323
|
+
const form = el.formGroup !== void 0 ? import_chalk11.default.gray(` form[${el.formGroup}]`) : "";
|
|
8324
|
+
console.log(` ${tag}${type} ${selectorStr}${req}${form}`);
|
|
8325
|
+
}
|
|
8326
|
+
console.log();
|
|
8327
|
+
}
|
|
8328
|
+
if (result.links.length > 0) {
|
|
8329
|
+
console.log(import_chalk11.default.bold(" Links:"));
|
|
8330
|
+
for (const link of result.links.slice(0, 20)) {
|
|
8331
|
+
const text = link.text || import_chalk11.default.gray("(no text)");
|
|
8332
|
+
console.log(` ${text} ${import_chalk11.default.gray("\u2192")} ${import_chalk11.default.blue(link.href)}`);
|
|
8333
|
+
}
|
|
8334
|
+
if (result.links.length > 20) {
|
|
8335
|
+
console.log(import_chalk11.default.gray(` ... and ${result.links.length - 20} more`));
|
|
8336
|
+
}
|
|
8337
|
+
console.log();
|
|
8338
|
+
}
|
|
8339
|
+
console.log(import_chalk11.default.gray(` ${result.elements.length} elements, ${result.forms.length} forms, ${result.links.length} links
|
|
8340
|
+
`));
|
|
8341
|
+
}
|
|
8342
|
+
} finally {
|
|
8343
|
+
await closeBrowser(session);
|
|
8344
|
+
}
|
|
8345
|
+
}
|
|
8346
|
+
function printMacElement(el) {
|
|
8347
|
+
const role = import_chalk11.default.cyan(el.role);
|
|
8348
|
+
const [best] = el.selectors;
|
|
8349
|
+
const selectorStr = best ? import_chalk11.default.yellow(best) : import_chalk11.default.gray("(no selector)");
|
|
8350
|
+
const label = el.title ?? el.description ?? el.value;
|
|
8351
|
+
const labelStr = label ? ` ${import_chalk11.default.gray(`"${label}"`)}` : "";
|
|
8352
|
+
const disabled = el.enabled === false ? import_chalk11.default.gray(" (disabled)") : "";
|
|
8353
|
+
console.log(` ${role} ${selectorStr}${labelStr}${disabled}`);
|
|
8354
|
+
}
|
|
8355
|
+
async function runMacAnalyze(app, config, options) {
|
|
8356
|
+
const allowedApps = config?.guardrails.allowedApps ?? [];
|
|
8357
|
+
assertTargetAppAllowed(allowedApps, app);
|
|
8358
|
+
const session = await launchMacSession({ app, timeoutMs: config?.browser.timeout });
|
|
8359
|
+
try {
|
|
8360
|
+
const result = await analyzeMacApp(session.client, { app: session.bundleId });
|
|
8361
|
+
if (options.json) {
|
|
8362
|
+
console.log(JSON.stringify(result, null, 2));
|
|
8363
|
+
} else {
|
|
8364
|
+
console.log(`
|
|
8365
|
+
${import_chalk11.default.bold("App Analysis:")} ${result.app}
|
|
8366
|
+
`);
|
|
8367
|
+
if (result.windows.length > 0) {
|
|
8368
|
+
console.log(import_chalk11.default.bold(" Windows:"));
|
|
8369
|
+
for (const win of result.windows) {
|
|
8370
|
+
const title = win.title ? import_chalk11.default.gray(`"${win.title}"`) : import_chalk11.default.gray("(untitled)");
|
|
8371
|
+
console.log(` ${title} ${import_chalk11.default.yellow(win.selector)}`);
|
|
8372
|
+
}
|
|
8373
|
+
console.log();
|
|
8374
|
+
}
|
|
8375
|
+
if (result.elements.length > 0) {
|
|
8376
|
+
console.log(import_chalk11.default.bold(" Interactive Elements:"));
|
|
8377
|
+
for (const el of result.elements) {
|
|
8378
|
+
printMacElement(el);
|
|
8379
|
+
}
|
|
8380
|
+
console.log();
|
|
8381
|
+
}
|
|
8382
|
+
if (result.menuItems.length > 0) {
|
|
8383
|
+
console.log(import_chalk11.default.bold(" Menu Bar:"));
|
|
8384
|
+
for (const el of result.menuItems) {
|
|
8385
|
+
printMacElement(el);
|
|
8386
|
+
}
|
|
8387
|
+
console.log();
|
|
8388
|
+
}
|
|
8389
|
+
console.log(
|
|
8390
|
+
import_chalk11.default.gray(
|
|
8391
|
+
` ${result.elements.length} elements, ${result.windows.length} windows, ${result.menuItems.length} menu items
|
|
8392
|
+
`
|
|
8393
|
+
)
|
|
8394
|
+
);
|
|
8395
|
+
}
|
|
8396
|
+
} finally {
|
|
8397
|
+
await session.client.close();
|
|
8398
|
+
}
|
|
8399
|
+
}
|
|
8400
|
+
function printAndroidElement(el) {
|
|
8401
|
+
const role = import_chalk11.default.cyan(el.className);
|
|
8402
|
+
const [best] = el.selectors;
|
|
8403
|
+
const selectorStr = best ? import_chalk11.default.yellow(best) : import_chalk11.default.gray("(no selector)");
|
|
8404
|
+
const label = el.contentDesc ?? el.text;
|
|
8405
|
+
const labelStr = label ? ` ${import_chalk11.default.gray(`"${label}"`)}` : "";
|
|
8406
|
+
const disabled = el.enabled === false ? import_chalk11.default.gray(" (disabled)") : "";
|
|
8407
|
+
console.log(` ${role} ${selectorStr}${labelStr}${disabled}`);
|
|
8408
|
+
}
|
|
8409
|
+
async function runAndroidAnalyze(app, config, options, deviceSerial) {
|
|
8410
|
+
const allowedApps = config?.guardrails.allowedApps ?? [];
|
|
8411
|
+
if (!looksLikeApk2(app)) {
|
|
8412
|
+
assertAndroidAppAllowed(allowedApps, app);
|
|
8413
|
+
}
|
|
8414
|
+
const session = await launchAndroidSession({
|
|
8415
|
+
app,
|
|
8416
|
+
...deviceSerial ? { deviceSerial } : {},
|
|
8417
|
+
timeoutMs: config?.browser.timeout,
|
|
8418
|
+
allowedApps
|
|
8419
|
+
});
|
|
8420
|
+
try {
|
|
8421
|
+
const sourceFn = session.client.source;
|
|
8422
|
+
if (typeof sourceFn !== "function") {
|
|
8423
|
+
throw new Error(
|
|
8424
|
+
"The Android agent client does not expose source(); a live uiautomator2 session is required to analyze."
|
|
8425
|
+
);
|
|
8426
|
+
}
|
|
8427
|
+
const result = await analyzeAndroidApp(
|
|
8428
|
+
{ source: () => sourceFn.call(session.client) },
|
|
8429
|
+
{ app: session.package }
|
|
8430
|
+
);
|
|
8431
|
+
if (options.json) {
|
|
8432
|
+
console.log(JSON.stringify(result, null, 2));
|
|
8433
|
+
} else {
|
|
8434
|
+
console.log(`
|
|
8435
|
+
${import_chalk11.default.bold("App Analysis:")} ${result.app}
|
|
8436
|
+
`);
|
|
8437
|
+
if (result.elements.length > 0) {
|
|
8438
|
+
console.log(import_chalk11.default.bold(" Interactive Elements:"));
|
|
8439
|
+
for (const el of result.elements) {
|
|
8440
|
+
printAndroidElement(el);
|
|
8441
|
+
}
|
|
8442
|
+
console.log();
|
|
8443
|
+
}
|
|
8444
|
+
console.log(import_chalk11.default.gray(` ${result.elements.length} elements
|
|
5433
8445
|
`));
|
|
8446
|
+
}
|
|
8447
|
+
} finally {
|
|
8448
|
+
await session.teardown();
|
|
8449
|
+
}
|
|
8450
|
+
}
|
|
8451
|
+
function printIosElement(el) {
|
|
8452
|
+
const role = import_chalk11.default.cyan(el.type);
|
|
8453
|
+
const [best] = el.selectors;
|
|
8454
|
+
const selectorStr = best ? import_chalk11.default.yellow(best) : import_chalk11.default.gray("(no selector)");
|
|
8455
|
+
const label = el.label ?? el.value ?? el.name;
|
|
8456
|
+
const labelStr = label ? ` ${import_chalk11.default.gray(`"${label}"`)}` : "";
|
|
8457
|
+
const disabled = el.enabled === false ? import_chalk11.default.gray(" (disabled)") : "";
|
|
8458
|
+
console.log(` ${role} ${selectorStr}${labelStr}${disabled}`);
|
|
8459
|
+
}
|
|
8460
|
+
async function runIosAnalyze(app, config, options, udid) {
|
|
8461
|
+
const allowedApps = config?.guardrails.allowedApps ?? [];
|
|
8462
|
+
if (!/\//.test(app) && !/\.app$/i.test(app.trim())) {
|
|
8463
|
+
assertIosAppAllowed(allowedApps, app);
|
|
8464
|
+
}
|
|
8465
|
+
const session = await launchIosSession({
|
|
8466
|
+
app,
|
|
8467
|
+
...udid ? { udid } : {},
|
|
8468
|
+
timeoutMs: config?.browser.timeout,
|
|
8469
|
+
allowedApps
|
|
8470
|
+
});
|
|
8471
|
+
try {
|
|
8472
|
+
const sourceFn = session.client.source;
|
|
8473
|
+
if (typeof sourceFn !== "function") {
|
|
8474
|
+
throw new Error(
|
|
8475
|
+
"The iOS agent client does not expose source(); a live WebDriverAgent session is required to analyze."
|
|
8476
|
+
);
|
|
8477
|
+
}
|
|
8478
|
+
const result = await analyzeIosApp(
|
|
8479
|
+
{ source: () => sourceFn.call(session.client) },
|
|
8480
|
+
{ app: session.bundleId }
|
|
8481
|
+
);
|
|
8482
|
+
if (options.json) {
|
|
8483
|
+
console.log(JSON.stringify(result, null, 2));
|
|
8484
|
+
} else {
|
|
8485
|
+
console.log(`
|
|
8486
|
+
${import_chalk11.default.bold("App Analysis:")} ${result.app}
|
|
8487
|
+
`);
|
|
8488
|
+
if (result.windows.length > 0) {
|
|
8489
|
+
console.log(import_chalk11.default.bold(" Windows:"));
|
|
8490
|
+
for (const win of result.windows) {
|
|
8491
|
+
const title = win.label ?? win.name;
|
|
8492
|
+
const titleStr = title ? import_chalk11.default.gray(`"${title}"`) : import_chalk11.default.gray("(untitled)");
|
|
8493
|
+
console.log(` ${titleStr} ${import_chalk11.default.yellow(win.selector)}`);
|
|
5434
8494
|
}
|
|
5435
|
-
|
|
5436
|
-
|
|
8495
|
+
console.log();
|
|
8496
|
+
}
|
|
8497
|
+
if (result.elements.length > 0) {
|
|
8498
|
+
console.log(import_chalk11.default.bold(" Interactive Elements:"));
|
|
8499
|
+
for (const el of result.elements) {
|
|
8500
|
+
printIosElement(el);
|
|
8501
|
+
}
|
|
8502
|
+
console.log();
|
|
8503
|
+
}
|
|
8504
|
+
console.log(import_chalk11.default.gray(` ${result.elements.length} elements, ${result.windows.length} windows
|
|
8505
|
+
`));
|
|
8506
|
+
}
|
|
8507
|
+
} finally {
|
|
8508
|
+
await session.teardown();
|
|
8509
|
+
}
|
|
8510
|
+
}
|
|
8511
|
+
async function runNativeAnalyze(platform, app, config, options, device) {
|
|
8512
|
+
if (platform === "android") {
|
|
8513
|
+
await runAndroidAnalyze(app, config, options, device.deviceSerial);
|
|
8514
|
+
return;
|
|
8515
|
+
}
|
|
8516
|
+
if (platform === "ios") {
|
|
8517
|
+
await runIosAnalyze(app, config, options, device.udid);
|
|
8518
|
+
return;
|
|
8519
|
+
}
|
|
8520
|
+
await runMacAnalyze(app, config, options);
|
|
8521
|
+
}
|
|
8522
|
+
function buildAnalyzeCommand() {
|
|
8523
|
+
const command = new import_commander8.Command("analyze").argument("[url]", "URL to analyze (web target)").description("Analyze a page or native app to discover interactive elements and selectors").option("--json", "Output as JSON").option("--app <app>", "Native app to analyze: bundle id / package / .app / .apk (macOS, Android, or iOS)").option("--platform <name>", "Native platform for --app: macos, android, or ios (else inferred)").option("--device <serial>", "Android adb device serial (native Android target)").option("--udid <udid>", "iOS simulator UDID (native iOS target)").option("--browser <engine>", "Browser engine: chromium, firefox, or webkit").option("--channel <name>", "Browser channel: chrome, msedge, etc.").option("--viewport <size>", "Viewport size: WxH or preset (mobile, tablet, desktop)").option("--headed", "Show browser window").option("--config <path>", "Custom config path").action(async (url, options) => {
|
|
8524
|
+
try {
|
|
8525
|
+
if (options.app && url) {
|
|
8526
|
+
throw new Error("Pass either a URL argument (web) or --app (native), not both.");
|
|
8527
|
+
}
|
|
8528
|
+
if (options.app) {
|
|
8529
|
+
const config2 = tryLoadConfig(options.config);
|
|
8530
|
+
const platform = pickNativePlatform(options.platform, config2, options.app);
|
|
8531
|
+
await runNativeAnalyze(platform, options.app, config2, options, {
|
|
8532
|
+
deviceSerial: options.device,
|
|
8533
|
+
udid: options.udid
|
|
8534
|
+
});
|
|
8535
|
+
return;
|
|
8536
|
+
}
|
|
8537
|
+
if (url) {
|
|
8538
|
+
await runWebAnalyze(url, options);
|
|
8539
|
+
return;
|
|
8540
|
+
}
|
|
8541
|
+
const config = tryLoadConfig(options.config);
|
|
8542
|
+
const target = config?.target;
|
|
8543
|
+
if (target?.type === "macos") {
|
|
8544
|
+
await runMacAnalyze(target.app, config, options);
|
|
8545
|
+
return;
|
|
5437
8546
|
}
|
|
8547
|
+
if (target?.type === "android") {
|
|
8548
|
+
await runAndroidAnalyze(target.app, config, options, options.device ?? target.deviceSerial);
|
|
8549
|
+
return;
|
|
8550
|
+
}
|
|
8551
|
+
if (target?.type === "ios") {
|
|
8552
|
+
await runIosAnalyze(target.app, config, options, options.udid ?? target.udid);
|
|
8553
|
+
return;
|
|
8554
|
+
}
|
|
8555
|
+
throw new Error(
|
|
8556
|
+
"analyze needs a target: pass a URL for the web target, or use --app <bundle-id|package|.app|.apk> (optionally with --platform), or set a native target.type in .prowl/config.yml."
|
|
8557
|
+
);
|
|
5438
8558
|
} catch (error) {
|
|
5439
8559
|
const message = error instanceof Error ? error.message : "Analysis failed";
|
|
5440
8560
|
if (options.json) {
|
|
@@ -5451,8 +8571,8 @@ function buildAnalyzeCommand() {
|
|
|
5451
8571
|
}
|
|
5452
8572
|
|
|
5453
8573
|
// src/cli/commands/generate.ts
|
|
5454
|
-
var
|
|
5455
|
-
var
|
|
8574
|
+
var import_node_fs20 = __toESM(require("fs"), 1);
|
|
8575
|
+
var import_node_path22 = __toESM(require("path"), 1);
|
|
5456
8576
|
var import_commander9 = require("commander");
|
|
5457
8577
|
var import_chalk12 = __toESM(require("chalk"), 1);
|
|
5458
8578
|
var import_ora = __toESM(require("ora"), 1);
|
|
@@ -5503,6 +8623,7 @@ var STEP_REFERENCE = `
|
|
|
5503
8623
|
|
|
5504
8624
|
### Visual Regression
|
|
5505
8625
|
- assertScreenshot: { name: "baseline-name", threshold: 0.1 }
|
|
8626
|
+
- assertWithAI: "The login form should show email and password fields" \u2014 AI checks the screenshot against the claim
|
|
5506
8627
|
|
|
5507
8628
|
### Control Flow
|
|
5508
8629
|
- if: { visible: ".banner", then: [steps...] }
|
|
@@ -5544,81 +8665,6 @@ function extractYamlFromResponse(response) {
|
|
|
5544
8665
|
return response.trim();
|
|
5545
8666
|
}
|
|
5546
8667
|
|
|
5547
|
-
// src/generator/ai.ts
|
|
5548
|
-
function resolveAiConfig() {
|
|
5549
|
-
const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
|
|
5550
|
-
if (provider !== "anthropic" && provider !== "openai") {
|
|
5551
|
-
throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
|
|
5552
|
-
}
|
|
5553
|
-
const apiKey = process.env.PROWL_AI_KEY;
|
|
5554
|
-
if (!apiKey) {
|
|
5555
|
-
throw new Error(
|
|
5556
|
-
"PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
|
|
5557
|
-
);
|
|
5558
|
-
}
|
|
5559
|
-
const defaultModel = provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
|
|
5560
|
-
const model = process.env.PROWL_AI_MODEL ?? defaultModel;
|
|
5561
|
-
return { provider, model, apiKey };
|
|
5562
|
-
}
|
|
5563
|
-
async function generateWithAi(prompt, config) {
|
|
5564
|
-
if (config.provider === "anthropic") {
|
|
5565
|
-
return generateWithAnthropic(prompt, config);
|
|
5566
|
-
}
|
|
5567
|
-
return generateWithOpenAi(prompt, config);
|
|
5568
|
-
}
|
|
5569
|
-
async function generateWithAnthropic(prompt, config) {
|
|
5570
|
-
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
5571
|
-
method: "POST",
|
|
5572
|
-
headers: {
|
|
5573
|
-
"Content-Type": "application/json",
|
|
5574
|
-
"x-api-key": config.apiKey,
|
|
5575
|
-
"anthropic-version": "2023-06-01"
|
|
5576
|
-
},
|
|
5577
|
-
body: JSON.stringify({
|
|
5578
|
-
model: config.model,
|
|
5579
|
-
max_tokens: 4096,
|
|
5580
|
-
messages: [
|
|
5581
|
-
{ role: "user", content: prompt }
|
|
5582
|
-
]
|
|
5583
|
-
})
|
|
5584
|
-
});
|
|
5585
|
-
if (!response.ok) {
|
|
5586
|
-
const body = await response.text();
|
|
5587
|
-
throw new Error(`Anthropic API error (${response.status}): ${body}`);
|
|
5588
|
-
}
|
|
5589
|
-
const data = await response.json();
|
|
5590
|
-
const textBlock = data.content.find((c) => c.type === "text");
|
|
5591
|
-
if (!textBlock?.text) {
|
|
5592
|
-
throw new Error("Anthropic API returned no text content");
|
|
5593
|
-
}
|
|
5594
|
-
return textBlock.text;
|
|
5595
|
-
}
|
|
5596
|
-
async function generateWithOpenAi(prompt, config) {
|
|
5597
|
-
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
5598
|
-
method: "POST",
|
|
5599
|
-
headers: {
|
|
5600
|
-
"Content-Type": "application/json",
|
|
5601
|
-
"Authorization": `Bearer ${config.apiKey}`
|
|
5602
|
-
},
|
|
5603
|
-
body: JSON.stringify({
|
|
5604
|
-
model: config.model,
|
|
5605
|
-
messages: [
|
|
5606
|
-
{ role: "user", content: prompt }
|
|
5607
|
-
],
|
|
5608
|
-
max_tokens: 4096
|
|
5609
|
-
})
|
|
5610
|
-
});
|
|
5611
|
-
if (!response.ok) {
|
|
5612
|
-
const body = await response.text();
|
|
5613
|
-
throw new Error(`OpenAI API error (${response.status}): ${body}`);
|
|
5614
|
-
}
|
|
5615
|
-
const data = await response.json();
|
|
5616
|
-
if (!data.choices?.[0]?.message?.content) {
|
|
5617
|
-
throw new Error("OpenAI API returned no content");
|
|
5618
|
-
}
|
|
5619
|
-
return data.choices[0].message.content;
|
|
5620
|
-
}
|
|
5621
|
-
|
|
5622
8668
|
// src/generator/index.ts
|
|
5623
8669
|
init_loader();
|
|
5624
8670
|
init_schema();
|
|
@@ -5721,13 +8767,13 @@ function buildGenerateCommand() {
|
|
|
5721
8767
|
const result = loadConfig2(options.config);
|
|
5722
8768
|
configDir = result.configDir;
|
|
5723
8769
|
} catch {
|
|
5724
|
-
configDir =
|
|
8770
|
+
configDir = import_node_path22.default.join(process.cwd(), ".prowl");
|
|
5725
8771
|
}
|
|
5726
|
-
const huntsDir =
|
|
5727
|
-
|
|
8772
|
+
const huntsDir = import_node_path22.default.join(configDir, "hunts");
|
|
8773
|
+
import_node_fs20.default.mkdirSync(huntsDir, { recursive: true });
|
|
5728
8774
|
const fileName = options.output.endsWith(".yml") ? options.output : `${options.output}.yml`;
|
|
5729
|
-
const filePath =
|
|
5730
|
-
|
|
8775
|
+
const filePath = import_node_path22.default.join(huntsDir, fileName);
|
|
8776
|
+
import_node_fs20.default.writeFileSync(filePath, yamlStr + "\n", "utf-8");
|
|
5731
8777
|
console.log(import_chalk12.default.green(` Saved to ${filePath}`));
|
|
5732
8778
|
} else {
|
|
5733
8779
|
console.log(yamlStr);
|
|
@@ -5924,7 +8970,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
|
5924
8970
|
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
5925
8971
|
|
|
5926
8972
|
// src/mcp/tools.ts
|
|
5927
|
-
var
|
|
8973
|
+
var import_node_path23 = __toESM(require("path"), 1);
|
|
5928
8974
|
init_loader();
|
|
5929
8975
|
function listHuntsTool(configPath) {
|
|
5930
8976
|
const { configDir } = loadConfig(configPath);
|
|
@@ -5932,7 +8978,7 @@ function listHuntsTool(configPath) {
|
|
|
5932
8978
|
}
|
|
5933
8979
|
async function runSuiteTool(args = {}, options = {}) {
|
|
5934
8980
|
const { configPath: resolvedConfigPath, configDir, config } = loadConfig(options.configPath);
|
|
5935
|
-
const projectRoot = options.projectRoot ??
|
|
8981
|
+
const projectRoot = options.projectRoot ?? import_node_path23.default.dirname(configDir);
|
|
5936
8982
|
const suite = await runSuite({
|
|
5937
8983
|
configPath: resolvedConfigPath,
|
|
5938
8984
|
includeTags: args.includeTags,
|
|
@@ -5941,8 +8987,8 @@ async function runSuiteTool(args = {}, options = {}) {
|
|
|
5941
8987
|
});
|
|
5942
8988
|
const bugLogCfg = config.bugLog ?? {};
|
|
5943
8989
|
const logBugs = args.logBugs ?? bugLogCfg.enabled ?? true;
|
|
5944
|
-
const backlogPath = bugLogCfg.backlogPath ?
|
|
5945
|
-
const resolvedPath = bugLogCfg.resolvedPath ?
|
|
8990
|
+
const backlogPath = bugLogCfg.backlogPath ? import_node_path23.default.resolve(projectRoot, bugLogCfg.backlogPath) : void 0;
|
|
8991
|
+
const resolvedPath = bugLogCfg.resolvedPath ? import_node_path23.default.resolve(projectRoot, bugLogCfg.resolvedPath) : backlogPath ? import_node_path23.default.join(import_node_path23.default.dirname(backlogPath), "resolved.md") : void 0;
|
|
5946
8992
|
const bugs = logBugs ? updateBacklogFromSuite(suite, { projectRoot, backlogPath, resolvedPath }) : { created: [], regressions: [], skipped: [], backlogPath: null };
|
|
5947
8993
|
const { status, totalHunts, passed, failed, skipped } = suite.result;
|
|
5948
8994
|
return {
|
|
@@ -5969,9 +9015,9 @@ async function runHuntTool(args, configPath) {
|
|
|
5969
9015
|
}
|
|
5970
9016
|
|
|
5971
9017
|
// src/mcp/projects.ts
|
|
5972
|
-
var
|
|
5973
|
-
var
|
|
5974
|
-
var
|
|
9018
|
+
var import_node_fs21 = __toESM(require("fs"), 1);
|
|
9019
|
+
var import_node_os3 = __toESM(require("os"), 1);
|
|
9020
|
+
var import_node_path24 = __toESM(require("path"), 1);
|
|
5975
9021
|
var import_yaml3 = __toESM(require("yaml"), 1);
|
|
5976
9022
|
var import_zod2 = require("zod");
|
|
5977
9023
|
var projectEntrySchema = import_zod2.z.object({
|
|
@@ -5983,37 +9029,37 @@ var projectRegistrySchema = import_zod2.z.object({
|
|
|
5983
9029
|
projects: import_zod2.z.record(import_zod2.z.string().min(1), projectEntrySchema)
|
|
5984
9030
|
}).strict();
|
|
5985
9031
|
function defaultRegistryPath() {
|
|
5986
|
-
return
|
|
9032
|
+
return import_node_path24.default.join(import_node_os3.default.homedir(), ".prowl", "projects.yml");
|
|
5987
9033
|
}
|
|
5988
9034
|
function legacyRegistryPath() {
|
|
5989
|
-
return
|
|
9035
|
+
return import_node_path24.default.join(import_node_os3.default.homedir(), ".prowlqa", "projects.yml");
|
|
5990
9036
|
}
|
|
5991
9037
|
function resolveProjectConfigPath(root) {
|
|
5992
|
-
const preferred =
|
|
5993
|
-
if (
|
|
5994
|
-
const legacy =
|
|
5995
|
-
if (
|
|
9038
|
+
const preferred = import_node_path24.default.join(root, ".prowl", "config.yml");
|
|
9039
|
+
if (import_node_fs21.default.existsSync(preferred)) return preferred;
|
|
9040
|
+
const legacy = import_node_path24.default.join(root, ".prowlqa", "config.yml");
|
|
9041
|
+
if (import_node_fs21.default.existsSync(legacy)) return legacy;
|
|
5996
9042
|
return preferred;
|
|
5997
9043
|
}
|
|
5998
9044
|
function resolveRegistryRelativePath(registry, inputPath) {
|
|
5999
|
-
return
|
|
9045
|
+
return import_node_path24.default.isAbsolute(inputPath) ? inputPath : import_node_path24.default.resolve(import_node_path24.default.dirname(registry.registryPath), inputPath);
|
|
6000
9046
|
}
|
|
6001
9047
|
function resolveRegistryPath(explicitPath) {
|
|
6002
|
-
if (explicitPath) return
|
|
9048
|
+
if (explicitPath) return import_node_path24.default.resolve(explicitPath);
|
|
6003
9049
|
const envPath = process.env.PROWL_PROJECTS ?? process.env.PROWLQA_PROJECTS;
|
|
6004
|
-
if (envPath) return
|
|
9050
|
+
if (envPath) return import_node_path24.default.resolve(envPath);
|
|
6005
9051
|
const fallback = defaultRegistryPath();
|
|
6006
|
-
if (
|
|
9052
|
+
if (import_node_fs21.default.existsSync(fallback)) return fallback;
|
|
6007
9053
|
const legacy = legacyRegistryPath();
|
|
6008
|
-
return
|
|
9054
|
+
return import_node_fs21.default.existsSync(legacy) ? legacy : null;
|
|
6009
9055
|
}
|
|
6010
9056
|
function loadProjectRegistry(explicitPath) {
|
|
6011
9057
|
const registryPath = resolveRegistryPath(explicitPath);
|
|
6012
9058
|
if (!registryPath) return null;
|
|
6013
|
-
if (!
|
|
9059
|
+
if (!import_node_fs21.default.existsSync(registryPath)) {
|
|
6014
9060
|
throw new Error(`Project registry not found at ${registryPath}`);
|
|
6015
9061
|
}
|
|
6016
|
-
const raw =
|
|
9062
|
+
const raw = import_node_fs21.default.readFileSync(registryPath, "utf-8");
|
|
6017
9063
|
const parsed = import_yaml3.default.parse(raw) ?? {};
|
|
6018
9064
|
const validated = projectRegistrySchema.parse(parsed);
|
|
6019
9065
|
return { projects: validated.projects, registryPath };
|