prowl-tools 0.1.4 → 0.1.5
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 +277 -2
- package/dist/{chunk-ZEFVTKQT.js → chunk-2KD2XCTH.js} +2240 -213
- package/dist/chunk-2KD2XCTH.js.map +1 -0
- package/dist/{chunk-MBAIGNVO.js → chunk-ITOSUJCN.js} +39 -3
- package/dist/chunk-ITOSUJCN.js.map +1 -0
- package/dist/index.cjs +2411 -313
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +162 -67
- package/dist/index.js.map +1 -1
- package/dist/lib.cjs +2375 -244
- package/dist/lib.cjs.map +1 -1
- package/dist/lib.d.cts +742 -4
- package/dist/lib.d.ts +742 -4
- package/dist/lib.js +126 -2
- package/dist/{loader-XMTDB6OL.js → loader-FCXPARP7.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-FCXPARP7.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, 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({
|
|
@@ -417,9 +434,28 @@ function resolveViewport(value) {
|
|
|
417
434
|
return value;
|
|
418
435
|
}
|
|
419
436
|
function resolveTarget(target) {
|
|
420
|
-
|
|
437
|
+
const type = target?.type;
|
|
438
|
+
if (type === "macos") {
|
|
421
439
|
return { type: "macos", app: target.app };
|
|
422
440
|
}
|
|
441
|
+
if (type === "android") {
|
|
442
|
+
const androidTarget = target;
|
|
443
|
+
return {
|
|
444
|
+
type: "android",
|
|
445
|
+
app: androidTarget.app,
|
|
446
|
+
...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
|
|
447
|
+
...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
if (type === "ios") {
|
|
451
|
+
const iosTarget = target;
|
|
452
|
+
return {
|
|
453
|
+
type: "ios",
|
|
454
|
+
app: iosTarget.app,
|
|
455
|
+
...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
|
|
456
|
+
...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
|
|
457
|
+
};
|
|
458
|
+
}
|
|
423
459
|
return {
|
|
424
460
|
type: "web",
|
|
425
461
|
url: target?.url ?? DEFAULT_WEB_URL
|
|
@@ -628,12 +664,12 @@ __export(visual_exports, {
|
|
|
628
664
|
ensureBaselineDir: () => ensureBaselineDir
|
|
629
665
|
});
|
|
630
666
|
async function compareScreenshots(baselinePath, currentPath, diffPath, threshold) {
|
|
631
|
-
const baselineData = import_pngjs.PNG.sync.read(
|
|
632
|
-
const currentData = import_pngjs.PNG.sync.read(
|
|
667
|
+
const baselineData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(baselinePath));
|
|
668
|
+
const currentData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(currentPath));
|
|
633
669
|
const { width, height } = baselineData;
|
|
634
670
|
if (currentData.width !== width || currentData.height !== height) {
|
|
635
671
|
const diff2 = new import_pngjs.PNG({ width, height });
|
|
636
|
-
|
|
672
|
+
import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
|
|
637
673
|
return {
|
|
638
674
|
match: false,
|
|
639
675
|
diffPercentage: 1,
|
|
@@ -651,7 +687,7 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
|
|
|
651
687
|
);
|
|
652
688
|
const totalPixels = width * height;
|
|
653
689
|
const diffPercentage = diffPixels / totalPixels;
|
|
654
|
-
|
|
690
|
+
import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
|
|
655
691
|
return {
|
|
656
692
|
match: diffPercentage <= threshold,
|
|
657
693
|
diffPercentage,
|
|
@@ -659,16 +695,16 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
|
|
|
659
695
|
};
|
|
660
696
|
}
|
|
661
697
|
function ensureBaselineDir(configDir) {
|
|
662
|
-
const baselineDir =
|
|
663
|
-
|
|
698
|
+
const baselineDir = import_node_path8.default.join(configDir, "baselines");
|
|
699
|
+
import_node_fs8.default.mkdirSync(baselineDir, { recursive: true });
|
|
664
700
|
return baselineDir;
|
|
665
701
|
}
|
|
666
|
-
var
|
|
702
|
+
var import_node_fs8, import_node_path8, import_pngjs, import_pixelmatch;
|
|
667
703
|
var init_visual = __esm({
|
|
668
704
|
"src/runner/visual.ts"() {
|
|
669
705
|
"use strict";
|
|
670
|
-
|
|
671
|
-
|
|
706
|
+
import_node_fs8 = __toESM(require("fs"), 1);
|
|
707
|
+
import_node_path8 = __toESM(require("path"), 1);
|
|
672
708
|
import_pngjs = require("pngjs");
|
|
673
709
|
import_pixelmatch = __toESM(require("pixelmatch"), 1);
|
|
674
710
|
}
|
|
@@ -680,7 +716,7 @@ var import_commander13 = require("commander");
|
|
|
680
716
|
// package.json
|
|
681
717
|
var package_default = {
|
|
682
718
|
name: "prowl-tools",
|
|
683
|
-
version: "0.1.
|
|
719
|
+
version: "0.1.5",
|
|
684
720
|
description: "CLI-first QA testing tool for deterministic Playwright flows.",
|
|
685
721
|
type: "module",
|
|
686
722
|
license: "Apache-2.0",
|
|
@@ -750,6 +786,10 @@ var package_default = {
|
|
|
750
786
|
yaml: "^2.6.1",
|
|
751
787
|
zod: "^3.23.8"
|
|
752
788
|
},
|
|
789
|
+
optionalDependencies: {
|
|
790
|
+
"appium-uiautomator2-server": "10.6.2",
|
|
791
|
+
"appium-webdriveragent": "16.4.0"
|
|
792
|
+
},
|
|
753
793
|
devDependencies: {
|
|
754
794
|
"@types/node": "^22.13.1",
|
|
755
795
|
"@types/pngjs": "^6.0.5",
|
|
@@ -767,8 +807,8 @@ var import_commander = require("commander");
|
|
|
767
807
|
var import_chalk3 = __toESM(require("chalk"), 1);
|
|
768
808
|
|
|
769
809
|
// src/runner/index.ts
|
|
770
|
-
var
|
|
771
|
-
var
|
|
810
|
+
var import_node_fs14 = __toESM(require("fs"), 1);
|
|
811
|
+
var import_node_path14 = __toESM(require("path"), 1);
|
|
772
812
|
init_loader();
|
|
773
813
|
|
|
774
814
|
// src/config/interpolate.ts
|
|
@@ -1171,15 +1211,25 @@ function webOnlyReason(step) {
|
|
|
1171
1211
|
}
|
|
1172
1212
|
return null;
|
|
1173
1213
|
}
|
|
1214
|
+
function nativeTargetLabel(target) {
|
|
1215
|
+
if (target === "android") {
|
|
1216
|
+
return "Android";
|
|
1217
|
+
}
|
|
1218
|
+
if (target === "ios") {
|
|
1219
|
+
return "iOS";
|
|
1220
|
+
}
|
|
1221
|
+
return "macOS";
|
|
1222
|
+
}
|
|
1174
1223
|
function assertStepsSupportedByTarget(steps, target) {
|
|
1175
|
-
if (target
|
|
1224
|
+
if (target === "web") {
|
|
1176
1225
|
return;
|
|
1177
1226
|
}
|
|
1227
|
+
const label = nativeTargetLabel(target);
|
|
1178
1228
|
for (const step of steps) {
|
|
1179
1229
|
const reason = webOnlyReason(step);
|
|
1180
1230
|
if (reason) {
|
|
1181
1231
|
throw new Error(
|
|
1182
|
-
`Step "${reason}" is not supported by the
|
|
1232
|
+
`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
1233
|
);
|
|
1184
1234
|
}
|
|
1185
1235
|
if ("if" in step) {
|
|
@@ -1194,20 +1244,34 @@ function assertStepsSupportedByTarget(steps, target) {
|
|
|
1194
1244
|
}
|
|
1195
1245
|
}
|
|
1196
1246
|
function assertHuntAssertionsSupportedByTarget(assertions, target) {
|
|
1197
|
-
if (target
|
|
1247
|
+
if (target === "web" || !assertions || assertions.length === 0) {
|
|
1198
1248
|
return;
|
|
1199
1249
|
}
|
|
1200
1250
|
throw new Error(
|
|
1201
|
-
|
|
1251
|
+
`Hunt-level assertions are not supported by the ${nativeTargetLabel(target)} target. Use inline assert visible/notVisible steps instead.`
|
|
1202
1252
|
);
|
|
1203
1253
|
}
|
|
1204
1254
|
function trimTrailingPathSeparators(value) {
|
|
1205
1255
|
return value.replace(/[\\/]+$/g, "");
|
|
1206
1256
|
}
|
|
1207
|
-
function
|
|
1257
|
+
function looksLikeAppBundlePath(app) {
|
|
1208
1258
|
const trimmed = trimTrailingPathSeparators(app);
|
|
1209
1259
|
return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
|
|
1210
1260
|
}
|
|
1261
|
+
function looksLikeIosAppPath(app) {
|
|
1262
|
+
const trimmed = trimTrailingPathSeparators(app);
|
|
1263
|
+
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
|
1264
|
+
return true;
|
|
1265
|
+
}
|
|
1266
|
+
if (trimmed.toLowerCase().endsWith(".app")) {
|
|
1267
|
+
try {
|
|
1268
|
+
return import_node_fs2.default.statSync(normalizeAppPath(app)).isDirectory();
|
|
1269
|
+
} catch {
|
|
1270
|
+
return false;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return false;
|
|
1274
|
+
}
|
|
1211
1275
|
function normalizeAppPath(app) {
|
|
1212
1276
|
return import_node_path2.default.resolve(trimTrailingPathSeparators(app));
|
|
1213
1277
|
}
|
|
@@ -1215,8 +1279,8 @@ function parseBundleIdentifier(plist) {
|
|
|
1215
1279
|
const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
|
|
1216
1280
|
return match?.[1]?.trim() || null;
|
|
1217
1281
|
}
|
|
1218
|
-
function readBundleIdentifier(appPath) {
|
|
1219
|
-
const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath),
|
|
1282
|
+
function readBundleIdentifier(appPath, ...plistSubPath) {
|
|
1283
|
+
const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), ...plistSubPath);
|
|
1220
1284
|
if (!import_node_fs2.default.existsSync(infoPlistPath)) {
|
|
1221
1285
|
return null;
|
|
1222
1286
|
}
|
|
@@ -1241,9 +1305,15 @@ function readBundleIdentifier(appPath) {
|
|
|
1241
1305
|
return null;
|
|
1242
1306
|
}
|
|
1243
1307
|
}
|
|
1244
|
-
function
|
|
1308
|
+
function readMacosBundleIdentifier(appPath) {
|
|
1309
|
+
return readBundleIdentifier(appPath, "Contents", "Info.plist");
|
|
1310
|
+
}
|
|
1311
|
+
function readIosBundleIdentifier(appPath) {
|
|
1312
|
+
return readBundleIdentifier(appPath, "Info.plist");
|
|
1313
|
+
}
|
|
1314
|
+
function appBundleAllowedIdentities(app, readBundleId, isAppPath = looksLikeAppBundlePath) {
|
|
1245
1315
|
const identities = /* @__PURE__ */ new Set([app]);
|
|
1246
|
-
if (
|
|
1316
|
+
if (isAppPath(app)) {
|
|
1247
1317
|
const normalizedPath = normalizeAppPath(app);
|
|
1248
1318
|
identities.add(trimTrailingPathSeparators(app));
|
|
1249
1319
|
identities.add(normalizedPath);
|
|
@@ -1251,19 +1321,56 @@ function macosAppAllowedIdentities(app) {
|
|
|
1251
1321
|
if (bundleName) {
|
|
1252
1322
|
identities.add(bundleName);
|
|
1253
1323
|
}
|
|
1254
|
-
const bundleId =
|
|
1324
|
+
const bundleId = readBundleId(app);
|
|
1255
1325
|
if (bundleId) {
|
|
1256
1326
|
identities.add(bundleId);
|
|
1257
1327
|
}
|
|
1258
1328
|
}
|
|
1259
1329
|
return [...identities];
|
|
1260
1330
|
}
|
|
1331
|
+
function macosAppAllowedIdentities(app) {
|
|
1332
|
+
return appBundleAllowedIdentities(app, readMacosBundleIdentifier);
|
|
1333
|
+
}
|
|
1334
|
+
function iosAppAllowedIdentities(app) {
|
|
1335
|
+
return appBundleAllowedIdentities(app, readIosBundleIdentifier, looksLikeIosAppPath);
|
|
1336
|
+
}
|
|
1261
1337
|
function assertTargetAppAllowed(allowedApps, app) {
|
|
1338
|
+
assertNativeAppAllowed(allowedApps, app, macosAppAllowedIdentities);
|
|
1339
|
+
}
|
|
1340
|
+
function assertIosAppAllowed(allowedApps, app) {
|
|
1341
|
+
assertNativeAppAllowed(allowedApps, app, iosAppAllowedIdentities);
|
|
1342
|
+
}
|
|
1343
|
+
function looksLikeApkPath(app) {
|
|
1344
|
+
const trimmed = trimTrailingPathSeparators(app);
|
|
1345
|
+
return trimmed.includes("/") || trimmed.includes("\\") || trimmed.toLowerCase().endsWith(".apk");
|
|
1346
|
+
}
|
|
1347
|
+
function normalizeAndroidApkPath(app) {
|
|
1348
|
+
const resolved = import_node_path2.default.resolve(trimTrailingPathSeparators(app));
|
|
1349
|
+
try {
|
|
1350
|
+
return import_node_fs2.default.realpathSync.native(resolved);
|
|
1351
|
+
} catch {
|
|
1352
|
+
return resolved;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
function androidAppAllowedIdentities(app, resolvedPackage) {
|
|
1356
|
+
if (looksLikeApkPath(app)) {
|
|
1357
|
+
return resolvedPackage ? [normalizeAndroidApkPath(app), resolvedPackage] : [normalizeAndroidApkPath(app)];
|
|
1358
|
+
}
|
|
1359
|
+
return [app];
|
|
1360
|
+
}
|
|
1361
|
+
function assertAndroidAppAllowed(allowedApps, app, resolvedPackage) {
|
|
1362
|
+
assertNativeAppAllowed(
|
|
1363
|
+
allowedApps,
|
|
1364
|
+
app,
|
|
1365
|
+
(value) => androidAppAllowedIdentities(value, value === app ? resolvedPackage : void 0)
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
function assertNativeAppAllowed(allowedApps, app, resolveIdentities) {
|
|
1262
1369
|
if (allowedApps.length === 0) {
|
|
1263
1370
|
return;
|
|
1264
1371
|
}
|
|
1265
|
-
const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) =>
|
|
1266
|
-
if (
|
|
1372
|
+
const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => resolveIdentities(allowedApp)));
|
|
1373
|
+
if (resolveIdentities(app).some((identity) => allowedIdentities.has(identity))) {
|
|
1267
1374
|
return;
|
|
1268
1375
|
}
|
|
1269
1376
|
throw new Error(
|
|
@@ -1641,7 +1748,11 @@ function createMacDriver(client, options = {}) {
|
|
|
1641
1748
|
return rejectUnsupported("evalScript");
|
|
1642
1749
|
},
|
|
1643
1750
|
async screenshot(screenshotOptions) {
|
|
1644
|
-
await client.request("screenshot", { path: screenshotOptions.path });
|
|
1751
|
+
const result = await client.request("screenshot", { path: screenshotOptions.path });
|
|
1752
|
+
const warning = result.warning;
|
|
1753
|
+
if (typeof warning === "string" && warning.length > 0) {
|
|
1754
|
+
console.warn(`macOS screenshot fell back to full screen: ${warning}`);
|
|
1755
|
+
}
|
|
1645
1756
|
},
|
|
1646
1757
|
// network / dialogs / downloads (all web-only) -------------------------
|
|
1647
1758
|
onResponse(_handler) {
|
|
@@ -1812,65 +1923,1732 @@ var SpawnMacHelperClient = class {
|
|
|
1812
1923
|
});
|
|
1813
1924
|
});
|
|
1814
1925
|
}
|
|
1815
|
-
async close() {
|
|
1816
|
-
if (this.closed) {
|
|
1926
|
+
async close() {
|
|
1927
|
+
if (this.closed) {
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
this.closed = true;
|
|
1931
|
+
try {
|
|
1932
|
+
this.child.stdin?.write(JSON.stringify({ cmd: "shutdown" }) + "\n");
|
|
1933
|
+
this.child.stdin?.end();
|
|
1934
|
+
} catch {
|
|
1935
|
+
}
|
|
1936
|
+
await new Promise((resolve) => {
|
|
1937
|
+
if (this.child.exitCode !== null || this.child.signalCode !== null) {
|
|
1938
|
+
resolve();
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
const timer = setTimeout(() => {
|
|
1942
|
+
this.child.kill("SIGKILL");
|
|
1943
|
+
resolve();
|
|
1944
|
+
}, 2e3);
|
|
1945
|
+
this.child.once("exit", () => {
|
|
1946
|
+
clearTimeout(timer);
|
|
1947
|
+
resolve();
|
|
1948
|
+
});
|
|
1949
|
+
});
|
|
1950
|
+
this.failAll(new Error("prowl-macdriver client is closed"));
|
|
1951
|
+
}
|
|
1952
|
+
};
|
|
1953
|
+
async function launchMacSession(options) {
|
|
1954
|
+
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_REQUEST_TIMEOUT_MS) + 5e3;
|
|
1955
|
+
const client = options.clientFactory ? options.clientFactory() : new SpawnMacHelperClient(resolveHelperBinary(), { requestTimeoutMs });
|
|
1956
|
+
const timeoutSeconds = (options.timeoutMs ?? 1e4) / 1e3;
|
|
1957
|
+
try {
|
|
1958
|
+
const trust = await client.request("check");
|
|
1959
|
+
if (trust.trusted !== true) {
|
|
1960
|
+
throw new Error(
|
|
1961
|
+
"Prowl's macOS target is not trusted for Accessibility. Grant the hosting terminal/app permission in System Settings \u2192 Privacy & Security \u2192 Accessibility, then retry."
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
const launched = await client.request("launch", { app: options.app, timeout: timeoutSeconds });
|
|
1965
|
+
const bundleId = String(launched.bundleId ?? options.app);
|
|
1966
|
+
const driver = createMacDriver(client, { appLabel: bundleId });
|
|
1967
|
+
return { client, driver, bundleId };
|
|
1968
|
+
} catch (error) {
|
|
1969
|
+
await client.close().catch(() => void 0);
|
|
1970
|
+
throw error;
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
async function closeMacSession(session) {
|
|
1974
|
+
try {
|
|
1975
|
+
await session.client.request("quit");
|
|
1976
|
+
} catch {
|
|
1977
|
+
} finally {
|
|
1978
|
+
await session.client.close();
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
// src/browser/android-helper.ts
|
|
1983
|
+
var import_node_module = require("module");
|
|
1984
|
+
var import_node_child_process4 = require("child_process");
|
|
1985
|
+
var import_node_fs6 = __toESM(require("fs"), 1);
|
|
1986
|
+
var import_node_path5 = __toESM(require("path"), 1);
|
|
1987
|
+
|
|
1988
|
+
// src/browser/android-driver.ts
|
|
1989
|
+
var import_node_fs5 = __toESM(require("fs"), 1);
|
|
1990
|
+
var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
1991
|
+
"query",
|
|
1992
|
+
"interact",
|
|
1993
|
+
"wait",
|
|
1994
|
+
"screenshot"
|
|
1995
|
+
]);
|
|
1996
|
+
var WAIT_POLL_INTERVAL_MS = 250;
|
|
1997
|
+
var DEFAULT_WAIT_TIMEOUT_MS = 5e3;
|
|
1998
|
+
var ANDROID_KEYCODES = {
|
|
1999
|
+
enter: 66,
|
|
2000
|
+
return: 66,
|
|
2001
|
+
tab: 61,
|
|
2002
|
+
space: 62,
|
|
2003
|
+
backspace: 67,
|
|
2004
|
+
delete: 67,
|
|
2005
|
+
del: 67,
|
|
2006
|
+
escape: 111,
|
|
2007
|
+
esc: 111,
|
|
2008
|
+
back: 4,
|
|
2009
|
+
home: 3,
|
|
2010
|
+
menu: 82,
|
|
2011
|
+
search: 84,
|
|
2012
|
+
up: 19,
|
|
2013
|
+
arrowup: 19,
|
|
2014
|
+
down: 20,
|
|
2015
|
+
arrowdown: 20,
|
|
2016
|
+
left: 21,
|
|
2017
|
+
arrowleft: 21,
|
|
2018
|
+
right: 22,
|
|
2019
|
+
arrowright: 22,
|
|
2020
|
+
pageup: 92,
|
|
2021
|
+
pagedown: 93
|
|
2022
|
+
};
|
|
2023
|
+
function unquote2(value) {
|
|
2024
|
+
const trimmed = value.trim();
|
|
2025
|
+
const first = trimmed[0];
|
|
2026
|
+
if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
|
|
2027
|
+
return trimmed.slice(1, -1);
|
|
2028
|
+
}
|
|
2029
|
+
return trimmed;
|
|
2030
|
+
}
|
|
2031
|
+
function escapeUiSelectorArg(value) {
|
|
2032
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
2033
|
+
}
|
|
2034
|
+
function parseAndroidSelector(selector) {
|
|
2035
|
+
const trimmed = selector.trim();
|
|
2036
|
+
if (trimmed === ":focus") {
|
|
2037
|
+
return { by: "focused" };
|
|
2038
|
+
}
|
|
2039
|
+
const idMatch = /^id=(.+)$/s.exec(trimmed);
|
|
2040
|
+
if (idMatch) {
|
|
2041
|
+
return { by: "id", value: unquote2(idMatch[1]) };
|
|
2042
|
+
}
|
|
2043
|
+
const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
|
|
2044
|
+
if (roleMatch) {
|
|
2045
|
+
const name = roleMatch[2] !== void 0 ? unquote2(roleMatch[2]) : void 0;
|
|
2046
|
+
return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
|
|
2047
|
+
}
|
|
2048
|
+
const labelMatch = /^label=(.+)$/s.exec(trimmed);
|
|
2049
|
+
if (labelMatch) {
|
|
2050
|
+
return { by: "accessibilityId", value: unquote2(labelMatch[1]) };
|
|
2051
|
+
}
|
|
2052
|
+
const textMatch = /^text=(.+)$/s.exec(trimmed);
|
|
2053
|
+
if (textMatch) {
|
|
2054
|
+
return { by: "text", value: unquote2(textMatch[1]) };
|
|
2055
|
+
}
|
|
2056
|
+
return { by: "text", value: trimmed };
|
|
2057
|
+
}
|
|
2058
|
+
function locator(strategy, selector) {
|
|
2059
|
+
return { strategy, selector, context: "" };
|
|
2060
|
+
}
|
|
2061
|
+
function qualifyResourceId(value, appPackage) {
|
|
2062
|
+
if (value.includes(":") || !appPackage) {
|
|
2063
|
+
return value;
|
|
2064
|
+
}
|
|
2065
|
+
return `${appPackage}:id/${value}`;
|
|
2066
|
+
}
|
|
2067
|
+
function androidQueryToLocator(query, options = {}) {
|
|
2068
|
+
switch (query.by) {
|
|
2069
|
+
case "id":
|
|
2070
|
+
return locator("id", qualifyResourceId(query.value, options.appPackage));
|
|
2071
|
+
case "accessibilityId":
|
|
2072
|
+
return locator("accessibility id", query.value);
|
|
2073
|
+
case "text":
|
|
2074
|
+
return locator(
|
|
2075
|
+
"-android uiautomator",
|
|
2076
|
+
`new UiSelector().textContains("${escapeUiSelectorArg(query.value)}")`
|
|
2077
|
+
);
|
|
2078
|
+
case "focused":
|
|
2079
|
+
return locator("-android uiautomator", "new UiSelector().focused(true)");
|
|
2080
|
+
case "role": {
|
|
2081
|
+
const className = escapeUiSelectorArg(query.role);
|
|
2082
|
+
if (query.name === void 0 || query.name.length === 0) {
|
|
2083
|
+
return locator("class name", query.role);
|
|
2084
|
+
}
|
|
2085
|
+
return locator(
|
|
2086
|
+
"-android uiautomator",
|
|
2087
|
+
`new UiSelector().className("${className}").textContains("${escapeUiSelectorArg(query.name)}")`
|
|
2088
|
+
);
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
function unwrapAndroidTextSelector(selector) {
|
|
2093
|
+
const trimmed = selector.trim();
|
|
2094
|
+
if (!trimmed.startsWith("text=")) {
|
|
2095
|
+
return null;
|
|
2096
|
+
}
|
|
2097
|
+
return unquote2(trimmed.slice(5));
|
|
2098
|
+
}
|
|
2099
|
+
function keyCodeFor(key) {
|
|
2100
|
+
const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
|
|
2101
|
+
if (code === void 0) {
|
|
2102
|
+
throw new Error(
|
|
2103
|
+
`Unsupported key "${key}" for the Android target. Supported keys: ${Object.keys(ANDROID_KEYCODES).sort().join(", ")}.`
|
|
2104
|
+
);
|
|
2105
|
+
}
|
|
2106
|
+
return code;
|
|
2107
|
+
}
|
|
2108
|
+
function delay(ms) {
|
|
2109
|
+
return new Promise((resolve) => {
|
|
2110
|
+
setTimeout(resolve, ms);
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
function createAndroidDriver(client, options = {}) {
|
|
2114
|
+
const unsupported = (verb) => new Error(`${verb} is not supported by the Android target`);
|
|
2115
|
+
const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
|
|
2116
|
+
async function resolveOne(selector) {
|
|
2117
|
+
const id = await client.findElement(parseAndroidSelector(selector));
|
|
2118
|
+
if (id === null) {
|
|
2119
|
+
throw new Error(`No element matched selector: ${selector}`);
|
|
2120
|
+
}
|
|
2121
|
+
return id;
|
|
2122
|
+
}
|
|
2123
|
+
async function clickSelector(selector) {
|
|
2124
|
+
await client.click(await resolveOne(selector));
|
|
2125
|
+
}
|
|
2126
|
+
async function fillSelector(selector, value) {
|
|
2127
|
+
await client.setValue(await resolveOne(selector), value);
|
|
2128
|
+
}
|
|
2129
|
+
return {
|
|
2130
|
+
capabilities: ANDROID_CAPABILITIES,
|
|
2131
|
+
// navigation -----------------------------------------------------------
|
|
2132
|
+
goto(_url, _options) {
|
|
2133
|
+
return rejectUnsupported("navigate");
|
|
2134
|
+
},
|
|
2135
|
+
currentUrl() {
|
|
2136
|
+
return `android:${options.appLabel ?? ""}`;
|
|
2137
|
+
},
|
|
2138
|
+
// queries --------------------------------------------------------------
|
|
2139
|
+
async count(selector) {
|
|
2140
|
+
return (await client.findElements(parseAndroidSelector(selector))).length;
|
|
2141
|
+
},
|
|
2142
|
+
async textContent(selector) {
|
|
2143
|
+
const id = await client.findElement(parseAndroidSelector(selector));
|
|
2144
|
+
if (id === null) {
|
|
2145
|
+
return null;
|
|
2146
|
+
}
|
|
2147
|
+
return client.getText(id);
|
|
2148
|
+
},
|
|
2149
|
+
// interactions ---------------------------------------------------------
|
|
2150
|
+
click: clickSelector,
|
|
2151
|
+
clickFirst: clickSelector,
|
|
2152
|
+
fill: fillSelector,
|
|
2153
|
+
fillFirst: fillSelector,
|
|
2154
|
+
async press(_selector, key) {
|
|
2155
|
+
await client.pressKeyCode(keyCodeFor(key));
|
|
2156
|
+
},
|
|
2157
|
+
selectOption() {
|
|
2158
|
+
return rejectUnsupported("select");
|
|
2159
|
+
},
|
|
2160
|
+
selectOptionFirst() {
|
|
2161
|
+
return rejectUnsupported("select");
|
|
2162
|
+
},
|
|
2163
|
+
hover() {
|
|
2164
|
+
return rejectUnsupported("hover");
|
|
2165
|
+
},
|
|
2166
|
+
scrollIntoView() {
|
|
2167
|
+
return rejectUnsupported("scrollTo");
|
|
2168
|
+
},
|
|
2169
|
+
setInputFiles() {
|
|
2170
|
+
return rejectUnsupported("setInputFiles");
|
|
2171
|
+
},
|
|
2172
|
+
// semantic locators ----------------------------------------------------
|
|
2173
|
+
async countByRole(role, name) {
|
|
2174
|
+
return (await client.findElements({ by: "role", role, name })).length;
|
|
2175
|
+
},
|
|
2176
|
+
async clickFirstByRole(role, name) {
|
|
2177
|
+
const id = await client.findElement({ by: "role", role, name });
|
|
2178
|
+
if (id === null) {
|
|
2179
|
+
throw new Error(`No element matched role=${role}[name="${name}"]`);
|
|
2180
|
+
}
|
|
2181
|
+
await client.click(id);
|
|
2182
|
+
},
|
|
2183
|
+
async countByLabel(label) {
|
|
2184
|
+
return (await client.findElements({ by: "accessibilityId", value: label })).length;
|
|
2185
|
+
},
|
|
2186
|
+
async fillFirstByLabel(label, value) {
|
|
2187
|
+
const id = await client.findElement({ by: "accessibilityId", value: label });
|
|
2188
|
+
if (id === null) {
|
|
2189
|
+
throw new Error(`No element matched label="${label}"`);
|
|
2190
|
+
}
|
|
2191
|
+
await client.setValue(id, value);
|
|
2192
|
+
},
|
|
2193
|
+
selectOptionFirstByLabel() {
|
|
2194
|
+
return rejectUnsupported("select");
|
|
2195
|
+
},
|
|
2196
|
+
// waiting --------------------------------------------------------------
|
|
2197
|
+
async waitForSelector(selector, waitOptions) {
|
|
2198
|
+
const query = parseAndroidSelector(selector);
|
|
2199
|
+
const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
2200
|
+
const deadline = Date.now() + timeoutMs;
|
|
2201
|
+
for (; ; ) {
|
|
2202
|
+
if ((await client.findElements(query)).length > 0) {
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
if (Date.now() >= deadline) {
|
|
2206
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
|
|
2207
|
+
}
|
|
2208
|
+
await delay(Math.min(WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
|
|
2209
|
+
}
|
|
2210
|
+
},
|
|
2211
|
+
waitForUrl() {
|
|
2212
|
+
return rejectUnsupported("waitForUrl");
|
|
2213
|
+
},
|
|
2214
|
+
waitForNetworkIdle() {
|
|
2215
|
+
return rejectUnsupported("waitForNetworkIdle");
|
|
2216
|
+
},
|
|
2217
|
+
// scripting & artifacts ------------------------------------------------
|
|
2218
|
+
evaluate() {
|
|
2219
|
+
return rejectUnsupported("evalScript");
|
|
2220
|
+
},
|
|
2221
|
+
async screenshot(screenshotOptions) {
|
|
2222
|
+
const png = await client.screenshotPng();
|
|
2223
|
+
import_node_fs5.default.writeFileSync(screenshotOptions.path, png);
|
|
2224
|
+
},
|
|
2225
|
+
// network / dialogs / downloads (all web-only) -------------------------
|
|
2226
|
+
onResponse(_handler) {
|
|
2227
|
+
throw unsupported("onResponse");
|
|
2228
|
+
},
|
|
2229
|
+
route(_url, _handler) {
|
|
2230
|
+
return rejectUnsupported("mockRoute");
|
|
2231
|
+
},
|
|
2232
|
+
unroute() {
|
|
2233
|
+
return rejectUnsupported("unmockRoute");
|
|
2234
|
+
},
|
|
2235
|
+
onDialog(_action) {
|
|
2236
|
+
throw unsupported("onDialog");
|
|
2237
|
+
},
|
|
2238
|
+
waitForDownloadEvent() {
|
|
2239
|
+
return rejectUnsupported("waitForDownload");
|
|
2240
|
+
},
|
|
2241
|
+
parseTextSelector(selector) {
|
|
2242
|
+
return unwrapAndroidTextSelector(selector);
|
|
2243
|
+
}
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
// src/browser/android-adb.ts
|
|
2248
|
+
var import_node_child_process3 = require("child_process");
|
|
2249
|
+
var execFileAdbRunner = (args, options) => new Promise((resolve) => {
|
|
2250
|
+
(0, import_node_child_process3.execFile)(
|
|
2251
|
+
"adb",
|
|
2252
|
+
args,
|
|
2253
|
+
{ encoding: "utf-8", timeout: options?.timeoutMs, maxBuffer: 16 * 1024 * 1024 },
|
|
2254
|
+
(error, stdout, stderr) => {
|
|
2255
|
+
const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
|
|
2256
|
+
const capturedStderr = stderr ?? "";
|
|
2257
|
+
resolve({
|
|
2258
|
+
stdout: stdout ?? "",
|
|
2259
|
+
stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
|
|
2260
|
+
code
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
);
|
|
2264
|
+
});
|
|
2265
|
+
var spawnAdbProcess = (args) => {
|
|
2266
|
+
const child = (0, import_node_child_process3.spawn)("adb", args, { stdio: "ignore" });
|
|
2267
|
+
child.on("error", () => {
|
|
2268
|
+
});
|
|
2269
|
+
return { kill: () => child.kill() };
|
|
2270
|
+
};
|
|
2271
|
+
function withSerial(serial, args) {
|
|
2272
|
+
return serial ? ["-s", serial, ...args] : args;
|
|
2273
|
+
}
|
|
2274
|
+
function parseAdbDevices(stdout) {
|
|
2275
|
+
const devices = [];
|
|
2276
|
+
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
2277
|
+
const line = rawLine.trim();
|
|
2278
|
+
if (!line || /^list of devices attached/i.test(line)) {
|
|
2279
|
+
continue;
|
|
2280
|
+
}
|
|
2281
|
+
const [serial, state, ...rest] = line.split(/\s+/);
|
|
2282
|
+
if (!serial || !state) {
|
|
2283
|
+
continue;
|
|
2284
|
+
}
|
|
2285
|
+
const description = {};
|
|
2286
|
+
for (const token of rest) {
|
|
2287
|
+
const eq = token.indexOf(":");
|
|
2288
|
+
if (eq > 0) {
|
|
2289
|
+
description[token.slice(0, eq)] = token.slice(eq + 1);
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
devices.push({ serial, state, description });
|
|
2293
|
+
}
|
|
2294
|
+
return devices;
|
|
2295
|
+
}
|
|
2296
|
+
function bootedDevices(devices) {
|
|
2297
|
+
return devices.filter((device) => device.state === "device");
|
|
2298
|
+
}
|
|
2299
|
+
function selectDeviceSerial(devices, requested) {
|
|
2300
|
+
const booted = bootedDevices(devices);
|
|
2301
|
+
if (requested) {
|
|
2302
|
+
const match = devices.find((device) => device.serial === requested);
|
|
2303
|
+
if (!match) {
|
|
2304
|
+
const attached = devices.length > 0 ? devices.map((d) => d.serial).join(", ") : "none";
|
|
2305
|
+
throw new Error(
|
|
2306
|
+
`Android device "${requested}" is not attached. Attached devices: ${attached}. Check \`adb devices -l\`.`
|
|
2307
|
+
);
|
|
2308
|
+
}
|
|
2309
|
+
if (match.state !== "device") {
|
|
2310
|
+
throw new Error(
|
|
2311
|
+
`Android device "${requested}" is present but not ready (state: ${match.state}). Boot or authorize it, then retry.`
|
|
2312
|
+
);
|
|
2313
|
+
}
|
|
2314
|
+
return requested;
|
|
2315
|
+
}
|
|
2316
|
+
if (booted.length === 0) {
|
|
2317
|
+
throw new Error(
|
|
2318
|
+
"No booted Android device found. Start an emulator or connect a device with USB debugging, then confirm it appears in `adb devices -l`."
|
|
2319
|
+
);
|
|
2320
|
+
}
|
|
2321
|
+
if (booted.length > 1) {
|
|
2322
|
+
throw new Error(
|
|
2323
|
+
`Multiple Android devices attached (${booted.map((d) => d.serial).join(", ")}). Set target.deviceSerial to pick one.`
|
|
2324
|
+
);
|
|
2325
|
+
}
|
|
2326
|
+
return booted[0].serial;
|
|
2327
|
+
}
|
|
2328
|
+
async function listDevices(runner) {
|
|
2329
|
+
const result = await runner(["devices", "-l"], { timeoutMs: 1e4 });
|
|
2330
|
+
if (result.code !== 0) {
|
|
2331
|
+
throw new Error(
|
|
2332
|
+
`\`adb devices\` failed (exit ${result.code}). Is adb on PATH and the server running? ` + (result.stderr.trim() || "").slice(0, 400)
|
|
2333
|
+
);
|
|
2334
|
+
}
|
|
2335
|
+
return parseAdbDevices(result.stdout);
|
|
2336
|
+
}
|
|
2337
|
+
function parseForwardPort(stdout) {
|
|
2338
|
+
const port = Number.parseInt(stdout.trim(), 10);
|
|
2339
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
2340
|
+
throw new Error(`Could not parse a forwarded port from adb output: "${stdout.trim()}"`);
|
|
2341
|
+
}
|
|
2342
|
+
return port;
|
|
2343
|
+
}
|
|
2344
|
+
function parseAaptPackage(stdout) {
|
|
2345
|
+
const match = /package:\s*name='([^']+)'/.exec(stdout);
|
|
2346
|
+
return match?.[1] ?? null;
|
|
2347
|
+
}
|
|
2348
|
+
async function forwardDynamicPort(runner, serial, remotePort) {
|
|
2349
|
+
const result = await runner(withSerial(serial, ["forward", "tcp:0", `tcp:${remotePort}`]), {
|
|
2350
|
+
timeoutMs: 1e4
|
|
2351
|
+
});
|
|
2352
|
+
if (result.code !== 0) {
|
|
2353
|
+
throw new Error(`adb forward failed (exit ${result.code}): ${result.stderr.trim()}`);
|
|
2354
|
+
}
|
|
2355
|
+
return parseForwardPort(result.stdout);
|
|
2356
|
+
}
|
|
2357
|
+
async function removeForward(runner, serial, localPort) {
|
|
2358
|
+
await runner(withSerial(serial, ["forward", "--remove", `tcp:${localPort}`]), { timeoutMs: 1e4 }).catch(
|
|
2359
|
+
() => void 0
|
|
2360
|
+
);
|
|
2361
|
+
}
|
|
2362
|
+
async function installApk(runner, serial, apkPath) {
|
|
2363
|
+
const result = await runner(withSerial(serial, ["install", "-r", "-t", "-g", apkPath]), {
|
|
2364
|
+
timeoutMs: 12e4
|
|
2365
|
+
});
|
|
2366
|
+
if (result.code !== 0 || /failure/i.test(result.stdout)) {
|
|
2367
|
+
throw new Error(
|
|
2368
|
+
`Failed to install APK "${apkPath}" (exit ${result.code}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
|
|
2369
|
+
);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
async function launchPackage(runner, serial, pkg) {
|
|
2373
|
+
const result = await runner(
|
|
2374
|
+
withSerial(serial, [
|
|
2375
|
+
"shell",
|
|
2376
|
+
"monkey",
|
|
2377
|
+
"-p",
|
|
2378
|
+
pkg,
|
|
2379
|
+
"-c",
|
|
2380
|
+
"android.intent.category.LAUNCHER",
|
|
2381
|
+
"1"
|
|
2382
|
+
]),
|
|
2383
|
+
{ timeoutMs: 3e4 }
|
|
2384
|
+
);
|
|
2385
|
+
if (result.code !== 0 || /no activities found|aborted|cannot launch/i.test(result.stdout + result.stderr)) {
|
|
2386
|
+
throw new Error(
|
|
2387
|
+
`Failed to launch Android package "${pkg}" (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 400)}. Is it installed?`
|
|
2388
|
+
);
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
async function forceStop(runner, serial, pkg) {
|
|
2392
|
+
await runner(withSerial(serial, ["shell", "am", "force-stop", pkg]), { timeoutMs: 3e4 });
|
|
2393
|
+
}
|
|
2394
|
+
async function clearPackage(runner, serial, pkg) {
|
|
2395
|
+
const result = await runner(withSerial(serial, ["shell", "pm", "clear", pkg]), { timeoutMs: 3e4 });
|
|
2396
|
+
if (result.code !== 0 || !/success/i.test(result.stdout)) {
|
|
2397
|
+
throw new Error(
|
|
2398
|
+
`Failed to clear Android package "${pkg}" for cold start (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 300)}`
|
|
2399
|
+
);
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
function startInstrumentation(spawner, serial) {
|
|
2403
|
+
return spawner(
|
|
2404
|
+
withSerial(serial, [
|
|
2405
|
+
"shell",
|
|
2406
|
+
"am",
|
|
2407
|
+
"instrument",
|
|
2408
|
+
"-w",
|
|
2409
|
+
"-e",
|
|
2410
|
+
"disableAnalytics",
|
|
2411
|
+
"true",
|
|
2412
|
+
"io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner"
|
|
2413
|
+
])
|
|
2414
|
+
);
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
// src/browser/android-agent.ts
|
|
2418
|
+
var DEFAULT_AGENT_REQUEST_TIMEOUT_MS = 3e4;
|
|
2419
|
+
var W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
|
|
2420
|
+
var Uia2HttpError = class extends Error {
|
|
2421
|
+
constructor(message, status, webdriverError) {
|
|
2422
|
+
super(message);
|
|
2423
|
+
this.status = status;
|
|
2424
|
+
this.webdriverError = webdriverError;
|
|
2425
|
+
this.name = "Uia2HttpError";
|
|
2426
|
+
}
|
|
2427
|
+
};
|
|
2428
|
+
var Uia2Transport = class {
|
|
2429
|
+
baseUrl;
|
|
2430
|
+
requestTimeoutMs;
|
|
2431
|
+
fetchImpl;
|
|
2432
|
+
constructor(options) {
|
|
2433
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
2434
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_AGENT_REQUEST_TIMEOUT_MS;
|
|
2435
|
+
const injected = options.fetchImpl;
|
|
2436
|
+
if (injected) {
|
|
2437
|
+
this.fetchImpl = injected;
|
|
2438
|
+
} else if (typeof fetch === "function") {
|
|
2439
|
+
this.fetchImpl = (url, init) => fetch(url, init);
|
|
2440
|
+
} else {
|
|
2441
|
+
throw new Error("global fetch is unavailable; Node 20+ is required for the Android target");
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
/**
|
|
2445
|
+
* Send one request and return the parsed `value` field. Rejects with a
|
|
2446
|
+
* {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
|
|
2447
|
+
* per-request deadline elapses.
|
|
2448
|
+
*/
|
|
2449
|
+
async request(method, path25, body, timeoutMs) {
|
|
2450
|
+
const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
|
|
2451
|
+
const controller = new AbortController();
|
|
2452
|
+
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
2453
|
+
timer.unref?.();
|
|
2454
|
+
const url = `${this.baseUrl}${path25}`;
|
|
2455
|
+
let response;
|
|
2456
|
+
try {
|
|
2457
|
+
response = await this.fetchImpl(url, {
|
|
2458
|
+
method,
|
|
2459
|
+
signal: controller.signal,
|
|
2460
|
+
headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
|
|
2461
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
2462
|
+
});
|
|
2463
|
+
} catch (error) {
|
|
2464
|
+
if (controller.signal.aborted) {
|
|
2465
|
+
const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
|
|
2466
|
+
throw new Error(`uiautomator2 request ${method} ${path25} timed out after ${shown}`);
|
|
2467
|
+
}
|
|
2468
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
2469
|
+
} finally {
|
|
2470
|
+
clearTimeout(timer);
|
|
2471
|
+
}
|
|
2472
|
+
const text = await response.text();
|
|
2473
|
+
const parsed = parseJson(text);
|
|
2474
|
+
if (!response.ok) {
|
|
2475
|
+
const wdError = extractWebdriverError(parsed);
|
|
2476
|
+
throw new Uia2HttpError(
|
|
2477
|
+
`uiautomator2 ${method} ${path25} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
|
|
2478
|
+
response.status,
|
|
2479
|
+
wdError
|
|
2480
|
+
);
|
|
2481
|
+
}
|
|
2482
|
+
return parsed?.value;
|
|
2483
|
+
}
|
|
2484
|
+
};
|
|
2485
|
+
function parseJson(text) {
|
|
2486
|
+
if (!text) {
|
|
2487
|
+
return void 0;
|
|
2488
|
+
}
|
|
2489
|
+
try {
|
|
2490
|
+
return JSON.parse(text);
|
|
2491
|
+
} catch {
|
|
2492
|
+
return void 0;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
function extractWebdriverError(parsed) {
|
|
2496
|
+
const value = parsed?.value;
|
|
2497
|
+
if (value && typeof value === "object") {
|
|
2498
|
+
const record = value;
|
|
2499
|
+
const error = typeof record.error === "string" ? record.error : void 0;
|
|
2500
|
+
const message = typeof record.message === "string" ? record.message : void 0;
|
|
2501
|
+
return error ?? message;
|
|
2502
|
+
}
|
|
2503
|
+
return void 0;
|
|
2504
|
+
}
|
|
2505
|
+
function extractElementId(value) {
|
|
2506
|
+
if (!value || typeof value !== "object") {
|
|
2507
|
+
return null;
|
|
2508
|
+
}
|
|
2509
|
+
const record = value;
|
|
2510
|
+
const id = record[W3C_ELEMENT_KEY] ?? record.ELEMENT;
|
|
2511
|
+
return typeof id === "string" ? id : null;
|
|
2512
|
+
}
|
|
2513
|
+
function isNoSuchElement(error) {
|
|
2514
|
+
if (error instanceof Uia2HttpError) {
|
|
2515
|
+
return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
|
|
2516
|
+
}
|
|
2517
|
+
return false;
|
|
2518
|
+
}
|
|
2519
|
+
async function createUia2Session(transport) {
|
|
2520
|
+
const value = await transport.request("POST", "/session", {
|
|
2521
|
+
capabilities: { alwaysMatch: {}, firstMatch: [{}] }
|
|
2522
|
+
});
|
|
2523
|
+
const record = value ?? {};
|
|
2524
|
+
const sessionId = record.sessionId;
|
|
2525
|
+
if (typeof sessionId === "string" && sessionId.length > 0) {
|
|
2526
|
+
return sessionId;
|
|
2527
|
+
}
|
|
2528
|
+
throw new Error("uiautomator2 did not return a session id");
|
|
2529
|
+
}
|
|
2530
|
+
async function waitForAgentReady(transport, options = { deadlineMs: 3e4 }) {
|
|
2531
|
+
const interval = options.intervalMs ?? 300;
|
|
2532
|
+
const deadline = Date.now() + options.deadlineMs;
|
|
2533
|
+
let lastError;
|
|
2534
|
+
for (; ; ) {
|
|
2535
|
+
try {
|
|
2536
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
2537
|
+
const value = await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
|
|
2538
|
+
const ready = value?.ready;
|
|
2539
|
+
if (ready === void 0 || ready === true) {
|
|
2540
|
+
return;
|
|
2541
|
+
}
|
|
2542
|
+
} catch (error) {
|
|
2543
|
+
lastError = error;
|
|
2544
|
+
}
|
|
2545
|
+
if (Date.now() >= deadline) {
|
|
2546
|
+
const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
|
|
2547
|
+
throw new Error(`uiautomator2 agent did not become ready within ${options.deadlineMs}ms${detail}`);
|
|
2548
|
+
}
|
|
2549
|
+
await sleep(Math.min(interval, Math.max(0, deadline - Date.now())));
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
function sleep(ms) {
|
|
2553
|
+
return new Promise((resolve) => {
|
|
2554
|
+
const timer = setTimeout(resolve, ms);
|
|
2555
|
+
timer.unref?.();
|
|
2556
|
+
});
|
|
2557
|
+
}
|
|
2558
|
+
function createUia2AgentClient(transport, sessionId, options = {}) {
|
|
2559
|
+
const base = `/session/${sessionId}`;
|
|
2560
|
+
async function locate(query, path25) {
|
|
2561
|
+
return transport.request(
|
|
2562
|
+
"POST",
|
|
2563
|
+
`${base}${path25}`,
|
|
2564
|
+
androidQueryToLocator(query, { appPackage: options.appPackage })
|
|
2565
|
+
);
|
|
2566
|
+
}
|
|
2567
|
+
return {
|
|
2568
|
+
async findElement(query) {
|
|
2569
|
+
try {
|
|
2570
|
+
return extractElementId(await locate(query, "/element"));
|
|
2571
|
+
} catch (error) {
|
|
2572
|
+
if (isNoSuchElement(error)) {
|
|
2573
|
+
return null;
|
|
2574
|
+
}
|
|
2575
|
+
throw error;
|
|
2576
|
+
}
|
|
2577
|
+
},
|
|
2578
|
+
async findElements(query) {
|
|
2579
|
+
const value = await locate(query, "/elements");
|
|
2580
|
+
if (!Array.isArray(value)) {
|
|
2581
|
+
return [];
|
|
2582
|
+
}
|
|
2583
|
+
return value.map((entry) => extractElementId(entry)).filter((id) => id !== null);
|
|
2584
|
+
},
|
|
2585
|
+
async click(elementId) {
|
|
2586
|
+
await transport.request("POST", `${base}/element/${elementId}/click`, {});
|
|
2587
|
+
},
|
|
2588
|
+
async setValue(elementId, text) {
|
|
2589
|
+
await transport.request("POST", `${base}/element/${elementId}/value`, { text });
|
|
2590
|
+
},
|
|
2591
|
+
async getText(elementId) {
|
|
2592
|
+
const value = await transport.request("GET", `${base}/element/${elementId}/text`);
|
|
2593
|
+
return typeof value === "string" ? value : value == null ? null : String(value);
|
|
2594
|
+
},
|
|
2595
|
+
async pressKeyCode(keyCode) {
|
|
2596
|
+
await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
|
|
2597
|
+
},
|
|
2598
|
+
async screenshotPng() {
|
|
2599
|
+
const value = await transport.request("GET", `${base}/screenshot`);
|
|
2600
|
+
if (typeof value !== "string") {
|
|
2601
|
+
throw new Error("uiautomator2 screenshot did not return base64 data");
|
|
2602
|
+
}
|
|
2603
|
+
return Buffer.from(value, "base64");
|
|
2604
|
+
},
|
|
2605
|
+
async close() {
|
|
2606
|
+
await transport.request("DELETE", base).catch(() => void 0);
|
|
2607
|
+
}
|
|
2608
|
+
};
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
// src/browser/android-helper.ts
|
|
2612
|
+
var import_meta2 = {};
|
|
2613
|
+
var UIA2_REMOTE_PORT = 6790;
|
|
2614
|
+
function resolveAgentApks(requireFn = (0, import_node_module.createRequire)(import_meta2.url)) {
|
|
2615
|
+
let pkgJsonPath;
|
|
2616
|
+
try {
|
|
2617
|
+
pkgJsonPath = requireFn.resolve("appium-uiautomator2-server/package.json");
|
|
2618
|
+
} catch {
|
|
2619
|
+
throw new Error(
|
|
2620
|
+
"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"
|
|
2621
|
+
);
|
|
2622
|
+
}
|
|
2623
|
+
const pkgDir = import_node_path5.default.dirname(pkgJsonPath);
|
|
2624
|
+
const version = requireFn(pkgJsonPath).version;
|
|
2625
|
+
const serverApk = import_node_path5.default.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
|
|
2626
|
+
const testApk = import_node_path5.default.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
|
|
2627
|
+
for (const apk of [serverApk, testApk]) {
|
|
2628
|
+
if (!import_node_fs6.default.existsSync(apk)) {
|
|
2629
|
+
throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
return { serverApk, testApk };
|
|
2633
|
+
}
|
|
2634
|
+
function looksLikeApk(app) {
|
|
2635
|
+
return app.toLowerCase().endsWith(".apk");
|
|
2636
|
+
}
|
|
2637
|
+
var execFileAaptResolver = async (apkPath) => {
|
|
2638
|
+
for (const tool of ["aapt", "aapt2"]) {
|
|
2639
|
+
const output = await new Promise((resolve) => {
|
|
2640
|
+
(0, import_node_child_process4.execFile)(
|
|
2641
|
+
tool,
|
|
2642
|
+
["dump", "badging", apkPath],
|
|
2643
|
+
{ encoding: "utf-8", timeout: 2e4, maxBuffer: 8 * 1024 * 1024 },
|
|
2644
|
+
(error, stdout) => resolve(error ? null : stdout)
|
|
2645
|
+
);
|
|
2646
|
+
});
|
|
2647
|
+
const pkg = output ? parseAaptPackage(output) : null;
|
|
2648
|
+
if (pkg) {
|
|
2649
|
+
return pkg;
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
return null;
|
|
2653
|
+
};
|
|
2654
|
+
var defaultAgentConnector = async ({
|
|
2655
|
+
host,
|
|
2656
|
+
port,
|
|
2657
|
+
requestTimeoutMs,
|
|
2658
|
+
readyDeadlineMs,
|
|
2659
|
+
appPackage
|
|
2660
|
+
}) => {
|
|
2661
|
+
const transport = new Uia2Transport({
|
|
2662
|
+
baseUrl: `http://${host}:${port}/wd/hub`,
|
|
2663
|
+
requestTimeoutMs
|
|
2664
|
+
});
|
|
2665
|
+
await waitForAgentReady(transport, { deadlineMs: readyDeadlineMs });
|
|
2666
|
+
const sessionId = await createUia2Session(transport);
|
|
2667
|
+
return createUia2AgentClient(transport, sessionId, { appPackage });
|
|
2668
|
+
};
|
|
2669
|
+
async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
|
|
2670
|
+
if (!looksLikeApk(app)) {
|
|
2671
|
+
assertAndroidAppAllowed(allowedApps, app);
|
|
2672
|
+
return app;
|
|
2673
|
+
}
|
|
2674
|
+
const apkPath = import_node_path5.default.resolve(app);
|
|
2675
|
+
if (!import_node_fs6.default.existsSync(apkPath)) {
|
|
2676
|
+
throw new Error(`APK not found: ${apkPath}`);
|
|
2677
|
+
}
|
|
2678
|
+
const pkg = await aaptResolver(apkPath);
|
|
2679
|
+
if (!pkg) {
|
|
2680
|
+
throw new Error(
|
|
2681
|
+
`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.`
|
|
2682
|
+
);
|
|
2683
|
+
}
|
|
2684
|
+
assertAndroidAppAllowed(allowedApps, apkPath, pkg);
|
|
2685
|
+
await installApk(runner, serial, apkPath);
|
|
2686
|
+
return pkg;
|
|
2687
|
+
}
|
|
2688
|
+
async function launchAndroidSession(options) {
|
|
2689
|
+
const runner = options.runner ?? execFileAdbRunner;
|
|
2690
|
+
const spawner = options.spawner ?? spawnAdbProcess;
|
|
2691
|
+
const connector = options.agentConnector ?? defaultAgentConnector;
|
|
2692
|
+
const aaptResolver = options.aaptResolver ?? execFileAaptResolver;
|
|
2693
|
+
const apks = options.apks ?? resolveAgentApks();
|
|
2694
|
+
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS) + 5e3;
|
|
2695
|
+
const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS);
|
|
2696
|
+
const devices = await listDevices(runner);
|
|
2697
|
+
const serial = selectDeviceSerial(devices, options.deviceSerial);
|
|
2698
|
+
const pkg = await resolvePackage(options.app, runner, serial, aaptResolver, options.allowedApps ?? []);
|
|
2699
|
+
await installApk(runner, serial, apks.serverApk);
|
|
2700
|
+
await installApk(runner, serial, apks.testApk);
|
|
2701
|
+
if (options.coldStart) {
|
|
2702
|
+
await clearPackage(runner, serial, pkg);
|
|
2703
|
+
}
|
|
2704
|
+
await launchPackage(runner, serial, pkg);
|
|
2705
|
+
let instrumentation;
|
|
2706
|
+
let localPort;
|
|
2707
|
+
let client;
|
|
2708
|
+
let tornDown = false;
|
|
2709
|
+
const teardown = async () => {
|
|
2710
|
+
if (tornDown) {
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
tornDown = true;
|
|
2714
|
+
if (client) {
|
|
2715
|
+
await client.close().catch(() => void 0);
|
|
2716
|
+
}
|
|
2717
|
+
instrumentation?.kill();
|
|
2718
|
+
const forwardedPort = localPort;
|
|
2719
|
+
localPort = void 0;
|
|
2720
|
+
if (forwardedPort !== void 0) {
|
|
2721
|
+
await removeForward(runner, serial, forwardedPort);
|
|
2722
|
+
}
|
|
2723
|
+
await forceStop(runner, serial, pkg).catch(() => void 0);
|
|
2724
|
+
};
|
|
2725
|
+
try {
|
|
2726
|
+
instrumentation = startInstrumentation(spawner, serial);
|
|
2727
|
+
localPort = await forwardDynamicPort(runner, serial, UIA2_REMOTE_PORT);
|
|
2728
|
+
client = await connector({
|
|
2729
|
+
host: "127.0.0.1",
|
|
2730
|
+
port: localPort,
|
|
2731
|
+
requestTimeoutMs,
|
|
2732
|
+
readyDeadlineMs,
|
|
2733
|
+
appPackage: pkg
|
|
2734
|
+
});
|
|
2735
|
+
const driver = createAndroidDriver(client, { appLabel: pkg });
|
|
2736
|
+
return { client, driver, package: pkg, serial, teardown };
|
|
2737
|
+
} catch (error) {
|
|
2738
|
+
await teardown();
|
|
2739
|
+
throw error;
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
async function closeAndroidSession(session) {
|
|
2743
|
+
await session.teardown();
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
// src/browser/ios-helper.ts
|
|
2747
|
+
var import_node_module2 = require("module");
|
|
2748
|
+
var import_node_fs7 = __toESM(require("fs"), 1);
|
|
2749
|
+
var import_node_os2 = __toESM(require("os"), 1);
|
|
2750
|
+
var import_node_path7 = __toESM(require("path"), 1);
|
|
2751
|
+
|
|
2752
|
+
// src/browser/ios-driver.ts
|
|
2753
|
+
var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
2754
|
+
"query",
|
|
2755
|
+
"interact",
|
|
2756
|
+
"wait",
|
|
2757
|
+
"screenshot"
|
|
2758
|
+
]);
|
|
2759
|
+
var WAIT_POLL_INTERVAL_MS2 = 250;
|
|
2760
|
+
var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
|
|
2761
|
+
var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
|
|
2762
|
+
function unquote3(value) {
|
|
2763
|
+
const trimmed = value.trim();
|
|
2764
|
+
const first = trimmed[0];
|
|
2765
|
+
if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
|
|
2766
|
+
return trimmed.slice(1, -1);
|
|
2767
|
+
}
|
|
2768
|
+
return trimmed;
|
|
2769
|
+
}
|
|
2770
|
+
function escapePredicateArg(value) {
|
|
2771
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
2772
|
+
}
|
|
2773
|
+
function normalizeXcuiClassName(role) {
|
|
2774
|
+
return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
|
|
2775
|
+
}
|
|
2776
|
+
function parseIosSelector(selector) {
|
|
2777
|
+
const trimmed = selector.trim();
|
|
2778
|
+
if (trimmed === ":focus") {
|
|
2779
|
+
return { by: "focused" };
|
|
2780
|
+
}
|
|
2781
|
+
const idMatch = /^id=(.+)$/s.exec(trimmed);
|
|
2782
|
+
if (idMatch) {
|
|
2783
|
+
return { by: "accessibilityId", value: unquote3(idMatch[1]) };
|
|
2784
|
+
}
|
|
2785
|
+
const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
|
|
2786
|
+
if (roleMatch) {
|
|
2787
|
+
const name = roleMatch[2] !== void 0 ? unquote3(roleMatch[2]) : void 0;
|
|
2788
|
+
return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
|
|
2789
|
+
}
|
|
2790
|
+
const labelMatch = /^label=(.+)$/s.exec(trimmed);
|
|
2791
|
+
if (labelMatch) {
|
|
2792
|
+
return { by: "label", value: unquote3(labelMatch[1]) };
|
|
2793
|
+
}
|
|
2794
|
+
const textMatch = /^text=(.+)$/s.exec(trimmed);
|
|
2795
|
+
if (textMatch) {
|
|
2796
|
+
return { by: "text", value: unquote3(textMatch[1]) };
|
|
2797
|
+
}
|
|
2798
|
+
return { by: "text", value: trimmed };
|
|
2799
|
+
}
|
|
2800
|
+
function iosQueryToLocator(query) {
|
|
2801
|
+
switch (query.by) {
|
|
2802
|
+
case "accessibilityId":
|
|
2803
|
+
return { using: "accessibility id", value: query.value };
|
|
2804
|
+
case "label":
|
|
2805
|
+
return { using: "predicate string", value: `label == "${escapePredicateArg(query.value)}"` };
|
|
2806
|
+
case "text": {
|
|
2807
|
+
const escaped = escapePredicateArg(query.value);
|
|
2808
|
+
return {
|
|
2809
|
+
using: "predicate string",
|
|
2810
|
+
value: `label CONTAINS "${escaped}" OR value CONTAINS "${escaped}"`
|
|
2811
|
+
};
|
|
2812
|
+
}
|
|
2813
|
+
case "focused":
|
|
2814
|
+
return { using: "predicate string", value: "hasKeyboardFocus == 1" };
|
|
2815
|
+
case "role": {
|
|
2816
|
+
const className = normalizeXcuiClassName(query.role);
|
|
2817
|
+
if (query.name === void 0 || query.name.length === 0) {
|
|
2818
|
+
return { using: "class name", value: className };
|
|
2819
|
+
}
|
|
2820
|
+
const escapedClass = escapePredicateArg(className);
|
|
2821
|
+
const escapedName = escapePredicateArg(query.name);
|
|
2822
|
+
return {
|
|
2823
|
+
using: "predicate string",
|
|
2824
|
+
value: `type == "${escapedClass}" AND (label CONTAINS "${escapedName}" OR value CONTAINS "${escapedName}")`
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
function unwrapIosTextSelector(selector) {
|
|
2830
|
+
const trimmed = selector.trim();
|
|
2831
|
+
if (!trimmed.startsWith("text=")) {
|
|
2832
|
+
return null;
|
|
2833
|
+
}
|
|
2834
|
+
return unquote3(trimmed.slice(5));
|
|
2835
|
+
}
|
|
2836
|
+
function delay2(ms) {
|
|
2837
|
+
return new Promise((resolve) => {
|
|
2838
|
+
setTimeout(resolve, ms);
|
|
2839
|
+
});
|
|
2840
|
+
}
|
|
2841
|
+
function createIosDriver(client, options) {
|
|
2842
|
+
const unsupported = (verb) => new Error(`${verb} is not supported by the iOS target`);
|
|
2843
|
+
const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
|
|
2844
|
+
async function resolveOne(selector) {
|
|
2845
|
+
const id = await client.findElement(parseIosSelector(selector));
|
|
2846
|
+
if (id === null) {
|
|
2847
|
+
throw new Error(`No element matched selector: ${selector}`);
|
|
2848
|
+
}
|
|
2849
|
+
return id;
|
|
2850
|
+
}
|
|
2851
|
+
async function clickSelector(selector) {
|
|
2852
|
+
await client.click(await resolveOne(selector));
|
|
2853
|
+
}
|
|
2854
|
+
async function fillSelector(selector, value) {
|
|
2855
|
+
await client.setValue(await resolveOne(selector), value);
|
|
2856
|
+
}
|
|
2857
|
+
async function pressKey(key) {
|
|
2858
|
+
const name = key.trim().toLowerCase();
|
|
2859
|
+
if (name === "enter" || name === "return") {
|
|
2860
|
+
await client.sendKeys(["\n"]);
|
|
2861
|
+
return;
|
|
2862
|
+
}
|
|
2863
|
+
if (name === "delete" || name === "backspace" || name === "del") {
|
|
2864
|
+
await client.sendKeys(["\b"]);
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
if (name === "home") {
|
|
2868
|
+
await client.homescreen();
|
|
2869
|
+
return;
|
|
2870
|
+
}
|
|
2871
|
+
throw new Error(
|
|
2872
|
+
`Unsupported key "${key}" for the iOS target. Supported keys: ${IOS_PRESS_KEYS.join(", ")}.`
|
|
2873
|
+
);
|
|
2874
|
+
}
|
|
2875
|
+
return {
|
|
2876
|
+
capabilities: IOS_CAPABILITIES,
|
|
2877
|
+
// navigation -----------------------------------------------------------
|
|
2878
|
+
goto(_url, _options) {
|
|
2879
|
+
return rejectUnsupported("navigate");
|
|
2880
|
+
},
|
|
2881
|
+
currentUrl() {
|
|
2882
|
+
return `ios:${options.appLabel ?? ""}`;
|
|
2883
|
+
},
|
|
2884
|
+
// queries --------------------------------------------------------------
|
|
2885
|
+
async count(selector) {
|
|
2886
|
+
return (await client.findElements(parseIosSelector(selector))).length;
|
|
2887
|
+
},
|
|
2888
|
+
async textContent(selector) {
|
|
2889
|
+
const id = await client.findElement(parseIosSelector(selector));
|
|
2890
|
+
if (id === null) {
|
|
2891
|
+
return null;
|
|
2892
|
+
}
|
|
2893
|
+
return client.getText(id);
|
|
2894
|
+
},
|
|
2895
|
+
// interactions ---------------------------------------------------------
|
|
2896
|
+
click: clickSelector,
|
|
2897
|
+
clickFirst: clickSelector,
|
|
2898
|
+
fill: fillSelector,
|
|
2899
|
+
fillFirst: fillSelector,
|
|
2900
|
+
async press(_selector, key) {
|
|
2901
|
+
await pressKey(key);
|
|
2902
|
+
},
|
|
2903
|
+
selectOption() {
|
|
2904
|
+
return rejectUnsupported("select");
|
|
2905
|
+
},
|
|
2906
|
+
selectOptionFirst() {
|
|
2907
|
+
return rejectUnsupported("select");
|
|
2908
|
+
},
|
|
2909
|
+
hover() {
|
|
2910
|
+
return rejectUnsupported("hover");
|
|
2911
|
+
},
|
|
2912
|
+
scrollIntoView() {
|
|
2913
|
+
return rejectUnsupported("scrollTo");
|
|
2914
|
+
},
|
|
2915
|
+
setInputFiles() {
|
|
2916
|
+
return rejectUnsupported("setInputFiles");
|
|
2917
|
+
},
|
|
2918
|
+
// semantic locators ----------------------------------------------------
|
|
2919
|
+
async countByRole(role, name) {
|
|
2920
|
+
return (await client.findElements({ by: "role", role, name })).length;
|
|
2921
|
+
},
|
|
2922
|
+
async clickFirstByRole(role, name) {
|
|
2923
|
+
const id = await client.findElement({ by: "role", role, name });
|
|
2924
|
+
if (id === null) {
|
|
2925
|
+
throw new Error(`No element matched role=${role}[name="${name}"]`);
|
|
2926
|
+
}
|
|
2927
|
+
await client.click(id);
|
|
2928
|
+
},
|
|
2929
|
+
async countByLabel(label) {
|
|
2930
|
+
return (await client.findElements({ by: "label", value: label })).length;
|
|
2931
|
+
},
|
|
2932
|
+
async fillFirstByLabel(label, value) {
|
|
2933
|
+
const id = await client.findElement({ by: "label", value: label });
|
|
2934
|
+
if (id === null) {
|
|
2935
|
+
throw new Error(`No element matched label="${label}"`);
|
|
2936
|
+
}
|
|
2937
|
+
await client.setValue(id, value);
|
|
2938
|
+
},
|
|
2939
|
+
selectOptionFirstByLabel() {
|
|
2940
|
+
return rejectUnsupported("select");
|
|
2941
|
+
},
|
|
2942
|
+
// waiting --------------------------------------------------------------
|
|
2943
|
+
async waitForSelector(selector, waitOptions) {
|
|
2944
|
+
const query = parseIosSelector(selector);
|
|
2945
|
+
const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
|
|
2946
|
+
const deadline = Date.now() + timeoutMs;
|
|
2947
|
+
for (; ; ) {
|
|
2948
|
+
if ((await client.findElements(query)).length > 0) {
|
|
2949
|
+
return;
|
|
2950
|
+
}
|
|
2951
|
+
if (Date.now() >= deadline) {
|
|
2952
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
|
|
2953
|
+
}
|
|
2954
|
+
await delay2(Math.min(WAIT_POLL_INTERVAL_MS2, Math.max(0, deadline - Date.now())));
|
|
2955
|
+
}
|
|
2956
|
+
},
|
|
2957
|
+
waitForUrl() {
|
|
2958
|
+
return rejectUnsupported("waitForUrl");
|
|
2959
|
+
},
|
|
2960
|
+
waitForNetworkIdle() {
|
|
2961
|
+
return rejectUnsupported("waitForNetworkIdle");
|
|
2962
|
+
},
|
|
2963
|
+
// scripting & artifacts ------------------------------------------------
|
|
2964
|
+
evaluate() {
|
|
2965
|
+
return rejectUnsupported("evalScript");
|
|
2966
|
+
},
|
|
2967
|
+
async screenshot(screenshotOptions) {
|
|
2968
|
+
await options.captureScreenshot(screenshotOptions.path);
|
|
2969
|
+
},
|
|
2970
|
+
// network / dialogs / downloads (all web-only) -------------------------
|
|
2971
|
+
onResponse(_handler) {
|
|
2972
|
+
throw unsupported("onResponse");
|
|
2973
|
+
},
|
|
2974
|
+
route(_url, _handler) {
|
|
2975
|
+
return rejectUnsupported("mockRoute");
|
|
2976
|
+
},
|
|
2977
|
+
unroute() {
|
|
2978
|
+
return rejectUnsupported("unmockRoute");
|
|
2979
|
+
},
|
|
2980
|
+
onDialog(_action) {
|
|
2981
|
+
throw unsupported("onDialog");
|
|
2982
|
+
},
|
|
2983
|
+
waitForDownloadEvent() {
|
|
2984
|
+
return rejectUnsupported("waitForDownload");
|
|
2985
|
+
},
|
|
2986
|
+
parseTextSelector(selector) {
|
|
2987
|
+
return unwrapIosTextSelector(selector);
|
|
2988
|
+
}
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
// src/browser/ios-simctl.ts
|
|
2993
|
+
var import_node_child_process5 = require("child_process");
|
|
2994
|
+
var import_promises = require("fs/promises");
|
|
2995
|
+
var import_node_net = __toESM(require("net"), 1);
|
|
2996
|
+
var import_node_os = __toESM(require("os"), 1);
|
|
2997
|
+
var import_node_path6 = __toESM(require("path"), 1);
|
|
2998
|
+
var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path6.default.join(import_node_os.default.tmpdir(), "prowl-ios-simulator-locks");
|
|
2999
|
+
var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
|
|
3000
|
+
var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
|
|
3001
|
+
(0, import_node_child_process5.execFile)(
|
|
3002
|
+
"xcrun",
|
|
3003
|
+
args,
|
|
3004
|
+
{
|
|
3005
|
+
encoding: "utf-8",
|
|
3006
|
+
timeout: options?.timeoutMs,
|
|
3007
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
3008
|
+
env: options?.env ? { ...process.env, ...options.env } : process.env
|
|
3009
|
+
},
|
|
3010
|
+
(error, stdout, stderr) => {
|
|
3011
|
+
const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
|
|
3012
|
+
const capturedStderr = stderr ?? "";
|
|
3013
|
+
resolve({
|
|
3014
|
+
stdout: stdout ?? "",
|
|
3015
|
+
stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
|
|
3016
|
+
code
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
);
|
|
3020
|
+
});
|
|
3021
|
+
function simulatorLockName(udid) {
|
|
3022
|
+
const safe = udid.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
3023
|
+
return `${safe || "simulator"}.lock`;
|
|
3024
|
+
}
|
|
3025
|
+
function isErrno(error, code) {
|
|
3026
|
+
return error?.code === code;
|
|
3027
|
+
}
|
|
3028
|
+
function isProcessAlive(pid) {
|
|
3029
|
+
try {
|
|
3030
|
+
process.kill(pid, 0);
|
|
3031
|
+
return true;
|
|
3032
|
+
} catch (error) {
|
|
3033
|
+
return isErrno(error, "EPERM");
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
async function removeStaleSimulatorLock(lockPath) {
|
|
3037
|
+
try {
|
|
3038
|
+
const ownerText = await (0, import_promises.readFile)(import_node_path6.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
|
|
3039
|
+
const owner = JSON.parse(ownerText);
|
|
3040
|
+
if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
|
|
3041
|
+
await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
|
|
3042
|
+
return true;
|
|
3043
|
+
}
|
|
3044
|
+
} catch {
|
|
3045
|
+
return false;
|
|
3046
|
+
}
|
|
3047
|
+
return false;
|
|
3048
|
+
}
|
|
3049
|
+
function simulatorReservedError(udid) {
|
|
3050
|
+
return new Error(
|
|
3051
|
+
`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.`
|
|
3052
|
+
);
|
|
3053
|
+
}
|
|
3054
|
+
async function reserveSimulatorUdid(udid, options = {}) {
|
|
3055
|
+
const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
|
|
3056
|
+
const lockPath = import_node_path6.default.join(lockRoot, simulatorLockName(udid));
|
|
3057
|
+
await (0, import_promises.mkdir)(lockRoot, { recursive: true });
|
|
3058
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
3059
|
+
try {
|
|
3060
|
+
await (0, import_promises.mkdir)(lockPath);
|
|
3061
|
+
} catch (error) {
|
|
3062
|
+
if (!isErrno(error, "EEXIST")) {
|
|
3063
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
3064
|
+
}
|
|
3065
|
+
if (attempt === 0 && await removeStaleSimulatorLock(lockPath)) {
|
|
3066
|
+
continue;
|
|
3067
|
+
}
|
|
3068
|
+
throw simulatorReservedError(udid);
|
|
3069
|
+
}
|
|
3070
|
+
let released = false;
|
|
3071
|
+
const release = async () => {
|
|
3072
|
+
if (released) {
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
3075
|
+
released = true;
|
|
3076
|
+
await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
|
|
3077
|
+
};
|
|
3078
|
+
try {
|
|
3079
|
+
await (0, import_promises.writeFile)(
|
|
3080
|
+
import_node_path6.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
|
|
3081
|
+
`${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
|
|
3082
|
+
`,
|
|
3083
|
+
{ flag: "wx" }
|
|
3084
|
+
);
|
|
3085
|
+
} catch (error) {
|
|
3086
|
+
await release().catch(() => void 0);
|
|
3087
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
3088
|
+
}
|
|
3089
|
+
return { udid, release };
|
|
3090
|
+
}
|
|
3091
|
+
throw simulatorReservedError(udid);
|
|
3092
|
+
}
|
|
3093
|
+
function findFreePort() {
|
|
3094
|
+
return new Promise((resolve, reject) => {
|
|
3095
|
+
const server = import_node_net.default.createServer();
|
|
3096
|
+
server.on("error", reject);
|
|
3097
|
+
server.listen(0, "127.0.0.1", () => {
|
|
3098
|
+
const address = server.address();
|
|
3099
|
+
if (address && typeof address === "object") {
|
|
3100
|
+
const { port } = address;
|
|
3101
|
+
server.close(() => resolve(port));
|
|
3102
|
+
} else {
|
|
3103
|
+
server.close(() => reject(new Error("Could not allocate a local port")));
|
|
3104
|
+
}
|
|
3105
|
+
});
|
|
3106
|
+
});
|
|
3107
|
+
}
|
|
3108
|
+
function parseSimctlDevices(json) {
|
|
3109
|
+
let parsed;
|
|
3110
|
+
try {
|
|
3111
|
+
parsed = JSON.parse(json);
|
|
3112
|
+
} catch {
|
|
3113
|
+
throw new Error("Could not parse `simctl list devices --json` output.");
|
|
3114
|
+
}
|
|
3115
|
+
const byRuntime = parsed?.devices;
|
|
3116
|
+
if (!byRuntime || typeof byRuntime !== "object") {
|
|
3117
|
+
return [];
|
|
3118
|
+
}
|
|
3119
|
+
const devices = [];
|
|
3120
|
+
for (const [runtime, entries] of Object.entries(byRuntime)) {
|
|
3121
|
+
if (!Array.isArray(entries)) {
|
|
3122
|
+
continue;
|
|
3123
|
+
}
|
|
3124
|
+
for (const entry of entries) {
|
|
3125
|
+
if (!entry || typeof entry !== "object") {
|
|
3126
|
+
continue;
|
|
3127
|
+
}
|
|
3128
|
+
const record = entry;
|
|
3129
|
+
const udid = typeof record.udid === "string" ? record.udid : void 0;
|
|
3130
|
+
const name = typeof record.name === "string" ? record.name : void 0;
|
|
3131
|
+
const state = typeof record.state === "string" ? record.state : "Unknown";
|
|
3132
|
+
if (!udid || !name) {
|
|
3133
|
+
continue;
|
|
3134
|
+
}
|
|
3135
|
+
devices.push({
|
|
3136
|
+
udid,
|
|
3137
|
+
name,
|
|
3138
|
+
state,
|
|
3139
|
+
runtime,
|
|
3140
|
+
isAvailable: record.isAvailable !== false
|
|
3141
|
+
});
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
return devices;
|
|
3145
|
+
}
|
|
3146
|
+
function bootedSimulators(devices) {
|
|
3147
|
+
return devices.filter((device) => device.state === "Booted");
|
|
3148
|
+
}
|
|
3149
|
+
function describeSimulator(device) {
|
|
3150
|
+
const runtime = device.runtime.replace(/^com\.apple\.CoreSimulator\.SimRuntime\./, "");
|
|
3151
|
+
return `${device.name} [${runtime}] (${device.udid})`;
|
|
3152
|
+
}
|
|
3153
|
+
function selectSimulatorUdid(devices, requested) {
|
|
3154
|
+
const booted = bootedSimulators(devices);
|
|
3155
|
+
if (requested) {
|
|
3156
|
+
const match = devices.find((device) => device.udid === requested);
|
|
3157
|
+
if (!match) {
|
|
3158
|
+
const known = devices.length > 0 ? devices.map((d) => d.udid).join(", ") : "none";
|
|
3159
|
+
throw new Error(
|
|
3160
|
+
`iOS simulator "${requested}" was not found. Known simulators: ${known}. Check \`xcrun simctl list devices\`.`
|
|
3161
|
+
);
|
|
3162
|
+
}
|
|
3163
|
+
if (match.state !== "Booted") {
|
|
3164
|
+
throw new Error(
|
|
3165
|
+
`iOS simulator "${requested}" is not booted (state: ${match.state}). Boot it with \`xcrun simctl boot ${requested}\`, then retry.`
|
|
3166
|
+
);
|
|
3167
|
+
}
|
|
3168
|
+
return requested;
|
|
3169
|
+
}
|
|
3170
|
+
if (booted.length === 0) {
|
|
3171
|
+
throw new Error(
|
|
3172
|
+
"No booted iOS simulator found. Boot one from Xcode or with `xcrun simctl boot <udid>` (see `xcrun simctl list devices available`), then retry."
|
|
3173
|
+
);
|
|
3174
|
+
}
|
|
3175
|
+
if (booted.length > 1) {
|
|
3176
|
+
throw new Error(
|
|
3177
|
+
`Multiple iOS simulators are booted (${booted.map(describeSimulator).join("; ")}). Set target.udid to pick one.`
|
|
3178
|
+
);
|
|
3179
|
+
}
|
|
3180
|
+
return booted[0].udid;
|
|
3181
|
+
}
|
|
3182
|
+
async function listSimulators(runner) {
|
|
3183
|
+
const result = await runner(["simctl", "list", "devices", "--json"], { timeoutMs: 3e4 });
|
|
3184
|
+
if (result.code !== 0) {
|
|
3185
|
+
throw new Error(
|
|
3186
|
+
`\`xcrun simctl list devices\` failed. Is Xcode installed and are the command-line tools selected (\`xcode-select -p\`)? ${(result.stderr.trim() || "").slice(0, 400)}`
|
|
3187
|
+
);
|
|
3188
|
+
}
|
|
3189
|
+
return parseSimctlDevices(result.stdout);
|
|
3190
|
+
}
|
|
3191
|
+
async function installApp(runner, udid, appPath) {
|
|
3192
|
+
const result = await runner(["simctl", "install", udid, appPath], { timeoutMs: 12e4 });
|
|
3193
|
+
if (result.code !== 0) {
|
|
3194
|
+
throw new Error(
|
|
3195
|
+
`Failed to install "${appPath}" onto simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
|
|
3196
|
+
);
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
async function uninstallApp(runner, udid, bundleId) {
|
|
3200
|
+
await runner(["simctl", "uninstall", udid, bundleId], { timeoutMs: 6e4 }).catch(() => void 0);
|
|
3201
|
+
}
|
|
3202
|
+
async function launchApp(runner, udid, bundleId, childEnv = {}) {
|
|
3203
|
+
const env = {};
|
|
3204
|
+
for (const [key, value] of Object.entries(childEnv)) {
|
|
3205
|
+
env[`SIMCTL_CHILD_${key}`] = value;
|
|
3206
|
+
}
|
|
3207
|
+
const result = await runner(["simctl", "launch", udid, bundleId], {
|
|
3208
|
+
timeoutMs: 6e4,
|
|
3209
|
+
env: Object.keys(env).length > 0 ? env : void 0
|
|
3210
|
+
});
|
|
3211
|
+
if (result.code !== 0) {
|
|
3212
|
+
throw new Error(
|
|
3213
|
+
`Failed to launch "${bundleId}" on simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}. Is it installed?`
|
|
3214
|
+
);
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
async function terminateApp(runner, udid, bundleId) {
|
|
3218
|
+
await runner(["simctl", "terminate", udid, bundleId], { timeoutMs: 3e4 }).catch(() => void 0);
|
|
3219
|
+
}
|
|
3220
|
+
async function captureScreenshot(runner, udid, outPath) {
|
|
3221
|
+
const result = await runner(["simctl", "io", udid, "screenshot", outPath], { timeoutMs: 3e4 });
|
|
3222
|
+
if (result.code !== 0) {
|
|
3223
|
+
throw new Error(
|
|
3224
|
+
`Failed to capture a simulator screenshot (${udid}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 300)}`
|
|
3225
|
+
);
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
function parseXcodeVersion(stdout) {
|
|
3229
|
+
const match = /Xcode\s+([\d.]+)/i.exec(stdout);
|
|
3230
|
+
return match?.[1] ?? null;
|
|
3231
|
+
}
|
|
3232
|
+
async function xcodeVersion(runner) {
|
|
3233
|
+
const result = await runner(["xcodebuild", "-version"], { timeoutMs: 3e4 });
|
|
3234
|
+
if (result.code !== 0) {
|
|
3235
|
+
return null;
|
|
3236
|
+
}
|
|
3237
|
+
return parseXcodeVersion(result.stdout);
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
// src/browser/ios-agent.ts
|
|
3241
|
+
var DEFAULT_WDA_REQUEST_TIMEOUT_MS = 3e4;
|
|
3242
|
+
var W3C_ELEMENT_KEY2 = "element-6066-11e4-a52e-4f735466cecf";
|
|
3243
|
+
var WdaHttpError = class extends Error {
|
|
3244
|
+
constructor(message, status, webdriverError) {
|
|
3245
|
+
super(message);
|
|
3246
|
+
this.status = status;
|
|
3247
|
+
this.webdriverError = webdriverError;
|
|
3248
|
+
this.name = "WdaHttpError";
|
|
3249
|
+
}
|
|
3250
|
+
};
|
|
3251
|
+
var WdaTransport = class {
|
|
3252
|
+
baseUrl;
|
|
3253
|
+
requestTimeoutMs;
|
|
3254
|
+
fetchImpl;
|
|
3255
|
+
constructor(options) {
|
|
3256
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
3257
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_WDA_REQUEST_TIMEOUT_MS;
|
|
3258
|
+
const injected = options.fetchImpl;
|
|
3259
|
+
if (injected) {
|
|
3260
|
+
this.fetchImpl = injected;
|
|
3261
|
+
} else if (typeof fetch === "function") {
|
|
3262
|
+
this.fetchImpl = (url, init) => fetch(url, init);
|
|
3263
|
+
} else {
|
|
3264
|
+
throw new Error("global fetch is unavailable; Node 20+ is required for the iOS target");
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
/**
|
|
3268
|
+
* Send one request and return the full parsed JSON body. Rejects with a
|
|
3269
|
+
* {@link WdaHttpError} on a non-2xx response, or a timeout error when the
|
|
3270
|
+
* per-request deadline elapses.
|
|
3271
|
+
*/
|
|
3272
|
+
async requestFull(method, path25, body, timeoutMs) {
|
|
3273
|
+
const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
|
|
3274
|
+
const controller = new AbortController();
|
|
3275
|
+
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
3276
|
+
timer.unref?.();
|
|
3277
|
+
const url = `${this.baseUrl}${path25}`;
|
|
3278
|
+
const timeoutError = () => {
|
|
3279
|
+
const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
|
|
3280
|
+
return new Error(`WebDriverAgent request ${method} ${path25} timed out after ${shown}`);
|
|
3281
|
+
};
|
|
3282
|
+
let response;
|
|
3283
|
+
try {
|
|
3284
|
+
response = await this.fetchImpl(url, {
|
|
3285
|
+
method,
|
|
3286
|
+
signal: controller.signal,
|
|
3287
|
+
headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
|
|
3288
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
3289
|
+
});
|
|
3290
|
+
} catch (error) {
|
|
3291
|
+
clearTimeout(timer);
|
|
3292
|
+
if (controller.signal.aborted) {
|
|
3293
|
+
throw timeoutError();
|
|
3294
|
+
}
|
|
3295
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
3296
|
+
}
|
|
3297
|
+
let text;
|
|
3298
|
+
try {
|
|
3299
|
+
text = await response.text();
|
|
3300
|
+
} catch (error) {
|
|
3301
|
+
if (controller.signal.aborted) {
|
|
3302
|
+
throw timeoutError();
|
|
3303
|
+
}
|
|
3304
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
3305
|
+
} finally {
|
|
3306
|
+
clearTimeout(timer);
|
|
3307
|
+
}
|
|
3308
|
+
const parsed = parseJson2(text);
|
|
3309
|
+
if (!response.ok) {
|
|
3310
|
+
const wdError = extractWebdriverError2(parsed);
|
|
3311
|
+
throw new WdaHttpError(
|
|
3312
|
+
`WebDriverAgent ${method} ${path25} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
|
|
3313
|
+
response.status,
|
|
3314
|
+
wdError
|
|
3315
|
+
);
|
|
3316
|
+
}
|
|
3317
|
+
return parsed;
|
|
3318
|
+
}
|
|
3319
|
+
/** Like {@link requestFull} but returns just the `value` field. */
|
|
3320
|
+
async request(method, path25, body, timeoutMs) {
|
|
3321
|
+
const parsed = await this.requestFull(method, path25, body, timeoutMs);
|
|
3322
|
+
return parsed?.value;
|
|
3323
|
+
}
|
|
3324
|
+
};
|
|
3325
|
+
function parseJson2(text) {
|
|
3326
|
+
if (!text) {
|
|
3327
|
+
return void 0;
|
|
3328
|
+
}
|
|
3329
|
+
try {
|
|
3330
|
+
return JSON.parse(text);
|
|
3331
|
+
} catch {
|
|
3332
|
+
return void 0;
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
function extractWebdriverError2(parsed) {
|
|
3336
|
+
const value = parsed?.value;
|
|
3337
|
+
if (value && typeof value === "object") {
|
|
3338
|
+
const record = value;
|
|
3339
|
+
const error = typeof record.error === "string" ? record.error : void 0;
|
|
3340
|
+
const message = typeof record.message === "string" ? record.message : void 0;
|
|
3341
|
+
return error ?? message;
|
|
3342
|
+
}
|
|
3343
|
+
return void 0;
|
|
3344
|
+
}
|
|
3345
|
+
function extractElementId2(value) {
|
|
3346
|
+
if (!value || typeof value !== "object") {
|
|
3347
|
+
return null;
|
|
3348
|
+
}
|
|
3349
|
+
const record = value;
|
|
3350
|
+
const id = record[W3C_ELEMENT_KEY2] ?? record.ELEMENT;
|
|
3351
|
+
return typeof id === "string" ? id : null;
|
|
3352
|
+
}
|
|
3353
|
+
function isNoSuchElement2(error) {
|
|
3354
|
+
if (error instanceof WdaHttpError) {
|
|
3355
|
+
return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
|
|
3356
|
+
}
|
|
3357
|
+
return false;
|
|
3358
|
+
}
|
|
3359
|
+
async function createWdaSession(transport, bundleId) {
|
|
3360
|
+
const body = await transport.requestFull("POST", "/session", {
|
|
3361
|
+
capabilities: { alwaysMatch: { bundleId }, firstMatch: [{}] }
|
|
3362
|
+
});
|
|
3363
|
+
const envelope = body ?? {};
|
|
3364
|
+
const value = envelope.value ?? {};
|
|
3365
|
+
const sessionId = typeof value.sessionId === "string" && value.sessionId || typeof envelope.sessionId === "string" && envelope.sessionId || "";
|
|
3366
|
+
if (sessionId.length > 0) {
|
|
3367
|
+
return sessionId;
|
|
3368
|
+
}
|
|
3369
|
+
throw new Error("WebDriverAgent did not return a session id");
|
|
3370
|
+
}
|
|
3371
|
+
async function waitForWdaReady(transport, options = { deadlineMs: 6e4 }) {
|
|
3372
|
+
const interval = options.intervalMs ?? 300;
|
|
3373
|
+
const deadline = Date.now() + options.deadlineMs;
|
|
3374
|
+
let lastError;
|
|
3375
|
+
for (; ; ) {
|
|
3376
|
+
try {
|
|
3377
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
3378
|
+
await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
|
|
3379
|
+
return;
|
|
3380
|
+
} catch (error) {
|
|
3381
|
+
lastError = error;
|
|
3382
|
+
}
|
|
3383
|
+
if (Date.now() >= deadline) {
|
|
3384
|
+
const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
|
|
3385
|
+
throw new Error(`WebDriverAgent did not become ready within ${options.deadlineMs}ms${detail}`);
|
|
3386
|
+
}
|
|
3387
|
+
await sleep2(Math.min(interval, Math.max(0, deadline - Date.now())));
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
function sleep2(ms) {
|
|
3391
|
+
return new Promise((resolve) => {
|
|
3392
|
+
setTimeout(resolve, ms);
|
|
3393
|
+
});
|
|
3394
|
+
}
|
|
3395
|
+
function createWdaAgentClient(transport, sessionId) {
|
|
3396
|
+
const base = `/session/${sessionId}`;
|
|
3397
|
+
async function locate(query, path25) {
|
|
3398
|
+
return transport.request("POST", `${base}${path25}`, iosQueryToLocator(query));
|
|
3399
|
+
}
|
|
3400
|
+
return {
|
|
3401
|
+
async findElement(query) {
|
|
3402
|
+
try {
|
|
3403
|
+
return extractElementId2(await locate(query, "/element"));
|
|
3404
|
+
} catch (error) {
|
|
3405
|
+
if (isNoSuchElement2(error)) {
|
|
3406
|
+
return null;
|
|
3407
|
+
}
|
|
3408
|
+
throw error;
|
|
3409
|
+
}
|
|
3410
|
+
},
|
|
3411
|
+
async findElements(query) {
|
|
3412
|
+
const value = await locate(query, "/elements");
|
|
3413
|
+
if (!Array.isArray(value)) {
|
|
3414
|
+
return [];
|
|
3415
|
+
}
|
|
3416
|
+
return value.map((entry) => extractElementId2(entry)).filter((id) => id !== null);
|
|
3417
|
+
},
|
|
3418
|
+
async click(elementId) {
|
|
3419
|
+
await transport.request("POST", `${base}/element/${elementId}/click`, {});
|
|
3420
|
+
},
|
|
3421
|
+
async setValue(elementId, text) {
|
|
3422
|
+
await transport.request("POST", `${base}/element/${elementId}/value`, { text });
|
|
3423
|
+
},
|
|
3424
|
+
async getText(elementId) {
|
|
3425
|
+
const value = await transport.request("GET", `${base}/element/${elementId}/text`);
|
|
3426
|
+
return typeof value === "string" ? value : value == null ? null : String(value);
|
|
3427
|
+
},
|
|
3428
|
+
async sendKeys(keys) {
|
|
3429
|
+
await transport.request("POST", `${base}/wda/keys`, { value: keys });
|
|
3430
|
+
},
|
|
3431
|
+
async homescreen() {
|
|
3432
|
+
await transport.request("POST", "/wda/homescreen", {});
|
|
3433
|
+
},
|
|
3434
|
+
async close() {
|
|
3435
|
+
await transport.request("DELETE", base).catch(() => void 0);
|
|
3436
|
+
}
|
|
3437
|
+
};
|
|
3438
|
+
}
|
|
3439
|
+
|
|
3440
|
+
// src/browser/ios-helper.ts
|
|
3441
|
+
var import_meta3 = {};
|
|
3442
|
+
var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
|
|
3443
|
+
var WDA_RUNNER_APP_NAME = "WebDriverAgentRunner-Runner.app";
|
|
3444
|
+
var WDA_USE_PORT_ENV = "USE_PORT";
|
|
3445
|
+
var WDA_STARTUP_ATTEMPTS = 2;
|
|
3446
|
+
var defaultIosAgentConnector = async ({
|
|
3447
|
+
host,
|
|
3448
|
+
port,
|
|
3449
|
+
bundleId,
|
|
3450
|
+
requestTimeoutMs,
|
|
3451
|
+
readyDeadlineMs
|
|
3452
|
+
}) => {
|
|
3453
|
+
const transport = new WdaTransport({ baseUrl: `http://${host}:${port}`, requestTimeoutMs });
|
|
3454
|
+
await waitForWdaReady(transport, { deadlineMs: readyDeadlineMs });
|
|
3455
|
+
const sessionId = await createWdaSession(transport, bundleId);
|
|
3456
|
+
return createWdaAgentClient(transport, sessionId);
|
|
3457
|
+
};
|
|
3458
|
+
function resolveWdaProject(requireFn = (0, import_node_module2.createRequire)(import_meta3.url)) {
|
|
3459
|
+
let pkgJsonPath;
|
|
3460
|
+
try {
|
|
3461
|
+
pkgJsonPath = requireFn.resolve("appium-webdriveragent/package.json");
|
|
3462
|
+
} catch {
|
|
3463
|
+
throw new Error(
|
|
3464
|
+
"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"
|
|
3465
|
+
);
|
|
3466
|
+
}
|
|
3467
|
+
const pkgDir = import_node_path7.default.dirname(pkgJsonPath);
|
|
3468
|
+
const version = requireFn(pkgJsonPath).version;
|
|
3469
|
+
const projectPath = import_node_path7.default.join(pkgDir, "WebDriverAgent.xcodeproj");
|
|
3470
|
+
if (!import_node_fs7.default.existsSync(projectPath)) {
|
|
3471
|
+
throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
|
|
3472
|
+
}
|
|
3473
|
+
return { projectPath, version };
|
|
3474
|
+
}
|
|
3475
|
+
function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os2.default.homedir()) {
|
|
3476
|
+
return import_node_path7.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
|
|
3477
|
+
}
|
|
3478
|
+
function runnerAppPath(derivedDataPath) {
|
|
3479
|
+
return import_node_path7.default.join(derivedDataPath, "Build", "Products", "Debug-iphonesimulator", WDA_RUNNER_APP_NAME);
|
|
3480
|
+
}
|
|
3481
|
+
async function resolveWdaRunner(options = {}) {
|
|
3482
|
+
const runner = options.runner ?? execFileXcrunRunner;
|
|
3483
|
+
const env = options.env ?? process.env;
|
|
3484
|
+
const homeDir = options.homeDir ?? import_node_os2.default.homedir();
|
|
3485
|
+
const log = options.logger ?? ((message) => process.stderr.write(`${message}
|
|
3486
|
+
`));
|
|
3487
|
+
const override = env.PROWL_WDA_RUNNER;
|
|
3488
|
+
if (override) {
|
|
3489
|
+
if (!import_node_fs7.default.existsSync(override)) {
|
|
3490
|
+
throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
|
|
3491
|
+
}
|
|
3492
|
+
return override;
|
|
3493
|
+
}
|
|
3494
|
+
const { projectPath, version } = resolveWdaProject(options.requireFn);
|
|
3495
|
+
const xcode = await xcodeVersion(runner);
|
|
3496
|
+
if (!xcode) {
|
|
3497
|
+
throw new Error(
|
|
3498
|
+
"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."
|
|
3499
|
+
);
|
|
3500
|
+
}
|
|
3501
|
+
const cacheDir = wdaCacheDir(version, xcode, homeDir);
|
|
3502
|
+
const cachedRunner = runnerAppPath(cacheDir);
|
|
3503
|
+
if (import_node_fs7.default.existsSync(cachedRunner)) {
|
|
3504
|
+
return cachedRunner;
|
|
3505
|
+
}
|
|
3506
|
+
log(
|
|
3507
|
+
`Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
|
|
3508
|
+
);
|
|
3509
|
+
import_node_fs7.default.mkdirSync(cacheDir, { recursive: true });
|
|
3510
|
+
const result = await runner(
|
|
3511
|
+
[
|
|
3512
|
+
"xcodebuild",
|
|
3513
|
+
"build-for-testing",
|
|
3514
|
+
"-project",
|
|
3515
|
+
projectPath,
|
|
3516
|
+
"-scheme",
|
|
3517
|
+
"WebDriverAgentRunner",
|
|
3518
|
+
"-destination",
|
|
3519
|
+
"generic/platform=iOS Simulator",
|
|
3520
|
+
"-derivedDataPath",
|
|
3521
|
+
cacheDir,
|
|
3522
|
+
"CODE_SIGNING_ALLOWED=NO"
|
|
3523
|
+
],
|
|
3524
|
+
{ timeoutMs: 12e5 }
|
|
3525
|
+
);
|
|
3526
|
+
if (result.code !== 0) {
|
|
3527
|
+
throw new Error(
|
|
3528
|
+
`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)}`
|
|
3529
|
+
);
|
|
3530
|
+
}
|
|
3531
|
+
if (!import_node_fs7.default.existsSync(cachedRunner)) {
|
|
3532
|
+
throw new Error(
|
|
3533
|
+
`WebDriverAgent build succeeded but the runner app was not found at ${cachedRunner}. This may indicate an Xcode layout change; set PROWL_WDA_RUNNER to a prebuilt runner.`
|
|
3534
|
+
);
|
|
3535
|
+
}
|
|
3536
|
+
return cachedRunner;
|
|
3537
|
+
}
|
|
3538
|
+
async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
|
|
3539
|
+
if (!looksLikeIosAppPath(app)) {
|
|
3540
|
+
assertIosAppAllowed(allowedApps, app);
|
|
3541
|
+
if (coldStart) {
|
|
3542
|
+
throw new Error(
|
|
3543
|
+
`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.`
|
|
3544
|
+
);
|
|
3545
|
+
}
|
|
3546
|
+
return app;
|
|
3547
|
+
}
|
|
3548
|
+
const appPath = import_node_path7.default.resolve(app);
|
|
3549
|
+
if (!import_node_fs7.default.existsSync(appPath)) {
|
|
3550
|
+
throw new Error(`.app bundle not found: ${appPath}`);
|
|
3551
|
+
}
|
|
3552
|
+
const bundleId = readIosBundleIdentifier(appPath);
|
|
3553
|
+
if (!bundleId) {
|
|
3554
|
+
throw new Error(
|
|
3555
|
+
`Could not read CFBundleIdentifier from "${appPath}" (root Info.plist). Ensure target.app points at a built iOS .app bundle.`
|
|
3556
|
+
);
|
|
3557
|
+
}
|
|
3558
|
+
assertIosAppAllowed(allowedApps, appPath);
|
|
3559
|
+
if (coldStart) {
|
|
3560
|
+
await uninstallApp(runner, udid, bundleId);
|
|
3561
|
+
}
|
|
3562
|
+
await installApp(runner, udid, appPath);
|
|
3563
|
+
return bundleId;
|
|
3564
|
+
}
|
|
3565
|
+
async function launchIosSession(options) {
|
|
3566
|
+
const runner = options.runner ?? execFileXcrunRunner;
|
|
3567
|
+
const portAllocator = options.portAllocator ?? findFreePort;
|
|
3568
|
+
const connector = options.agentConnector ?? defaultIosAgentConnector;
|
|
3569
|
+
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
|
|
3570
|
+
const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, 6e4);
|
|
3571
|
+
const devices = await listSimulators(runner);
|
|
3572
|
+
const udid = selectSimulatorUdid(devices, options.udid);
|
|
3573
|
+
const reservation = await reserveSimulatorUdid(udid, { lockRoot: options.simulatorLockRoot });
|
|
3574
|
+
let reservationReleased = false;
|
|
3575
|
+
const releaseReservation = async () => {
|
|
3576
|
+
if (reservationReleased) {
|
|
1817
3577
|
return;
|
|
1818
3578
|
}
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
this.child.stdin?.end();
|
|
1823
|
-
} catch {
|
|
1824
|
-
}
|
|
1825
|
-
await new Promise((resolve) => {
|
|
1826
|
-
if (this.child.exitCode !== null || this.child.signalCode !== null) {
|
|
1827
|
-
resolve();
|
|
1828
|
-
return;
|
|
1829
|
-
}
|
|
1830
|
-
const timer = setTimeout(() => {
|
|
1831
|
-
this.child.kill("SIGKILL");
|
|
1832
|
-
resolve();
|
|
1833
|
-
}, 2e3);
|
|
1834
|
-
this.child.once("exit", () => {
|
|
1835
|
-
clearTimeout(timer);
|
|
1836
|
-
resolve();
|
|
1837
|
-
});
|
|
1838
|
-
});
|
|
1839
|
-
this.failAll(new Error("prowl-macdriver client is closed"));
|
|
1840
|
-
}
|
|
1841
|
-
};
|
|
1842
|
-
async function launchMacSession(options) {
|
|
1843
|
-
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_REQUEST_TIMEOUT_MS) + 5e3;
|
|
1844
|
-
const client = options.clientFactory ? options.clientFactory() : new SpawnMacHelperClient(resolveHelperBinary(), { requestTimeoutMs });
|
|
1845
|
-
const timeoutSeconds = (options.timeoutMs ?? 1e4) / 1e3;
|
|
3579
|
+
reservationReleased = true;
|
|
3580
|
+
await reservation.release();
|
|
3581
|
+
};
|
|
1846
3582
|
try {
|
|
1847
|
-
const
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
3583
|
+
const bundleId = await resolveBundleId(
|
|
3584
|
+
options.app,
|
|
3585
|
+
runner,
|
|
3586
|
+
udid,
|
|
3587
|
+
options.coldStart ?? false,
|
|
3588
|
+
options.allowedApps ?? []
|
|
3589
|
+
);
|
|
3590
|
+
const wdaRunnerApp = options.wdaRunnerApp ?? await resolveWdaRunner({ runner, logger: options.logger });
|
|
3591
|
+
await installApp(runner, udid, wdaRunnerApp);
|
|
3592
|
+
for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
|
|
3593
|
+
let client;
|
|
3594
|
+
let tornDown = false;
|
|
3595
|
+
let terminateWda = false;
|
|
3596
|
+
let terminateTarget = false;
|
|
3597
|
+
let stage = "port";
|
|
3598
|
+
const teardownAttempt = async () => {
|
|
3599
|
+
if (tornDown) {
|
|
3600
|
+
return;
|
|
3601
|
+
}
|
|
3602
|
+
tornDown = true;
|
|
3603
|
+
if (client) {
|
|
3604
|
+
await client.close().catch(() => void 0);
|
|
3605
|
+
}
|
|
3606
|
+
if (terminateWda) {
|
|
3607
|
+
await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
|
|
3608
|
+
}
|
|
3609
|
+
if (terminateTarget) {
|
|
3610
|
+
await terminateApp(runner, udid, bundleId);
|
|
3611
|
+
}
|
|
3612
|
+
};
|
|
3613
|
+
try {
|
|
3614
|
+
const port = await portAllocator();
|
|
3615
|
+
stage = "wda-launch";
|
|
3616
|
+
terminateWda = true;
|
|
3617
|
+
await launchApp(runner, udid, WDA_RUNNER_BUNDLE_ID, { [WDA_USE_PORT_ENV]: String(port) });
|
|
3618
|
+
stage = "target-launch";
|
|
3619
|
+
terminateTarget = true;
|
|
3620
|
+
await launchApp(runner, udid, bundleId);
|
|
3621
|
+
stage = "connect";
|
|
3622
|
+
client = await connector({ host: "127.0.0.1", port, bundleId, requestTimeoutMs, readyDeadlineMs });
|
|
3623
|
+
const driver = createIosDriver(client, {
|
|
3624
|
+
appLabel: bundleId,
|
|
3625
|
+
captureScreenshot: (outPath) => captureScreenshot(runner, udid, outPath)
|
|
3626
|
+
});
|
|
3627
|
+
const teardown = async () => {
|
|
3628
|
+
await teardownAttempt();
|
|
3629
|
+
await releaseReservation();
|
|
3630
|
+
};
|
|
3631
|
+
return { client, driver, bundleId, udid, teardown };
|
|
3632
|
+
} catch (error) {
|
|
3633
|
+
await teardownAttempt();
|
|
3634
|
+
if (stage === "target-launch" || attempt >= WDA_STARTUP_ATTEMPTS) {
|
|
3635
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
1852
3638
|
}
|
|
1853
|
-
|
|
1854
|
-
const bundleId = String(launched.bundleId ?? options.app);
|
|
1855
|
-
const driver = createMacDriver(client, { appLabel: bundleId });
|
|
1856
|
-
return { client, driver, bundleId };
|
|
3639
|
+
throw new Error("WebDriverAgent startup failed without an error.");
|
|
1857
3640
|
} catch (error) {
|
|
1858
|
-
await
|
|
3641
|
+
await releaseReservation().catch(() => void 0);
|
|
1859
3642
|
throw error;
|
|
1860
3643
|
}
|
|
1861
3644
|
}
|
|
1862
|
-
async function
|
|
1863
|
-
|
|
1864
|
-
await session.client.request("quit");
|
|
1865
|
-
} catch {
|
|
1866
|
-
} finally {
|
|
1867
|
-
await session.client.close();
|
|
1868
|
-
}
|
|
3645
|
+
async function closeIosSession(session) {
|
|
3646
|
+
await session.teardown();
|
|
1869
3647
|
}
|
|
1870
3648
|
|
|
1871
3649
|
// src/runner/steps.ts
|
|
1872
|
-
var
|
|
1873
|
-
var
|
|
3650
|
+
var import_node_fs9 = __toESM(require("fs"), 1);
|
|
3651
|
+
var import_node_path9 = __toESM(require("path"), 1);
|
|
1874
3652
|
init_loader();
|
|
1875
3653
|
|
|
1876
3654
|
// src/runner/healing.ts
|
|
@@ -1915,8 +3693,8 @@ async function healSelector(probe, selector, options) {
|
|
|
1915
3693
|
for (const candidate of buildHealCandidates(selector)) {
|
|
1916
3694
|
let count;
|
|
1917
3695
|
try {
|
|
1918
|
-
const
|
|
1919
|
-
count = await
|
|
3696
|
+
const locator2 = probe.locator(candidate.selector);
|
|
3697
|
+
count = await locator2.count();
|
|
1920
3698
|
} catch {
|
|
1921
3699
|
continue;
|
|
1922
3700
|
}
|
|
@@ -2361,7 +4139,7 @@ async function runInlineAssert(driver, policy, assertion) {
|
|
|
2361
4139
|
throw new Error("assert step is missing an assertion type");
|
|
2362
4140
|
}
|
|
2363
4141
|
function screenshotPath(screenshotsDir, fileName) {
|
|
2364
|
-
return
|
|
4142
|
+
return import_node_path9.default.join(screenshotsDir, fileName);
|
|
2365
4143
|
}
|
|
2366
4144
|
function stepPath(prefix, index) {
|
|
2367
4145
|
return prefix ? `${prefix}.${index}` : `${index}`;
|
|
@@ -2378,12 +4156,12 @@ function validateDownloadFilename(suggestedFilename) {
|
|
|
2378
4156
|
const safeFilename = suggestedFilename.trim();
|
|
2379
4157
|
const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
|
|
2380
4158
|
const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
|
|
2381
|
-
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !==
|
|
4159
|
+
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path9.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
|
|
2382
4160
|
throw new Error(`Invalid download filename: "${suggestedFilename}"`);
|
|
2383
4161
|
}
|
|
2384
4162
|
return safeFilename;
|
|
2385
4163
|
}
|
|
2386
|
-
async function
|
|
4164
|
+
async function captureScreenshot2(taker, filePath) {
|
|
2387
4165
|
try {
|
|
2388
4166
|
await taker.screenshot({ path: filePath, fullPage: true });
|
|
2389
4167
|
} catch (error) {
|
|
@@ -2558,7 +4336,7 @@ var STEP_HANDLERS = {
|
|
|
2558
4336
|
if (!("setInputFiles" in h.step)) unknownStep();
|
|
2559
4337
|
const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
|
|
2560
4338
|
const rawFiles = h.step.setInputFiles.files;
|
|
2561
|
-
const resolveFile = (f) =>
|
|
4339
|
+
const resolveFile = (f) => import_node_path9.default.isAbsolute(f) ? f : import_node_path9.default.join(h.context.configDir, f);
|
|
2562
4340
|
const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
|
|
2563
4341
|
await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
|
|
2564
4342
|
h.policy.ensureLocationAllowed(h.driver);
|
|
@@ -2595,7 +4373,7 @@ var STEP_HANDLERS = {
|
|
|
2595
4373
|
redactedFillSteps: subRedacted,
|
|
2596
4374
|
randomVars
|
|
2597
4375
|
} = interpolateHunt(subHunt, process.env, h.context.randomVars);
|
|
2598
|
-
const subTargetType = h.driver.capabilities.has("navigate") ? "web" : "macos";
|
|
4376
|
+
const subTargetType = h.context.targetType ?? (h.driver.capabilities.has("navigate") ? "web" : "macos");
|
|
2599
4377
|
assertStepsSupportedByTarget(interpolatedSubHunt.steps, subTargetType);
|
|
2600
4378
|
assertHuntAssertionsSupportedByTarget(interpolatedSubHunt.assertions, subTargetType);
|
|
2601
4379
|
h.policy.assertWithinMaxSteps(interpolatedSubHunt.steps.length, huntName);
|
|
@@ -2920,15 +4698,15 @@ var STEP_HANDLERS = {
|
|
|
2920
4698
|
if (!responseFile) {
|
|
2921
4699
|
throw new Error("mock.response must include either body or file");
|
|
2922
4700
|
}
|
|
2923
|
-
const candidateFilePath =
|
|
2924
|
-
const resolvedConfigDir =
|
|
2925
|
-
const resolvedFilePath =
|
|
2926
|
-
const relativePath =
|
|
2927
|
-
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${
|
|
4701
|
+
const candidateFilePath = import_node_path9.default.isAbsolute(responseFile) ? responseFile : import_node_path9.default.join(h.context.configDir, responseFile);
|
|
4702
|
+
const resolvedConfigDir = import_node_path9.default.resolve(h.context.configDir);
|
|
4703
|
+
const resolvedFilePath = import_node_path9.default.resolve(candidateFilePath);
|
|
4704
|
+
const relativePath = import_node_path9.default.relative(resolvedConfigDir, resolvedFilePath);
|
|
4705
|
+
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path9.default.sep}`) && !import_node_path9.default.isAbsolute(relativePath);
|
|
2928
4706
|
if (!isWithinConfigDir) {
|
|
2929
4707
|
throw new Error("mock.response.file must resolve within config directory");
|
|
2930
4708
|
}
|
|
2931
|
-
responseBody = await
|
|
4709
|
+
responseBody = await import_node_fs9.default.promises.readFile(resolvedFilePath, "utf-8");
|
|
2932
4710
|
}
|
|
2933
4711
|
const contentType = mock.response.contentType ?? "application/json";
|
|
2934
4712
|
const status = mock.response.status;
|
|
@@ -2991,8 +4769,8 @@ var STEP_HANDLERS = {
|
|
|
2991
4769
|
capabilities: ["evaluate"],
|
|
2992
4770
|
run: async (h) => {
|
|
2993
4771
|
if (!("runScript" in h.step)) unknownStep();
|
|
2994
|
-
const filePath =
|
|
2995
|
-
const fileContents =
|
|
4772
|
+
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);
|
|
4773
|
+
const fileContents = import_node_fs9.default.readFileSync(filePath, "utf-8");
|
|
2996
4774
|
await h.driver.evaluate(fileContents);
|
|
2997
4775
|
return {
|
|
2998
4776
|
kind: "result",
|
|
@@ -3013,19 +4791,19 @@ var STEP_HANDLERS = {
|
|
|
3013
4791
|
const name = h.step.assertScreenshot.name;
|
|
3014
4792
|
const threshold = h.step.assertScreenshot.threshold ?? 0.1;
|
|
3015
4793
|
const baselineDir = ensureBaselineDir2(h.context.configDir);
|
|
3016
|
-
const baselinePath =
|
|
3017
|
-
const currentScreenshotPath =
|
|
3018
|
-
|
|
4794
|
+
const baselinePath = import_node_path9.default.join(baselineDir, `${name}.png`);
|
|
4795
|
+
const currentScreenshotPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
|
|
4796
|
+
import_node_fs9.default.mkdirSync(import_node_path9.default.dirname(currentScreenshotPath), { recursive: true });
|
|
3019
4797
|
await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
|
|
3020
|
-
h.screenshots.push(
|
|
3021
|
-
if (!
|
|
3022
|
-
|
|
4798
|
+
h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-current.png`));
|
|
4799
|
+
if (!import_node_fs9.default.existsSync(baselinePath)) {
|
|
4800
|
+
import_node_fs9.default.copyFileSync(currentScreenshotPath, baselinePath);
|
|
3023
4801
|
return {
|
|
3024
4802
|
kind: "result",
|
|
3025
4803
|
result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
|
|
3026
4804
|
};
|
|
3027
4805
|
}
|
|
3028
|
-
const diffPath =
|
|
4806
|
+
const diffPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
|
|
3029
4807
|
const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
|
|
3030
4808
|
if (comparison.match) {
|
|
3031
4809
|
return {
|
|
@@ -3038,7 +4816,7 @@ var STEP_HANDLERS = {
|
|
|
3038
4816
|
}
|
|
3039
4817
|
};
|
|
3040
4818
|
}
|
|
3041
|
-
h.screenshots.push(
|
|
4819
|
+
h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-diff.png`));
|
|
3042
4820
|
throw new Error(
|
|
3043
4821
|
`Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
|
|
3044
4822
|
);
|
|
@@ -3080,7 +4858,7 @@ var STEP_HANDLERS = {
|
|
|
3080
4858
|
`Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
|
|
3081
4859
|
);
|
|
3082
4860
|
}
|
|
3083
|
-
const savePath =
|
|
4861
|
+
const savePath = import_node_path9.default.join(h.context.runDir, suggestedFilename);
|
|
3084
4862
|
await download.saveAs(savePath);
|
|
3085
4863
|
return {
|
|
3086
4864
|
kind: "result",
|
|
@@ -3109,8 +4887,8 @@ async function executeSteps(context) {
|
|
|
3109
4887
|
maxSteps: context.maxSteps,
|
|
3110
4888
|
selfHealing: context.selfHealing
|
|
3111
4889
|
});
|
|
3112
|
-
const screenshotsDir =
|
|
3113
|
-
|
|
4890
|
+
const screenshotsDir = import_node_path9.default.join(context.runDir, "screenshots");
|
|
4891
|
+
import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
|
|
3114
4892
|
const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
|
|
3115
4893
|
policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
|
|
3116
4894
|
const results = [];
|
|
@@ -3119,8 +4897,8 @@ async function executeSteps(context) {
|
|
|
3119
4897
|
context.runStartedAtMs = runStartedAtMs;
|
|
3120
4898
|
const addScreenshot = async (fileName) => {
|
|
3121
4899
|
const fullPath = screenshotPath(screenshotsDir, fileName);
|
|
3122
|
-
await
|
|
3123
|
-
const relative =
|
|
4900
|
+
await captureScreenshot2(driver, fullPath);
|
|
4901
|
+
const relative = import_node_path9.default.join("screenshots", fileName);
|
|
3124
4902
|
screenshots.push(relative);
|
|
3125
4903
|
return relative;
|
|
3126
4904
|
};
|
|
@@ -3208,12 +4986,12 @@ async function executeSteps(context) {
|
|
|
3208
4986
|
return { results, screenshots, failed: false };
|
|
3209
4987
|
}
|
|
3210
4988
|
async function captureFinalScreenshot(page, runDir) {
|
|
3211
|
-
const screenshotsDir =
|
|
3212
|
-
|
|
4989
|
+
const screenshotsDir = import_node_path9.default.join(runDir, "screenshots");
|
|
4990
|
+
import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
|
|
3213
4991
|
const fileName = "final.png";
|
|
3214
4992
|
const filePath = screenshotPath(screenshotsDir, fileName);
|
|
3215
|
-
await
|
|
3216
|
-
return
|
|
4993
|
+
await captureScreenshot2(page, filePath);
|
|
4994
|
+
return import_node_path9.default.join("screenshots", fileName);
|
|
3217
4995
|
}
|
|
3218
4996
|
|
|
3219
4997
|
// src/runner/assertions.ts
|
|
@@ -3373,18 +5151,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
|
|
|
3373
5151
|
}
|
|
3374
5152
|
|
|
3375
5153
|
// src/reporter/result.ts
|
|
3376
|
-
var
|
|
3377
|
-
var
|
|
5154
|
+
var import_node_fs10 = __toESM(require("fs"), 1);
|
|
5155
|
+
var import_node_path10 = __toESM(require("path"), 1);
|
|
3378
5156
|
function writeResult(runDir, result) {
|
|
3379
5157
|
const fileName = "result.json";
|
|
3380
|
-
const fullPath =
|
|
3381
|
-
|
|
5158
|
+
const fullPath = import_node_path10.default.join(runDir, fileName);
|
|
5159
|
+
import_node_fs10.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
|
|
3382
5160
|
return fileName;
|
|
3383
5161
|
}
|
|
3384
5162
|
|
|
3385
5163
|
// src/reporter/summary.ts
|
|
3386
|
-
var
|
|
3387
|
-
var
|
|
5164
|
+
var import_node_fs11 = __toESM(require("fs"), 1);
|
|
5165
|
+
var import_node_path11 = __toESM(require("path"), 1);
|
|
3388
5166
|
function escapeMd(text) {
|
|
3389
5167
|
return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
|
|
3390
5168
|
}
|
|
@@ -3462,15 +5240,15 @@ function writeSummary(runDir, result) {
|
|
|
3462
5240
|
}
|
|
3463
5241
|
}
|
|
3464
5242
|
const fileName = "summary.md";
|
|
3465
|
-
const fullPath =
|
|
3466
|
-
|
|
5243
|
+
const fullPath = import_node_path11.default.join(runDir, fileName);
|
|
5244
|
+
import_node_fs11.default.writeFileSync(fullPath, `${lines.join("\n")}
|
|
3467
5245
|
`);
|
|
3468
5246
|
return fileName;
|
|
3469
5247
|
}
|
|
3470
5248
|
|
|
3471
5249
|
// src/reporter/junit.ts
|
|
3472
|
-
var
|
|
3473
|
-
var
|
|
5250
|
+
var import_node_fs12 = __toESM(require("fs"), 1);
|
|
5251
|
+
var import_node_path12 = __toESM(require("path"), 1);
|
|
3474
5252
|
function escapeXml(text) {
|
|
3475
5253
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3476
5254
|
}
|
|
@@ -3514,8 +5292,8 @@ function writeJunit(runDir, result) {
|
|
|
3514
5292
|
lines.push(" </testsuite>");
|
|
3515
5293
|
lines.push("</testsuites>");
|
|
3516
5294
|
const fileName = "junit.xml";
|
|
3517
|
-
const fullPath =
|
|
3518
|
-
|
|
5295
|
+
const fullPath = import_node_path12.default.join(runDir, fileName);
|
|
5296
|
+
import_node_fs12.default.writeFileSync(fullPath, `${lines.join("\n")}
|
|
3519
5297
|
`);
|
|
3520
5298
|
return fileName;
|
|
3521
5299
|
}
|
|
@@ -3549,15 +5327,15 @@ function timestamp(prefix) {
|
|
|
3549
5327
|
}
|
|
3550
5328
|
|
|
3551
5329
|
// src/runner/history.ts
|
|
3552
|
-
var
|
|
3553
|
-
var
|
|
5330
|
+
var import_node_fs13 = __toESM(require("fs"), 1);
|
|
5331
|
+
var import_node_path13 = __toESM(require("path"), 1);
|
|
3554
5332
|
var HISTORY_FILE = "history.json";
|
|
3555
5333
|
var LOCK_FILE_SUFFIX = ".lock";
|
|
3556
5334
|
var LOCK_RETRY_MS = 10;
|
|
3557
5335
|
var LOCK_TIMEOUT_MS = 5e3;
|
|
3558
5336
|
var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
3559
5337
|
function historyPath(configDir) {
|
|
3560
|
-
return
|
|
5338
|
+
return import_node_path13.default.join(configDir, HISTORY_FILE);
|
|
3561
5339
|
}
|
|
3562
5340
|
function isHistoryEntry(value) {
|
|
3563
5341
|
if (!value || typeof value !== "object") {
|
|
@@ -3568,11 +5346,11 @@ function isHistoryEntry(value) {
|
|
|
3568
5346
|
}
|
|
3569
5347
|
function readHistory(configDir) {
|
|
3570
5348
|
const filePath = historyPath(configDir);
|
|
3571
|
-
if (!
|
|
5349
|
+
if (!import_node_fs13.default.existsSync(filePath)) {
|
|
3572
5350
|
return { entries: [] };
|
|
3573
5351
|
}
|
|
3574
5352
|
try {
|
|
3575
|
-
const raw =
|
|
5353
|
+
const raw = import_node_fs13.default.readFileSync(filePath, "utf-8");
|
|
3576
5354
|
const parsed = JSON.parse(raw);
|
|
3577
5355
|
if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
|
|
3578
5356
|
const validatedEntries = parsed.entries.filter(isHistoryEntry);
|
|
@@ -3611,12 +5389,12 @@ function sleepSync(ms) {
|
|
|
3611
5389
|
function withHistoryLock(configDir, fn) {
|
|
3612
5390
|
const filePath = historyPath(configDir);
|
|
3613
5391
|
const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
|
|
3614
|
-
|
|
5392
|
+
import_node_fs13.default.mkdirSync(import_node_path13.default.dirname(filePath), { recursive: true });
|
|
3615
5393
|
const startedAt = Date.now();
|
|
3616
5394
|
while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
|
|
3617
5395
|
let fd;
|
|
3618
5396
|
try {
|
|
3619
|
-
fd =
|
|
5397
|
+
fd = import_node_fs13.default.openSync(lockPath, "wx");
|
|
3620
5398
|
} catch (error) {
|
|
3621
5399
|
if (error.code === "EEXIST") {
|
|
3622
5400
|
sleepSync(LOCK_RETRY_MS);
|
|
@@ -3628,10 +5406,10 @@ function withHistoryLock(configDir, fn) {
|
|
|
3628
5406
|
return fn();
|
|
3629
5407
|
} finally {
|
|
3630
5408
|
try {
|
|
3631
|
-
|
|
5409
|
+
import_node_fs13.default.closeSync(fd);
|
|
3632
5410
|
} catch {
|
|
3633
5411
|
}
|
|
3634
|
-
|
|
5412
|
+
import_node_fs13.default.rmSync(lockPath, { force: true });
|
|
3635
5413
|
}
|
|
3636
5414
|
}
|
|
3637
5415
|
throw new Error(
|
|
@@ -3644,9 +5422,9 @@ function appendEntry(configDir, entry, maxRuns) {
|
|
|
3644
5422
|
const current = readHistory(configDir);
|
|
3645
5423
|
const next = pruneEntries([...current.entries, entry], maxRuns);
|
|
3646
5424
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
3647
|
-
|
|
5425
|
+
import_node_fs13.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
|
|
3648
5426
|
`);
|
|
3649
|
-
|
|
5427
|
+
import_node_fs13.default.renameSync(tempPath, filePath);
|
|
3650
5428
|
});
|
|
3651
5429
|
}
|
|
3652
5430
|
|
|
@@ -3659,11 +5437,11 @@ function parseViewportFlag(value) {
|
|
|
3659
5437
|
return value;
|
|
3660
5438
|
}
|
|
3661
5439
|
function resolvePath(configDir, inputPath) {
|
|
3662
|
-
if (
|
|
5440
|
+
if (import_node_path14.default.isAbsolute(inputPath)) {
|
|
3663
5441
|
return inputPath;
|
|
3664
5442
|
}
|
|
3665
|
-
const projectRoot =
|
|
3666
|
-
return
|
|
5443
|
+
const projectRoot = import_node_path14.default.dirname(configDir);
|
|
5444
|
+
return import_node_path14.default.join(projectRoot, inputPath);
|
|
3667
5445
|
}
|
|
3668
5446
|
function buildRunResult(options) {
|
|
3669
5447
|
return {
|
|
@@ -3682,12 +5460,12 @@ function buildRunResult(options) {
|
|
|
3682
5460
|
}
|
|
3683
5461
|
function writeConsoleLog(runDir, entries) {
|
|
3684
5462
|
const fileName = "console.log";
|
|
3685
|
-
const filePath =
|
|
5463
|
+
const filePath = import_node_path14.default.join(runDir, fileName);
|
|
3686
5464
|
const lines = entries.map((entry) => {
|
|
3687
5465
|
const location = entry.location ? ` (${entry.location})` : "";
|
|
3688
5466
|
return `[${entry.type}] ${entry.text}${location}`;
|
|
3689
5467
|
});
|
|
3690
|
-
|
|
5468
|
+
import_node_fs14.default.writeFileSync(filePath, `${lines.join("\n")}
|
|
3691
5469
|
`);
|
|
3692
5470
|
return fileName;
|
|
3693
5471
|
}
|
|
@@ -3695,8 +5473,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
3695
5473
|
const headless = options.headed ? false : config.browser.headless;
|
|
3696
5474
|
const slowMo = options.slowMo ?? config.browser.slowMo;
|
|
3697
5475
|
const maxSteps = config.guardrails.maxSteps;
|
|
3698
|
-
const runDir =
|
|
3699
|
-
|
|
5476
|
+
const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
|
|
5477
|
+
import_node_fs14.default.mkdirSync(runDir, { recursive: true });
|
|
3700
5478
|
const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
|
|
3701
5479
|
const engine = options.browser ?? config.browser.engine;
|
|
3702
5480
|
const channel = options.channel ?? config.browser.channel;
|
|
@@ -3813,7 +5591,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
3813
5591
|
}
|
|
3814
5592
|
return { result, runDir, steps: interpolatedHunt.steps };
|
|
3815
5593
|
}
|
|
3816
|
-
function
|
|
5594
|
+
function delay3(ms) {
|
|
3817
5595
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3818
5596
|
}
|
|
3819
5597
|
async function runHunt(options) {
|
|
@@ -3821,6 +5599,12 @@ async function runHunt(options) {
|
|
|
3821
5599
|
if (config.target.type === "macos") {
|
|
3822
5600
|
return runMacHunt(options, config, configDir, config.target);
|
|
3823
5601
|
}
|
|
5602
|
+
if (config.target.type === "android") {
|
|
5603
|
+
return runAndroidHunt(options, config, configDir, config.target);
|
|
5604
|
+
}
|
|
5605
|
+
if (config.target.type === "ios") {
|
|
5606
|
+
return runIosHunt(options, config, configDir, config.target);
|
|
5607
|
+
}
|
|
3824
5608
|
const hunt = loadHunt(options.huntName, configDir);
|
|
3825
5609
|
const {
|
|
3826
5610
|
hunt: interpolatedHunt,
|
|
@@ -3842,7 +5626,7 @@ async function runHunt(options) {
|
|
|
3842
5626
|
let lastResult;
|
|
3843
5627
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3844
5628
|
if (attempt > 0 && retryDelay > 0) {
|
|
3845
|
-
await
|
|
5629
|
+
await delay3(retryDelay);
|
|
3846
5630
|
}
|
|
3847
5631
|
lastResult = await executeHuntAttempt(
|
|
3848
5632
|
options,
|
|
@@ -3871,19 +5655,17 @@ async function runHunt(options) {
|
|
|
3871
5655
|
}
|
|
3872
5656
|
return lastResult;
|
|
3873
5657
|
}
|
|
3874
|
-
async function
|
|
5658
|
+
async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
|
|
3875
5659
|
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
|
-
});
|
|
5660
|
+
const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
|
|
5661
|
+
import_node_fs14.default.mkdirSync(runDir, { recursive: true });
|
|
5662
|
+
const session = await native.launchSession();
|
|
3883
5663
|
let result;
|
|
3884
5664
|
try {
|
|
3885
|
-
const
|
|
3886
|
-
const
|
|
5665
|
+
const driver = native.sessionDriver(session);
|
|
5666
|
+
const appIdentity = native.sessionAppIdentity(session);
|
|
5667
|
+
const targetLabel = `${native.targetType}:${appIdentity}`;
|
|
5668
|
+
const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
|
|
3887
5669
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3888
5670
|
const startTime = Date.now();
|
|
3889
5671
|
let stepResults = [];
|
|
@@ -3891,7 +5673,8 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
|
|
|
3891
5673
|
let stepFailed = false;
|
|
3892
5674
|
try {
|
|
3893
5675
|
const stepExecution = await executeSteps({
|
|
3894
|
-
driver
|
|
5676
|
+
driver,
|
|
5677
|
+
targetType: native.targetType,
|
|
3895
5678
|
steps: interpolatedHunt.steps,
|
|
3896
5679
|
targetUrl: targetLabel,
|
|
3897
5680
|
runDir,
|
|
@@ -3918,7 +5701,7 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
|
|
|
3918
5701
|
}
|
|
3919
5702
|
let finalScreenshot;
|
|
3920
5703
|
try {
|
|
3921
|
-
finalScreenshot = await captureFinalScreenshot(
|
|
5704
|
+
finalScreenshot = await captureFinalScreenshot(driver, runDir);
|
|
3922
5705
|
} catch {
|
|
3923
5706
|
finalScreenshot = void 0;
|
|
3924
5707
|
}
|
|
@@ -3939,16 +5722,116 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
|
|
|
3939
5722
|
});
|
|
3940
5723
|
result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
|
|
3941
5724
|
} finally {
|
|
3942
|
-
await
|
|
5725
|
+
await native.closeSession(session);
|
|
3943
5726
|
}
|
|
3944
5727
|
return { result, runDir, steps: interpolatedHunt.steps };
|
|
3945
5728
|
}
|
|
5729
|
+
async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
5730
|
+
return executeNativeHuntAttempt(
|
|
5731
|
+
options,
|
|
5732
|
+
config,
|
|
5733
|
+
configDir,
|
|
5734
|
+
interpolatedHunt,
|
|
5735
|
+
redactedFillSteps,
|
|
5736
|
+
randomVars,
|
|
5737
|
+
allowedApps,
|
|
5738
|
+
{
|
|
5739
|
+
targetType: "macos",
|
|
5740
|
+
targetApp: target.app,
|
|
5741
|
+
launchSession: () => launchMacSession({
|
|
5742
|
+
app: target.app,
|
|
5743
|
+
timeoutMs: config.browser.timeout,
|
|
5744
|
+
clientFactory: options.macClientFactory
|
|
5745
|
+
}),
|
|
5746
|
+
closeSession: closeMacSession,
|
|
5747
|
+
sessionDriver: (session) => session.driver,
|
|
5748
|
+
sessionAppIdentity: (session) => session.bundleId
|
|
5749
|
+
}
|
|
5750
|
+
);
|
|
5751
|
+
}
|
|
3946
5752
|
async function runMacHunt(options, config, configDir, target) {
|
|
5753
|
+
return runNativeHunt(options, config, configDir, target, {
|
|
5754
|
+
targetType: "macos",
|
|
5755
|
+
assertAppAllowed: (allowedApps, nativeTarget) => assertTargetAppAllowed(allowedApps, nativeTarget.app),
|
|
5756
|
+
attempt: executeMacHuntAttempt
|
|
5757
|
+
});
|
|
5758
|
+
}
|
|
5759
|
+
async function executeAndroidHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
5760
|
+
const launch = options.androidSessionFactory ?? launchAndroidSession;
|
|
5761
|
+
return executeNativeHuntAttempt(
|
|
5762
|
+
options,
|
|
5763
|
+
config,
|
|
5764
|
+
configDir,
|
|
5765
|
+
interpolatedHunt,
|
|
5766
|
+
redactedFillSteps,
|
|
5767
|
+
randomVars,
|
|
5768
|
+
allowedApps,
|
|
5769
|
+
{
|
|
5770
|
+
targetType: "android",
|
|
5771
|
+
targetApp: target.app,
|
|
5772
|
+
launchSession: () => launch({
|
|
5773
|
+
app: target.app,
|
|
5774
|
+
deviceSerial: target.deviceSerial,
|
|
5775
|
+
coldStart: target.coldStart,
|
|
5776
|
+
timeoutMs: config.browser.timeout,
|
|
5777
|
+
allowedApps
|
|
5778
|
+
}),
|
|
5779
|
+
closeSession: closeAndroidSession,
|
|
5780
|
+
sessionDriver: (session) => session.driver,
|
|
5781
|
+
sessionAppIdentity: (session) => session.package
|
|
5782
|
+
}
|
|
5783
|
+
);
|
|
5784
|
+
}
|
|
5785
|
+
async function runAndroidHunt(options, config, configDir, target) {
|
|
5786
|
+
return runNativeHunt(options, config, configDir, target, {
|
|
5787
|
+
targetType: "android",
|
|
5788
|
+
assertAppAllowed: (allowedApps, nativeTarget) => {
|
|
5789
|
+
if (!nativeTarget.app.toLowerCase().endsWith(".apk")) {
|
|
5790
|
+
assertAndroidAppAllowed(allowedApps, nativeTarget.app);
|
|
5791
|
+
}
|
|
5792
|
+
},
|
|
5793
|
+
attempt: executeAndroidHuntAttempt
|
|
5794
|
+
});
|
|
5795
|
+
}
|
|
5796
|
+
async function executeIosHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
5797
|
+
const launch = options.iosSessionFactory ?? launchIosSession;
|
|
5798
|
+
return executeNativeHuntAttempt(
|
|
5799
|
+
options,
|
|
5800
|
+
config,
|
|
5801
|
+
configDir,
|
|
5802
|
+
interpolatedHunt,
|
|
5803
|
+
redactedFillSteps,
|
|
5804
|
+
randomVars,
|
|
5805
|
+
allowedApps,
|
|
5806
|
+
{
|
|
5807
|
+
targetType: "ios",
|
|
5808
|
+
targetApp: target.app,
|
|
5809
|
+
launchSession: () => launch({
|
|
5810
|
+
app: target.app,
|
|
5811
|
+
udid: target.udid,
|
|
5812
|
+
coldStart: target.coldStart,
|
|
5813
|
+
timeoutMs: config.browser.timeout,
|
|
5814
|
+
allowedApps
|
|
5815
|
+
}),
|
|
5816
|
+
closeSession: closeIosSession,
|
|
5817
|
+
sessionDriver: (session) => session.driver,
|
|
5818
|
+
sessionAppIdentity: (session) => session.bundleId
|
|
5819
|
+
}
|
|
5820
|
+
);
|
|
5821
|
+
}
|
|
5822
|
+
async function runIosHunt(options, config, configDir, target) {
|
|
5823
|
+
return runNativeHunt(options, config, configDir, target, {
|
|
5824
|
+
targetType: "ios",
|
|
5825
|
+
assertAppAllowed: (allowedApps, nativeTarget) => assertIosAppAllowed(allowedApps, nativeTarget.app),
|
|
5826
|
+
attempt: executeIosHuntAttempt
|
|
5827
|
+
});
|
|
5828
|
+
}
|
|
5829
|
+
async function runNativeHunt(options, config, configDir, target, native) {
|
|
3947
5830
|
const hunt = loadHunt(options.huntName, configDir);
|
|
3948
5831
|
const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
|
|
3949
|
-
assertStepsSupportedByTarget(interpolatedHunt.steps,
|
|
3950
|
-
assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions,
|
|
3951
|
-
|
|
5832
|
+
assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
|
|
5833
|
+
assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
|
|
5834
|
+
native.assertAppAllowed(config.guardrails.allowedApps, target);
|
|
3952
5835
|
const maxSteps = config.guardrails.maxSteps;
|
|
3953
5836
|
if (interpolatedHunt.steps.length > maxSteps) {
|
|
3954
5837
|
throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
|
|
@@ -3958,9 +5841,9 @@ async function runMacHunt(options, config, configDir, target) {
|
|
|
3958
5841
|
let lastResult;
|
|
3959
5842
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3960
5843
|
if (attempt > 0 && retryDelay > 0) {
|
|
3961
|
-
await
|
|
5844
|
+
await delay3(retryDelay);
|
|
3962
5845
|
}
|
|
3963
|
-
lastResult = await
|
|
5846
|
+
lastResult = await native.attempt(
|
|
3964
5847
|
options,
|
|
3965
5848
|
config,
|
|
3966
5849
|
configDir,
|
|
@@ -3988,7 +5871,7 @@ async function runMacHunt(options, config, configDir, target) {
|
|
|
3988
5871
|
}
|
|
3989
5872
|
function recordHistory(configDir, outcome, maxRuns) {
|
|
3990
5873
|
try {
|
|
3991
|
-
const relativeRunDir =
|
|
5874
|
+
const relativeRunDir = import_node_path14.default.relative(configDir, outcome.runDir);
|
|
3992
5875
|
appendEntry(
|
|
3993
5876
|
configDir,
|
|
3994
5877
|
{
|
|
@@ -4213,37 +6096,37 @@ function buildRunCommand() {
|
|
|
4213
6096
|
}
|
|
4214
6097
|
|
|
4215
6098
|
// src/cli/commands/init.ts
|
|
4216
|
-
var
|
|
4217
|
-
var
|
|
6099
|
+
var import_node_fs15 = __toESM(require("fs"), 1);
|
|
6100
|
+
var import_node_path15 = __toESM(require("path"), 1);
|
|
4218
6101
|
var import_node_url2 = require("url");
|
|
4219
6102
|
var import_commander2 = require("commander");
|
|
4220
6103
|
var import_chalk4 = __toESM(require("chalk"), 1);
|
|
4221
6104
|
init_loader();
|
|
4222
|
-
var
|
|
6105
|
+
var import_meta4 = {};
|
|
4223
6106
|
function getPackageRoot2() {
|
|
4224
|
-
const currentFile = (0, import_node_url2.fileURLToPath)(
|
|
4225
|
-
let dir =
|
|
4226
|
-
const root =
|
|
6107
|
+
const currentFile = (0, import_node_url2.fileURLToPath)(import_meta4.url);
|
|
6108
|
+
let dir = import_node_path15.default.dirname(currentFile);
|
|
6109
|
+
const root = import_node_path15.default.parse(dir).root;
|
|
4227
6110
|
while (dir !== root) {
|
|
4228
|
-
if (
|
|
6111
|
+
if (import_node_fs15.default.existsSync(import_node_path15.default.join(dir, "package.json"))) {
|
|
4229
6112
|
return dir;
|
|
4230
6113
|
}
|
|
4231
|
-
dir =
|
|
6114
|
+
dir = import_node_path15.default.dirname(dir);
|
|
4232
6115
|
}
|
|
4233
|
-
if (
|
|
6116
|
+
if (import_node_fs15.default.existsSync(import_node_path15.default.join(root, "package.json"))) {
|
|
4234
6117
|
return root;
|
|
4235
6118
|
}
|
|
4236
6119
|
throw new Error("Cannot find package root. Reinstall prowl-tools.");
|
|
4237
6120
|
}
|
|
4238
6121
|
function copyFile(source, destination) {
|
|
4239
|
-
|
|
4240
|
-
|
|
6122
|
+
import_node_fs15.default.mkdirSync(import_node_path15.default.dirname(destination), { recursive: true });
|
|
6123
|
+
import_node_fs15.default.copyFileSync(source, destination);
|
|
4241
6124
|
}
|
|
4242
6125
|
function buildInitCommand() {
|
|
4243
6126
|
const command = new import_commander2.Command("init").option("--force", `Overwrite existing ${CONFIG_DIR} directory`).action((options) => {
|
|
4244
6127
|
const root = process.cwd();
|
|
4245
|
-
const prowlDir =
|
|
4246
|
-
if (
|
|
6128
|
+
const prowlDir = import_node_path15.default.join(root, CONFIG_DIR);
|
|
6129
|
+
if (import_node_fs15.default.existsSync(prowlDir) && !options.force) {
|
|
4247
6130
|
console.error(
|
|
4248
6131
|
import_chalk4.default.red(
|
|
4249
6132
|
`${CONFIG_DIR} already exists. Run with --force to reinitialize prowl configuration without deleting existing files.`
|
|
@@ -4253,20 +6136,20 @@ function buildInitCommand() {
|
|
|
4253
6136
|
return;
|
|
4254
6137
|
}
|
|
4255
6138
|
const packageRoot = getPackageRoot2();
|
|
4256
|
-
const examplesDir =
|
|
4257
|
-
const exampleConfig =
|
|
4258
|
-
const exampleHuntsDir =
|
|
4259
|
-
if (!
|
|
6139
|
+
const examplesDir = import_node_path15.default.join(packageRoot, "examples");
|
|
6140
|
+
const exampleConfig = import_node_path15.default.join(examplesDir, "config.yml");
|
|
6141
|
+
const exampleHuntsDir = import_node_path15.default.join(examplesDir, "hunts");
|
|
6142
|
+
if (!import_node_fs15.default.existsSync(exampleConfig) || !import_node_fs15.default.existsSync(exampleHuntsDir)) {
|
|
4260
6143
|
console.error(import_chalk4.default.red("Examples not found in package. Reinstall prowl-tools."));
|
|
4261
6144
|
process.exitCode = 1;
|
|
4262
6145
|
return;
|
|
4263
6146
|
}
|
|
4264
|
-
copyFile(exampleConfig,
|
|
4265
|
-
const huntFiles =
|
|
6147
|
+
copyFile(exampleConfig, import_node_path15.default.join(prowlDir, "config.yml"));
|
|
6148
|
+
const huntFiles = import_node_fs15.default.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
|
|
4266
6149
|
for (const huntFile of huntFiles) {
|
|
4267
6150
|
copyFile(
|
|
4268
|
-
|
|
4269
|
-
|
|
6151
|
+
import_node_path15.default.join(exampleHuntsDir, huntFile),
|
|
6152
|
+
import_node_path15.default.join(prowlDir, "hunts", huntFile)
|
|
4270
6153
|
);
|
|
4271
6154
|
}
|
|
4272
6155
|
const gitignore = [
|
|
@@ -4280,7 +6163,7 @@ function buildInitCommand() {
|
|
|
4280
6163
|
".env",
|
|
4281
6164
|
""
|
|
4282
6165
|
].join("\n");
|
|
4283
|
-
|
|
6166
|
+
import_node_fs15.default.writeFileSync(import_node_path15.default.join(prowlDir, ".gitignore"), gitignore);
|
|
4284
6167
|
console.log(welcomeBanner());
|
|
4285
6168
|
console.log(import_chalk4.default.green(` Initialized ${CONFIG_DIR} directory.`));
|
|
4286
6169
|
console.log(import_chalk4.default.gray(" Run ") + import_chalk4.default.bold("prowl run hello") + import_chalk4.default.gray(" to get started."));
|
|
@@ -4290,17 +6173,17 @@ function buildInitCommand() {
|
|
|
4290
6173
|
}
|
|
4291
6174
|
|
|
4292
6175
|
// src/cli/commands/login.ts
|
|
4293
|
-
var
|
|
6176
|
+
var import_node_path16 = __toESM(require("path"), 1);
|
|
4294
6177
|
var import_node_readline = __toESM(require("readline"), 1);
|
|
4295
6178
|
var import_chalk5 = __toESM(require("chalk"), 1);
|
|
4296
6179
|
var import_commander3 = require("commander");
|
|
4297
6180
|
init_loader();
|
|
4298
6181
|
function resolvePath2(configDir, inputPath) {
|
|
4299
|
-
if (
|
|
6182
|
+
if (import_node_path16.default.isAbsolute(inputPath)) {
|
|
4300
6183
|
return inputPath;
|
|
4301
6184
|
}
|
|
4302
|
-
const projectRoot =
|
|
4303
|
-
return
|
|
6185
|
+
const projectRoot = import_node_path16.default.dirname(configDir);
|
|
6186
|
+
return import_node_path16.default.join(projectRoot, inputPath);
|
|
4304
6187
|
}
|
|
4305
6188
|
function waitForEnter(prompt) {
|
|
4306
6189
|
return new Promise((resolve) => {
|
|
@@ -4316,7 +6199,7 @@ function buildLoginCommand() {
|
|
|
4316
6199
|
let session = null;
|
|
4317
6200
|
try {
|
|
4318
6201
|
const { config, configDir } = loadConfig(options.config);
|
|
4319
|
-
if (config.target.type
|
|
6202
|
+
if (config.target.type !== "web") {
|
|
4320
6203
|
throw new Error("`prowl login` captures browser auth state and only applies to web targets.");
|
|
4321
6204
|
}
|
|
4322
6205
|
const targetUrl = options.url ?? config.target.url;
|
|
@@ -4387,18 +6270,18 @@ function buildListCommand() {
|
|
|
4387
6270
|
}
|
|
4388
6271
|
|
|
4389
6272
|
// src/cli/commands/watch.ts
|
|
4390
|
-
var
|
|
6273
|
+
var import_node_fs16 = __toESM(require("fs"), 1);
|
|
4391
6274
|
var import_chalk7 = __toESM(require("chalk"), 1);
|
|
4392
6275
|
var import_commander5 = require("commander");
|
|
4393
6276
|
init_loader();
|
|
4394
6277
|
|
|
4395
6278
|
// src/cli/watch-utils.ts
|
|
4396
|
-
var
|
|
6279
|
+
var import_node_path17 = __toESM(require("path"), 1);
|
|
4397
6280
|
function getWatchTargets(configDir, huntName) {
|
|
4398
6281
|
return [
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
6282
|
+
import_node_path17.default.join(configDir, "hunts", `${huntName}.yml`),
|
|
6283
|
+
import_node_path17.default.join(configDir, "config.yml"),
|
|
6284
|
+
import_node_path17.default.join(configDir, ".env")
|
|
4402
6285
|
];
|
|
4403
6286
|
}
|
|
4404
6287
|
function createDebouncer(delayMs, fn) {
|
|
@@ -4467,14 +6350,14 @@ function buildWatchCommand() {
|
|
|
4467
6350
|
});
|
|
4468
6351
|
const unwatch = [];
|
|
4469
6352
|
for (const target of watchTargets) {
|
|
4470
|
-
|
|
6353
|
+
import_node_fs16.default.watchFile(target, { interval: 150 }, (curr, prev) => {
|
|
4471
6354
|
if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) {
|
|
4472
6355
|
return;
|
|
4473
6356
|
}
|
|
4474
6357
|
console.log(import_chalk7.default.gray(`Change detected: ${target}`));
|
|
4475
6358
|
debounced.trigger();
|
|
4476
6359
|
});
|
|
4477
|
-
unwatch.push(() =>
|
|
6360
|
+
unwatch.push(() => import_node_fs16.default.unwatchFile(target));
|
|
4478
6361
|
}
|
|
4479
6362
|
const stop = () => {
|
|
4480
6363
|
if (stopped) {
|
|
@@ -4500,12 +6383,12 @@ var import_commander6 = require("commander");
|
|
|
4500
6383
|
var import_chalk9 = __toESM(require("chalk"), 1);
|
|
4501
6384
|
|
|
4502
6385
|
// src/runner/suite.ts
|
|
4503
|
-
var
|
|
6386
|
+
var import_node_path20 = __toESM(require("path"), 1);
|
|
4504
6387
|
init_loader();
|
|
4505
6388
|
|
|
4506
6389
|
// src/reporter/ci-summary.ts
|
|
4507
|
-
var
|
|
4508
|
-
var
|
|
6390
|
+
var import_node_fs17 = __toESM(require("fs"), 1);
|
|
6391
|
+
var import_node_path18 = __toESM(require("path"), 1);
|
|
4509
6392
|
var import_chalk8 = __toESM(require("chalk"), 1);
|
|
4510
6393
|
function countCiResults(results) {
|
|
4511
6394
|
return {
|
|
@@ -4569,9 +6452,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
|
|
|
4569
6452
|
...flaky.length > 0 ? { flaky } : {},
|
|
4570
6453
|
...clusters.length > 0 ? { clusters } : {}
|
|
4571
6454
|
};
|
|
4572
|
-
|
|
4573
|
-
const filePath =
|
|
4574
|
-
|
|
6455
|
+
import_node_fs17.default.mkdirSync(ciRunDir, { recursive: true });
|
|
6456
|
+
const filePath = import_node_path18.default.join(ciRunDir, "ci-result.json");
|
|
6457
|
+
import_node_fs17.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
|
|
4575
6458
|
return filePath;
|
|
4576
6459
|
}
|
|
4577
6460
|
|
|
@@ -4705,8 +6588,8 @@ function clusterFailures(failures) {
|
|
|
4705
6588
|
}
|
|
4706
6589
|
|
|
4707
6590
|
// src/backlog/index.ts
|
|
4708
|
-
var
|
|
4709
|
-
var
|
|
6591
|
+
var import_node_fs18 = __toESM(require("fs"), 1);
|
|
6592
|
+
var import_node_path19 = __toESM(require("path"), 1);
|
|
4710
6593
|
|
|
4711
6594
|
// src/backlog/parse.ts
|
|
4712
6595
|
var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
|
|
@@ -4796,7 +6679,7 @@ ${after}`;
|
|
|
4796
6679
|
// src/backlog/index.ts
|
|
4797
6680
|
function readFileOrEmpty(filePath) {
|
|
4798
6681
|
try {
|
|
4799
|
-
return
|
|
6682
|
+
return import_node_fs18.default.readFileSync(filePath, "utf-8");
|
|
4800
6683
|
} catch (error) {
|
|
4801
6684
|
const err = error;
|
|
4802
6685
|
if (err.code === "ENOENT") return "";
|
|
@@ -4812,7 +6695,7 @@ function buildFailure(hunt) {
|
|
|
4812
6695
|
if (!hunt.runDir) return failure;
|
|
4813
6696
|
let run;
|
|
4814
6697
|
try {
|
|
4815
|
-
const resultJson = readFileOrEmpty(
|
|
6698
|
+
const resultJson = readFileOrEmpty(import_node_path19.default.join(hunt.runDir, "result.json"));
|
|
4816
6699
|
if (!resultJson) return failure;
|
|
4817
6700
|
run = JSON.parse(resultJson);
|
|
4818
6701
|
} catch (error) {
|
|
@@ -4841,8 +6724,8 @@ function extractFailures(suiteResult) {
|
|
|
4841
6724
|
}
|
|
4842
6725
|
function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
4843
6726
|
const projectRoot = options.projectRoot ?? process.cwd();
|
|
4844
|
-
const backlogPath = options.backlogPath ??
|
|
4845
|
-
const resolvedPath = options.resolvedPath ??
|
|
6727
|
+
const backlogPath = options.backlogPath ?? import_node_path19.default.join(projectRoot, "docs", "backlog.md");
|
|
6728
|
+
const resolvedPath = options.resolvedPath ?? import_node_path19.default.join(projectRoot, "docs", "resolved.md");
|
|
4846
6729
|
const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
4847
6730
|
const summary = { created: [], regressions: [], skipped: [], backlogPath };
|
|
4848
6731
|
const failures = extractFailures(suiteResult);
|
|
@@ -4874,8 +6757,8 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
|
4874
6757
|
}
|
|
4875
6758
|
}
|
|
4876
6759
|
if (ticketsToAdd.length > 0) {
|
|
4877
|
-
|
|
4878
|
-
|
|
6760
|
+
import_node_fs18.default.mkdirSync(import_node_path19.default.dirname(backlogPath), { recursive: true });
|
|
6761
|
+
import_node_fs18.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
|
|
4879
6762
|
}
|
|
4880
6763
|
return summary;
|
|
4881
6764
|
}
|
|
@@ -5036,7 +6919,7 @@ async function runSuite(options = {}) {
|
|
|
5036
6919
|
const clusters = clusterFailures(
|
|
5037
6920
|
extractFailures({ result: { hunts: results }, resultPath: null })
|
|
5038
6921
|
).filter((cluster) => cluster.count > 1);
|
|
5039
|
-
const ciRunDir =
|
|
6922
|
+
const ciRunDir = import_node_path20.default.join(configDir, "runs", timestamp("ci"));
|
|
5040
6923
|
const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
|
|
5041
6924
|
const { passed, failed, skipped } = countCiResults(results);
|
|
5042
6925
|
return {
|
|
@@ -5154,8 +7037,8 @@ function buildCiCommand() {
|
|
|
5154
7037
|
}
|
|
5155
7038
|
|
|
5156
7039
|
// src/cli/commands/update-baselines.ts
|
|
5157
|
-
var
|
|
5158
|
-
var
|
|
7040
|
+
var import_node_fs19 = __toESM(require("fs"), 1);
|
|
7041
|
+
var import_node_path21 = __toESM(require("path"), 1);
|
|
5159
7042
|
var import_commander7 = require("commander");
|
|
5160
7043
|
var import_chalk10 = __toESM(require("chalk"), 1);
|
|
5161
7044
|
init_loader();
|
|
@@ -5163,33 +7046,33 @@ function buildUpdateBaselinesCommand() {
|
|
|
5163
7046
|
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
7047
|
try {
|
|
5165
7048
|
const { configDir } = loadConfig(options.config);
|
|
5166
|
-
const baselinesDir =
|
|
5167
|
-
|
|
7049
|
+
const baselinesDir = import_node_path21.default.join(configDir, "baselines");
|
|
7050
|
+
import_node_fs19.default.mkdirSync(baselinesDir, { recursive: true });
|
|
5168
7051
|
let runDir;
|
|
5169
7052
|
if (options.run) {
|
|
5170
|
-
runDir =
|
|
7053
|
+
runDir = import_node_path21.default.isAbsolute(options.run) ? options.run : import_node_path21.default.resolve(options.run);
|
|
5171
7054
|
} else {
|
|
5172
|
-
const runsDir =
|
|
5173
|
-
if (!
|
|
7055
|
+
const runsDir = import_node_path21.default.join(configDir, "runs");
|
|
7056
|
+
if (!import_node_fs19.default.existsSync(runsDir)) {
|
|
5174
7057
|
console.error(import_chalk10.default.red(" No runs directory found. Run a hunt first."));
|
|
5175
7058
|
process.exitCode = 1;
|
|
5176
7059
|
return;
|
|
5177
7060
|
}
|
|
5178
|
-
const entries =
|
|
7061
|
+
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
7062
|
if (entries.length === 0) {
|
|
5180
7063
|
console.error(import_chalk10.default.red(" No run directories found. Run a hunt first."));
|
|
5181
7064
|
process.exitCode = 1;
|
|
5182
7065
|
return;
|
|
5183
7066
|
}
|
|
5184
|
-
runDir =
|
|
7067
|
+
runDir = import_node_path21.default.join(runsDir, entries[0]);
|
|
5185
7068
|
}
|
|
5186
|
-
const screenshotsDir =
|
|
5187
|
-
if (!
|
|
7069
|
+
const screenshotsDir = import_node_path21.default.join(runDir, "screenshots");
|
|
7070
|
+
if (!import_node_fs19.default.existsSync(screenshotsDir)) {
|
|
5188
7071
|
console.error(import_chalk10.default.red(` No screenshots found in ${runDir}`));
|
|
5189
7072
|
process.exitCode = 1;
|
|
5190
7073
|
return;
|
|
5191
7074
|
}
|
|
5192
|
-
const screenshots =
|
|
7075
|
+
const screenshots = import_node_fs19.default.readdirSync(screenshotsDir).filter((f) => f.endsWith("-current.png"));
|
|
5193
7076
|
if (screenshots.length === 0) {
|
|
5194
7077
|
console.log(import_chalk10.default.yellow(" No assertScreenshot results found in this run."));
|
|
5195
7078
|
return;
|
|
@@ -5203,10 +7086,10 @@ function buildUpdateBaselinesCommand() {
|
|
|
5203
7086
|
let updated = 0;
|
|
5204
7087
|
for (const file of filtered) {
|
|
5205
7088
|
const baselineName = file.replace("-current.png", ".png");
|
|
5206
|
-
const sourcePath =
|
|
5207
|
-
const destPath =
|
|
5208
|
-
const exists =
|
|
5209
|
-
|
|
7089
|
+
const sourcePath = import_node_path21.default.join(screenshotsDir, file);
|
|
7090
|
+
const destPath = import_node_path21.default.join(baselinesDir, baselineName);
|
|
7091
|
+
const exists = import_node_fs19.default.existsSync(destPath);
|
|
7092
|
+
import_node_fs19.default.copyFileSync(sourcePath, destPath);
|
|
5210
7093
|
updated++;
|
|
5211
7094
|
const status = exists ? import_chalk10.default.yellow("updated") : import_chalk10.default.green("created");
|
|
5212
7095
|
console.log(` ${status} ${baselineName}`);
|
|
@@ -5359,6 +7242,134 @@ async function analyzePage(page) {
|
|
|
5359
7242
|
};
|
|
5360
7243
|
}
|
|
5361
7244
|
|
|
7245
|
+
// src/analyzer/mac.ts
|
|
7246
|
+
var INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
|
|
7247
|
+
"AXButton",
|
|
7248
|
+
"AXTextField",
|
|
7249
|
+
"AXSecureTextField",
|
|
7250
|
+
"AXTextArea",
|
|
7251
|
+
"AXCheckBox",
|
|
7252
|
+
"AXRadioButton",
|
|
7253
|
+
"AXPopUpButton",
|
|
7254
|
+
"AXMenuButton",
|
|
7255
|
+
"AXLink",
|
|
7256
|
+
"AXMenuItem",
|
|
7257
|
+
"AXComboBox",
|
|
7258
|
+
"AXSlider",
|
|
7259
|
+
"AXDisclosureTriangle"
|
|
7260
|
+
]);
|
|
7261
|
+
var DEFAULT_ANALYZE_TREE_DEPTH = 20;
|
|
7262
|
+
function str(value) {
|
|
7263
|
+
if (typeof value !== "string") {
|
|
7264
|
+
return void 0;
|
|
7265
|
+
}
|
|
7266
|
+
const trimmed = value.trim();
|
|
7267
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
7268
|
+
}
|
|
7269
|
+
function toNode(raw) {
|
|
7270
|
+
const node = raw ?? {};
|
|
7271
|
+
const children = Array.isArray(node.children) ? node.children.map(toNode) : void 0;
|
|
7272
|
+
return {
|
|
7273
|
+
role: str(node.role),
|
|
7274
|
+
title: str(node.title),
|
|
7275
|
+
description: str(node.description),
|
|
7276
|
+
value: str(node.value),
|
|
7277
|
+
identifier: str(node.identifier),
|
|
7278
|
+
enabled: typeof node.enabled === "boolean" ? node.enabled : void 0,
|
|
7279
|
+
...children ? { children } : {}
|
|
7280
|
+
};
|
|
7281
|
+
}
|
|
7282
|
+
function quote(value) {
|
|
7283
|
+
return `"${value}"`;
|
|
7284
|
+
}
|
|
7285
|
+
function rankMacSelectors(node) {
|
|
7286
|
+
const selectors = [];
|
|
7287
|
+
const identifier = node.identifier;
|
|
7288
|
+
const exactLabel = node.title ?? node.description;
|
|
7289
|
+
const name = node.title ?? node.description ?? node.value;
|
|
7290
|
+
if (identifier) {
|
|
7291
|
+
selectors.push(`id=${identifier}`);
|
|
7292
|
+
}
|
|
7293
|
+
if (exactLabel) {
|
|
7294
|
+
selectors.push(`label=${quote(exactLabel)}`);
|
|
7295
|
+
}
|
|
7296
|
+
if (node.role && name) {
|
|
7297
|
+
selectors.push(`role=${node.role}[name=${quote(name)}]`);
|
|
7298
|
+
}
|
|
7299
|
+
if (name) {
|
|
7300
|
+
selectors.push(`text=${quote(name)}`);
|
|
7301
|
+
}
|
|
7302
|
+
if (selectors.length === 0 && node.role) {
|
|
7303
|
+
selectors.push(`role=${node.role}`);
|
|
7304
|
+
}
|
|
7305
|
+
return selectors;
|
|
7306
|
+
}
|
|
7307
|
+
function toElement(node, source) {
|
|
7308
|
+
return {
|
|
7309
|
+
role: node.role ?? "?",
|
|
7310
|
+
...node.title ? { title: node.title } : {},
|
|
7311
|
+
...node.description ? { description: node.description } : {},
|
|
7312
|
+
...node.value ? { value: node.value } : {},
|
|
7313
|
+
...node.identifier ? { identifier: node.identifier } : {},
|
|
7314
|
+
...node.enabled !== void 0 ? { enabled: node.enabled } : {},
|
|
7315
|
+
source,
|
|
7316
|
+
selectors: rankMacSelectors(node)
|
|
7317
|
+
};
|
|
7318
|
+
}
|
|
7319
|
+
function collectInteractive(root) {
|
|
7320
|
+
const out = [];
|
|
7321
|
+
const visit = (node) => {
|
|
7322
|
+
if (node.role && INTERACTIVE_ROLES.has(node.role)) {
|
|
7323
|
+
out.push(toElement(node, "window"));
|
|
7324
|
+
}
|
|
7325
|
+
for (const child of node.children ?? []) {
|
|
7326
|
+
visit(child);
|
|
7327
|
+
}
|
|
7328
|
+
};
|
|
7329
|
+
visit(root);
|
|
7330
|
+
return out;
|
|
7331
|
+
}
|
|
7332
|
+
function toWindow(node) {
|
|
7333
|
+
const [best] = rankMacSelectors(node);
|
|
7334
|
+
return {
|
|
7335
|
+
...node.title ? { title: node.title } : {},
|
|
7336
|
+
...node.identifier ? { identifier: node.identifier } : {},
|
|
7337
|
+
selector: best ?? "role=AXWindow"
|
|
7338
|
+
};
|
|
7339
|
+
}
|
|
7340
|
+
async function analyzeMacApp(client, options) {
|
|
7341
|
+
const depth = options.treeDepth ?? DEFAULT_ANALYZE_TREE_DEPTH;
|
|
7342
|
+
const treeResult = await client.request("tree", { depth });
|
|
7343
|
+
const elements = collectInteractive(toNode(treeResult.tree));
|
|
7344
|
+
const windowsResult = await client.request("windows");
|
|
7345
|
+
const rawWindows = Array.isArray(windowsResult.windows) ? windowsResult.windows : [];
|
|
7346
|
+
const windows = rawWindows.map((raw) => toWindow(toNode(raw)));
|
|
7347
|
+
const menuItems = await readStatusMenu(client, options.menuTimeoutSeconds);
|
|
7348
|
+
return { app: options.app, elements, windows, menuItems };
|
|
7349
|
+
}
|
|
7350
|
+
async function readStatusMenu(client, menuTimeoutSeconds) {
|
|
7351
|
+
let statusItems;
|
|
7352
|
+
try {
|
|
7353
|
+
const status = await client.request("statusItems");
|
|
7354
|
+
statusItems = Array.isArray(status.items) ? status.items : [];
|
|
7355
|
+
} catch {
|
|
7356
|
+
return [];
|
|
7357
|
+
}
|
|
7358
|
+
if (statusItems.length === 0) {
|
|
7359
|
+
return [];
|
|
7360
|
+
}
|
|
7361
|
+
const params = menuTimeoutSeconds !== void 0 ? { timeout: menuTimeoutSeconds } : {};
|
|
7362
|
+
try {
|
|
7363
|
+
const menu = await client.request("openMenu", params);
|
|
7364
|
+
const rawItems = Array.isArray(menu.items) ? menu.items : [];
|
|
7365
|
+
return rawItems.map((raw) => toNode(raw)).filter((node) => node.role !== "AXMenuItem" || node.title || node.identifier || node.description).map((node) => toElement(node, "menu"));
|
|
7366
|
+
} catch {
|
|
7367
|
+
return [];
|
|
7368
|
+
} finally {
|
|
7369
|
+
await client.request("closeMenu").catch(() => void 0);
|
|
7370
|
+
}
|
|
7371
|
+
}
|
|
7372
|
+
|
|
5362
7373
|
// src/cli/commands/analyze.ts
|
|
5363
7374
|
init_loader();
|
|
5364
7375
|
function parseViewportFlag2(value) {
|
|
@@ -5368,73 +7379,160 @@ function parseViewportFlag2(value) {
|
|
|
5368
7379
|
}
|
|
5369
7380
|
return value;
|
|
5370
7381
|
}
|
|
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
|
-
|
|
7382
|
+
function tryLoadConfig(configPath) {
|
|
7383
|
+
const configLocation = configPath !== void 0 ? configPath : findConfigPath(process.cwd());
|
|
7384
|
+
if (configLocation === null) {
|
|
7385
|
+
return null;
|
|
7386
|
+
}
|
|
7387
|
+
try {
|
|
7388
|
+
return loadConfig(configLocation).config;
|
|
7389
|
+
} catch (error) {
|
|
7390
|
+
const message = error instanceof Error ? error.message : "Unknown configuration error";
|
|
7391
|
+
throw new Error(`Failed to load config at ${configLocation}: ${message}`);
|
|
7392
|
+
}
|
|
7393
|
+
}
|
|
7394
|
+
async function runWebAnalyze(url, options) {
|
|
7395
|
+
const engine = parseBrowserEngine(options.browser);
|
|
7396
|
+
const channel = options.channel;
|
|
7397
|
+
const viewport = options.viewport ? resolveViewport(parseViewportFlag2(options.viewport)) : { width: 1280, height: 720 };
|
|
7398
|
+
const session = await launchBrowser({
|
|
7399
|
+
headless: !options.headed,
|
|
7400
|
+
slowMo: 0,
|
|
7401
|
+
timeout: 3e4,
|
|
7402
|
+
trace: false,
|
|
7403
|
+
recordHar: false,
|
|
7404
|
+
runDir: process.cwd(),
|
|
7405
|
+
engine,
|
|
7406
|
+
channel,
|
|
7407
|
+
viewport
|
|
7408
|
+
});
|
|
7409
|
+
const driver = createPlaywrightDriver(session.page);
|
|
7410
|
+
try {
|
|
7411
|
+
await driver.goto(url, { waitUntil: "networkidle" });
|
|
7412
|
+
const result = await analyzePage(driver);
|
|
7413
|
+
if (options.json) {
|
|
7414
|
+
console.log(JSON.stringify(result, null, 2));
|
|
7415
|
+
} else {
|
|
7416
|
+
console.log(`
|
|
5396
7417
|
${import_chalk11.default.bold("Page Analysis:")} ${result.title}`);
|
|
5397
|
-
|
|
7418
|
+
console.log(` ${import_chalk11.default.gray("URL:")} ${result.url}
|
|
5398
7419
|
`);
|
|
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
|
-
|
|
7420
|
+
if (result.forms.length > 0) {
|
|
7421
|
+
console.log(import_chalk11.default.bold(" Forms:"));
|
|
7422
|
+
for (const form of result.forms) {
|
|
7423
|
+
const method = form.method ? import_chalk11.default.cyan(form.method) : "";
|
|
7424
|
+
const action = form.action ? import_chalk11.default.gray(form.action) : "";
|
|
7425
|
+
console.log(` [${form.index}] ${method} ${action} (${form.fieldCount} fields)`);
|
|
7426
|
+
}
|
|
7427
|
+
console.log();
|
|
7428
|
+
}
|
|
7429
|
+
if (result.elements.length > 0) {
|
|
7430
|
+
console.log(import_chalk11.default.bold(" Interactive Elements:"));
|
|
7431
|
+
for (const el of result.elements) {
|
|
7432
|
+
const tag = import_chalk11.default.cyan(el.tag);
|
|
7433
|
+
const type = el.type ? import_chalk11.default.gray(`[${el.type}]`) : "";
|
|
7434
|
+
const bestSelector = el.selectors.testId ?? el.selectors.label ?? el.selectors.ariaLabel ?? el.selectors.css ?? el.selectors.name ?? "";
|
|
7435
|
+
const selectorStr = bestSelector ? import_chalk11.default.yellow(bestSelector) : import_chalk11.default.gray("(no selector)");
|
|
7436
|
+
const req = el.required ? import_chalk11.default.red(" *") : "";
|
|
7437
|
+
const form = el.formGroup !== void 0 ? import_chalk11.default.gray(` form[${el.formGroup}]`) : "";
|
|
7438
|
+
console.log(` ${tag}${type} ${selectorStr}${req}${form}`);
|
|
7439
|
+
}
|
|
7440
|
+
console.log();
|
|
7441
|
+
}
|
|
7442
|
+
if (result.links.length > 0) {
|
|
7443
|
+
console.log(import_chalk11.default.bold(" Links:"));
|
|
7444
|
+
for (const link of result.links.slice(0, 20)) {
|
|
7445
|
+
const text = link.text || import_chalk11.default.gray("(no text)");
|
|
7446
|
+
console.log(` ${text} ${import_chalk11.default.gray("\u2192")} ${import_chalk11.default.blue(link.href)}`);
|
|
7447
|
+
}
|
|
7448
|
+
if (result.links.length > 20) {
|
|
7449
|
+
console.log(import_chalk11.default.gray(` ... and ${result.links.length - 20} more`));
|
|
7450
|
+
}
|
|
7451
|
+
console.log();
|
|
7452
|
+
}
|
|
7453
|
+
console.log(import_chalk11.default.gray(` ${result.elements.length} elements, ${result.forms.length} forms, ${result.links.length} links
|
|
5433
7454
|
`));
|
|
7455
|
+
}
|
|
7456
|
+
} finally {
|
|
7457
|
+
await closeBrowser(session);
|
|
7458
|
+
}
|
|
7459
|
+
}
|
|
7460
|
+
function printMacElement(el) {
|
|
7461
|
+
const role = import_chalk11.default.cyan(el.role);
|
|
7462
|
+
const [best] = el.selectors;
|
|
7463
|
+
const selectorStr = best ? import_chalk11.default.yellow(best) : import_chalk11.default.gray("(no selector)");
|
|
7464
|
+
const label = el.title ?? el.description ?? el.value;
|
|
7465
|
+
const labelStr = label ? ` ${import_chalk11.default.gray(`"${label}"`)}` : "";
|
|
7466
|
+
const disabled = el.enabled === false ? import_chalk11.default.gray(" (disabled)") : "";
|
|
7467
|
+
console.log(` ${role} ${selectorStr}${labelStr}${disabled}`);
|
|
7468
|
+
}
|
|
7469
|
+
async function runMacAnalyze(app, config, options) {
|
|
7470
|
+
const allowedApps = config?.guardrails.allowedApps ?? [];
|
|
7471
|
+
assertTargetAppAllowed(allowedApps, app);
|
|
7472
|
+
const session = await launchMacSession({ app, timeoutMs: config?.browser.timeout });
|
|
7473
|
+
try {
|
|
7474
|
+
const result = await analyzeMacApp(session.client, { app: session.bundleId });
|
|
7475
|
+
if (options.json) {
|
|
7476
|
+
console.log(JSON.stringify(result, null, 2));
|
|
7477
|
+
} else {
|
|
7478
|
+
console.log(`
|
|
7479
|
+
${import_chalk11.default.bold("App Analysis:")} ${result.app}
|
|
7480
|
+
`);
|
|
7481
|
+
if (result.windows.length > 0) {
|
|
7482
|
+
console.log(import_chalk11.default.bold(" Windows:"));
|
|
7483
|
+
for (const win of result.windows) {
|
|
7484
|
+
const title = win.title ? import_chalk11.default.gray(`"${win.title}"`) : import_chalk11.default.gray("(untitled)");
|
|
7485
|
+
console.log(` ${title} ${import_chalk11.default.yellow(win.selector)}`);
|
|
5434
7486
|
}
|
|
5435
|
-
|
|
5436
|
-
|
|
7487
|
+
console.log();
|
|
7488
|
+
}
|
|
7489
|
+
if (result.elements.length > 0) {
|
|
7490
|
+
console.log(import_chalk11.default.bold(" Interactive Elements:"));
|
|
7491
|
+
for (const el of result.elements) {
|
|
7492
|
+
printMacElement(el);
|
|
7493
|
+
}
|
|
7494
|
+
console.log();
|
|
7495
|
+
}
|
|
7496
|
+
if (result.menuItems.length > 0) {
|
|
7497
|
+
console.log(import_chalk11.default.bold(" Menu Bar:"));
|
|
7498
|
+
for (const el of result.menuItems) {
|
|
7499
|
+
printMacElement(el);
|
|
7500
|
+
}
|
|
7501
|
+
console.log();
|
|
7502
|
+
}
|
|
7503
|
+
console.log(
|
|
7504
|
+
import_chalk11.default.gray(
|
|
7505
|
+
` ${result.elements.length} elements, ${result.windows.length} windows, ${result.menuItems.length} menu items
|
|
7506
|
+
`
|
|
7507
|
+
)
|
|
7508
|
+
);
|
|
7509
|
+
}
|
|
7510
|
+
} finally {
|
|
7511
|
+
await session.client.close();
|
|
7512
|
+
}
|
|
7513
|
+
}
|
|
7514
|
+
function buildAnalyzeCommand() {
|
|
7515
|
+
const command = new import_commander8.Command("analyze").argument("[url]", "URL to analyze (web target)").description("Analyze a page or macOS app to discover interactive elements and selectors").option("--json", "Output as JSON").option("--app <app>", "macOS app to analyze: bundle id or /path/to/App.app (macOS 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) => {
|
|
7516
|
+
try {
|
|
7517
|
+
if (options.app && url) {
|
|
7518
|
+
throw new Error("Pass either a URL argument (web) or --app (macOS), not both.");
|
|
7519
|
+
}
|
|
7520
|
+
if (options.app) {
|
|
7521
|
+
await runMacAnalyze(options.app, tryLoadConfig(options.config), options);
|
|
7522
|
+
return;
|
|
7523
|
+
}
|
|
7524
|
+
if (url) {
|
|
7525
|
+
await runWebAnalyze(url, options);
|
|
7526
|
+
return;
|
|
7527
|
+
}
|
|
7528
|
+
const config = tryLoadConfig(options.config);
|
|
7529
|
+
if (config?.target.type === "macos") {
|
|
7530
|
+
await runMacAnalyze(config.target.app, config, options);
|
|
7531
|
+
return;
|
|
5437
7532
|
}
|
|
7533
|
+
throw new Error(
|
|
7534
|
+
"analyze needs a target: pass a URL for the web target, or use --app <bundle-id|.app-path> (or set target.type: macos in .prowl/config.yml) for the macOS target."
|
|
7535
|
+
);
|
|
5438
7536
|
} catch (error) {
|
|
5439
7537
|
const message = error instanceof Error ? error.message : "Analysis failed";
|
|
5440
7538
|
if (options.json) {
|
|
@@ -5451,8 +7549,8 @@ function buildAnalyzeCommand() {
|
|
|
5451
7549
|
}
|
|
5452
7550
|
|
|
5453
7551
|
// src/cli/commands/generate.ts
|
|
5454
|
-
var
|
|
5455
|
-
var
|
|
7552
|
+
var import_node_fs20 = __toESM(require("fs"), 1);
|
|
7553
|
+
var import_node_path22 = __toESM(require("path"), 1);
|
|
5456
7554
|
var import_commander9 = require("commander");
|
|
5457
7555
|
var import_chalk12 = __toESM(require("chalk"), 1);
|
|
5458
7556
|
var import_ora = __toESM(require("ora"), 1);
|
|
@@ -5721,13 +7819,13 @@ function buildGenerateCommand() {
|
|
|
5721
7819
|
const result = loadConfig2(options.config);
|
|
5722
7820
|
configDir = result.configDir;
|
|
5723
7821
|
} catch {
|
|
5724
|
-
configDir =
|
|
7822
|
+
configDir = import_node_path22.default.join(process.cwd(), ".prowl");
|
|
5725
7823
|
}
|
|
5726
|
-
const huntsDir =
|
|
5727
|
-
|
|
7824
|
+
const huntsDir = import_node_path22.default.join(configDir, "hunts");
|
|
7825
|
+
import_node_fs20.default.mkdirSync(huntsDir, { recursive: true });
|
|
5728
7826
|
const fileName = options.output.endsWith(".yml") ? options.output : `${options.output}.yml`;
|
|
5729
|
-
const filePath =
|
|
5730
|
-
|
|
7827
|
+
const filePath = import_node_path22.default.join(huntsDir, fileName);
|
|
7828
|
+
import_node_fs20.default.writeFileSync(filePath, yamlStr + "\n", "utf-8");
|
|
5731
7829
|
console.log(import_chalk12.default.green(` Saved to ${filePath}`));
|
|
5732
7830
|
} else {
|
|
5733
7831
|
console.log(yamlStr);
|
|
@@ -5924,7 +8022,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
|
5924
8022
|
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
5925
8023
|
|
|
5926
8024
|
// src/mcp/tools.ts
|
|
5927
|
-
var
|
|
8025
|
+
var import_node_path23 = __toESM(require("path"), 1);
|
|
5928
8026
|
init_loader();
|
|
5929
8027
|
function listHuntsTool(configPath) {
|
|
5930
8028
|
const { configDir } = loadConfig(configPath);
|
|
@@ -5932,7 +8030,7 @@ function listHuntsTool(configPath) {
|
|
|
5932
8030
|
}
|
|
5933
8031
|
async function runSuiteTool(args = {}, options = {}) {
|
|
5934
8032
|
const { configPath: resolvedConfigPath, configDir, config } = loadConfig(options.configPath);
|
|
5935
|
-
const projectRoot = options.projectRoot ??
|
|
8033
|
+
const projectRoot = options.projectRoot ?? import_node_path23.default.dirname(configDir);
|
|
5936
8034
|
const suite = await runSuite({
|
|
5937
8035
|
configPath: resolvedConfigPath,
|
|
5938
8036
|
includeTags: args.includeTags,
|
|
@@ -5941,8 +8039,8 @@ async function runSuiteTool(args = {}, options = {}) {
|
|
|
5941
8039
|
});
|
|
5942
8040
|
const bugLogCfg = config.bugLog ?? {};
|
|
5943
8041
|
const logBugs = args.logBugs ?? bugLogCfg.enabled ?? true;
|
|
5944
|
-
const backlogPath = bugLogCfg.backlogPath ?
|
|
5945
|
-
const resolvedPath = bugLogCfg.resolvedPath ?
|
|
8042
|
+
const backlogPath = bugLogCfg.backlogPath ? import_node_path23.default.resolve(projectRoot, bugLogCfg.backlogPath) : void 0;
|
|
8043
|
+
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
8044
|
const bugs = logBugs ? updateBacklogFromSuite(suite, { projectRoot, backlogPath, resolvedPath }) : { created: [], regressions: [], skipped: [], backlogPath: null };
|
|
5947
8045
|
const { status, totalHunts, passed, failed, skipped } = suite.result;
|
|
5948
8046
|
return {
|
|
@@ -5969,9 +8067,9 @@ async function runHuntTool(args, configPath) {
|
|
|
5969
8067
|
}
|
|
5970
8068
|
|
|
5971
8069
|
// src/mcp/projects.ts
|
|
5972
|
-
var
|
|
5973
|
-
var
|
|
5974
|
-
var
|
|
8070
|
+
var import_node_fs21 = __toESM(require("fs"), 1);
|
|
8071
|
+
var import_node_os3 = __toESM(require("os"), 1);
|
|
8072
|
+
var import_node_path24 = __toESM(require("path"), 1);
|
|
5975
8073
|
var import_yaml3 = __toESM(require("yaml"), 1);
|
|
5976
8074
|
var import_zod2 = require("zod");
|
|
5977
8075
|
var projectEntrySchema = import_zod2.z.object({
|
|
@@ -5983,37 +8081,37 @@ var projectRegistrySchema = import_zod2.z.object({
|
|
|
5983
8081
|
projects: import_zod2.z.record(import_zod2.z.string().min(1), projectEntrySchema)
|
|
5984
8082
|
}).strict();
|
|
5985
8083
|
function defaultRegistryPath() {
|
|
5986
|
-
return
|
|
8084
|
+
return import_node_path24.default.join(import_node_os3.default.homedir(), ".prowl", "projects.yml");
|
|
5987
8085
|
}
|
|
5988
8086
|
function legacyRegistryPath() {
|
|
5989
|
-
return
|
|
8087
|
+
return import_node_path24.default.join(import_node_os3.default.homedir(), ".prowlqa", "projects.yml");
|
|
5990
8088
|
}
|
|
5991
8089
|
function resolveProjectConfigPath(root) {
|
|
5992
|
-
const preferred =
|
|
5993
|
-
if (
|
|
5994
|
-
const legacy =
|
|
5995
|
-
if (
|
|
8090
|
+
const preferred = import_node_path24.default.join(root, ".prowl", "config.yml");
|
|
8091
|
+
if (import_node_fs21.default.existsSync(preferred)) return preferred;
|
|
8092
|
+
const legacy = import_node_path24.default.join(root, ".prowlqa", "config.yml");
|
|
8093
|
+
if (import_node_fs21.default.existsSync(legacy)) return legacy;
|
|
5996
8094
|
return preferred;
|
|
5997
8095
|
}
|
|
5998
8096
|
function resolveRegistryRelativePath(registry, inputPath) {
|
|
5999
|
-
return
|
|
8097
|
+
return import_node_path24.default.isAbsolute(inputPath) ? inputPath : import_node_path24.default.resolve(import_node_path24.default.dirname(registry.registryPath), inputPath);
|
|
6000
8098
|
}
|
|
6001
8099
|
function resolveRegistryPath(explicitPath) {
|
|
6002
|
-
if (explicitPath) return
|
|
8100
|
+
if (explicitPath) return import_node_path24.default.resolve(explicitPath);
|
|
6003
8101
|
const envPath = process.env.PROWL_PROJECTS ?? process.env.PROWLQA_PROJECTS;
|
|
6004
|
-
if (envPath) return
|
|
8102
|
+
if (envPath) return import_node_path24.default.resolve(envPath);
|
|
6005
8103
|
const fallback = defaultRegistryPath();
|
|
6006
|
-
if (
|
|
8104
|
+
if (import_node_fs21.default.existsSync(fallback)) return fallback;
|
|
6007
8105
|
const legacy = legacyRegistryPath();
|
|
6008
|
-
return
|
|
8106
|
+
return import_node_fs21.default.existsSync(legacy) ? legacy : null;
|
|
6009
8107
|
}
|
|
6010
8108
|
function loadProjectRegistry(explicitPath) {
|
|
6011
8109
|
const registryPath = resolveRegistryPath(explicitPath);
|
|
6012
8110
|
if (!registryPath) return null;
|
|
6013
|
-
if (!
|
|
8111
|
+
if (!import_node_fs21.default.existsSync(registryPath)) {
|
|
6014
8112
|
throw new Error(`Project registry not found at ${registryPath}`);
|
|
6015
8113
|
}
|
|
6016
|
-
const raw =
|
|
8114
|
+
const raw = import_node_fs21.default.readFileSync(registryPath, "utf-8");
|
|
6017
8115
|
const parsed = import_yaml3.default.parse(raw) ?? {};
|
|
6018
8116
|
const validated = projectRegistrySchema.parse(parsed);
|
|
6019
8117
|
return { projects: validated.projects, registryPath };
|