prowl-tools 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +10 -0
- package/README.md +519 -2
- package/dist/{chunk-ZEFVTKQT.js → chunk-5KQR3IR3.js} +3321 -346
- package/dist/chunk-5KQR3IR3.js.map +1 -0
- package/dist/{chunk-MBAIGNVO.js → chunk-WHAMB4TY.js} +43 -3
- package/dist/chunk-WHAMB4TY.js.map +1 -0
- package/dist/index.cjs +3527 -481
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +325 -67
- package/dist/index.js.map +1 -1
- package/dist/lib.cjs +3444 -325
- package/dist/lib.cjs.map +1 -1
- package/dist/lib.d.cts +1236 -11
- package/dist/lib.d.ts +1236 -11
- package/dist/lib.js +194 -2
- package/dist/{loader-XMTDB6OL.js → loader-PBBYV3U7.js} +2 -2
- package/package.json +5 -1
- package/dist/chunk-MBAIGNVO.js.map +0 -1
- package/dist/chunk-ZEFVTKQT.js.map +0 -1
- /package/dist/{loader-XMTDB6OL.js.map → loader-PBBYV3U7.js.map} +0 -0
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
loadHunt,
|
|
8
8
|
loadHuntTags,
|
|
9
9
|
resolveViewport
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-WHAMB4TY.js";
|
|
11
11
|
|
|
12
12
|
// src/config/interpolate.ts
|
|
13
13
|
import crypto from "crypto";
|
|
@@ -303,6 +303,11 @@ function interpolateStep(step, vars, stepPath2, redacted) {
|
|
|
303
303
|
}
|
|
304
304
|
};
|
|
305
305
|
}
|
|
306
|
+
if ("assertWithAI" in step) {
|
|
307
|
+
return {
|
|
308
|
+
assertWithAI: interpolateString(step.assertWithAI, vars).value
|
|
309
|
+
};
|
|
310
|
+
}
|
|
306
311
|
if ("copyText" in step) {
|
|
307
312
|
return {
|
|
308
313
|
copyText: {
|
|
@@ -409,15 +414,25 @@ function webOnlyReason(step) {
|
|
|
409
414
|
}
|
|
410
415
|
return null;
|
|
411
416
|
}
|
|
417
|
+
function nativeTargetLabel(target) {
|
|
418
|
+
if (target === "android") {
|
|
419
|
+
return "Android";
|
|
420
|
+
}
|
|
421
|
+
if (target === "ios") {
|
|
422
|
+
return "iOS";
|
|
423
|
+
}
|
|
424
|
+
return "macOS";
|
|
425
|
+
}
|
|
412
426
|
function assertStepsSupportedByTarget(steps, target) {
|
|
413
|
-
if (target
|
|
427
|
+
if (target === "web") {
|
|
414
428
|
return;
|
|
415
429
|
}
|
|
430
|
+
const label = nativeTargetLabel(target);
|
|
416
431
|
for (const step of steps) {
|
|
417
432
|
const reason = webOnlyReason(step);
|
|
418
433
|
if (reason) {
|
|
419
434
|
throw new Error(
|
|
420
|
-
`Step "${reason}" is not supported by the
|
|
435
|
+
`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.).`
|
|
421
436
|
);
|
|
422
437
|
}
|
|
423
438
|
if ("if" in step) {
|
|
@@ -432,20 +447,34 @@ function assertStepsSupportedByTarget(steps, target) {
|
|
|
432
447
|
}
|
|
433
448
|
}
|
|
434
449
|
function assertHuntAssertionsSupportedByTarget(assertions, target) {
|
|
435
|
-
if (target
|
|
450
|
+
if (target === "web" || !assertions || assertions.length === 0) {
|
|
436
451
|
return;
|
|
437
452
|
}
|
|
438
453
|
throw new Error(
|
|
439
|
-
|
|
454
|
+
`Hunt-level assertions are not supported by the ${nativeTargetLabel(target)} target. Use inline assert visible/notVisible steps instead.`
|
|
440
455
|
);
|
|
441
456
|
}
|
|
442
457
|
function trimTrailingPathSeparators(value) {
|
|
443
458
|
return value.replace(/[\\/]+$/g, "");
|
|
444
459
|
}
|
|
445
|
-
function
|
|
460
|
+
function looksLikeAppBundlePath(app) {
|
|
446
461
|
const trimmed = trimTrailingPathSeparators(app);
|
|
447
462
|
return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
|
|
448
463
|
}
|
|
464
|
+
function looksLikeIosAppPath(app) {
|
|
465
|
+
const trimmed = trimTrailingPathSeparators(app);
|
|
466
|
+
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
if (trimmed.toLowerCase().endsWith(".app")) {
|
|
470
|
+
try {
|
|
471
|
+
return fs.statSync(normalizeAppPath(app)).isDirectory();
|
|
472
|
+
} catch {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
449
478
|
function normalizeAppPath(app) {
|
|
450
479
|
return path.resolve(trimTrailingPathSeparators(app));
|
|
451
480
|
}
|
|
@@ -453,8 +482,8 @@ function parseBundleIdentifier(plist) {
|
|
|
453
482
|
const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
|
|
454
483
|
return match?.[1]?.trim() || null;
|
|
455
484
|
}
|
|
456
|
-
function readBundleIdentifier(appPath) {
|
|
457
|
-
const infoPlistPath = path.join(normalizeAppPath(appPath),
|
|
485
|
+
function readBundleIdentifier(appPath, ...plistSubPath) {
|
|
486
|
+
const infoPlistPath = path.join(normalizeAppPath(appPath), ...plistSubPath);
|
|
458
487
|
if (!fs.existsSync(infoPlistPath)) {
|
|
459
488
|
return null;
|
|
460
489
|
}
|
|
@@ -479,9 +508,15 @@ function readBundleIdentifier(appPath) {
|
|
|
479
508
|
return null;
|
|
480
509
|
}
|
|
481
510
|
}
|
|
482
|
-
function
|
|
511
|
+
function readMacosBundleIdentifier(appPath) {
|
|
512
|
+
return readBundleIdentifier(appPath, "Contents", "Info.plist");
|
|
513
|
+
}
|
|
514
|
+
function readIosBundleIdentifier(appPath) {
|
|
515
|
+
return readBundleIdentifier(appPath, "Info.plist");
|
|
516
|
+
}
|
|
517
|
+
function appBundleAllowedIdentities(app, readBundleId, isAppPath = looksLikeAppBundlePath) {
|
|
483
518
|
const identities = /* @__PURE__ */ new Set([app]);
|
|
484
|
-
if (
|
|
519
|
+
if (isAppPath(app)) {
|
|
485
520
|
const normalizedPath = normalizeAppPath(app);
|
|
486
521
|
identities.add(trimTrailingPathSeparators(app));
|
|
487
522
|
identities.add(normalizedPath);
|
|
@@ -489,19 +524,56 @@ function macosAppAllowedIdentities(app) {
|
|
|
489
524
|
if (bundleName) {
|
|
490
525
|
identities.add(bundleName);
|
|
491
526
|
}
|
|
492
|
-
const bundleId =
|
|
527
|
+
const bundleId = readBundleId(app);
|
|
493
528
|
if (bundleId) {
|
|
494
529
|
identities.add(bundleId);
|
|
495
530
|
}
|
|
496
531
|
}
|
|
497
532
|
return [...identities];
|
|
498
533
|
}
|
|
534
|
+
function macosAppAllowedIdentities(app) {
|
|
535
|
+
return appBundleAllowedIdentities(app, readMacosBundleIdentifier);
|
|
536
|
+
}
|
|
537
|
+
function iosAppAllowedIdentities(app) {
|
|
538
|
+
return appBundleAllowedIdentities(app, readIosBundleIdentifier, looksLikeIosAppPath);
|
|
539
|
+
}
|
|
499
540
|
function assertTargetAppAllowed(allowedApps, app) {
|
|
541
|
+
assertNativeAppAllowed(allowedApps, app, macosAppAllowedIdentities);
|
|
542
|
+
}
|
|
543
|
+
function assertIosAppAllowed(allowedApps, app) {
|
|
544
|
+
assertNativeAppAllowed(allowedApps, app, iosAppAllowedIdentities);
|
|
545
|
+
}
|
|
546
|
+
function looksLikeApkPath(app) {
|
|
547
|
+
const trimmed = trimTrailingPathSeparators(app);
|
|
548
|
+
return trimmed.includes("/") || trimmed.includes("\\") || trimmed.toLowerCase().endsWith(".apk");
|
|
549
|
+
}
|
|
550
|
+
function normalizeAndroidApkPath(app) {
|
|
551
|
+
const resolved = path.resolve(trimTrailingPathSeparators(app));
|
|
552
|
+
try {
|
|
553
|
+
return fs.realpathSync.native(resolved);
|
|
554
|
+
} catch {
|
|
555
|
+
return resolved;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
function androidAppAllowedIdentities(app, resolvedPackage) {
|
|
559
|
+
if (looksLikeApkPath(app)) {
|
|
560
|
+
return resolvedPackage ? [normalizeAndroidApkPath(app), resolvedPackage] : [normalizeAndroidApkPath(app)];
|
|
561
|
+
}
|
|
562
|
+
return [app];
|
|
563
|
+
}
|
|
564
|
+
function assertAndroidAppAllowed(allowedApps, app, resolvedPackage) {
|
|
565
|
+
assertNativeAppAllowed(
|
|
566
|
+
allowedApps,
|
|
567
|
+
app,
|
|
568
|
+
(value) => androidAppAllowedIdentities(value, value === app ? resolvedPackage : void 0)
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
function assertNativeAppAllowed(allowedApps, app, resolveIdentities) {
|
|
500
572
|
if (allowedApps.length === 0) {
|
|
501
573
|
return;
|
|
502
574
|
}
|
|
503
|
-
const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) =>
|
|
504
|
-
if (
|
|
575
|
+
const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => resolveIdentities(allowedApp)));
|
|
576
|
+
if (resolveIdentities(app).some((identity) => allowedIdentities.has(identity))) {
|
|
505
577
|
return;
|
|
506
578
|
}
|
|
507
579
|
throw new Error(
|
|
@@ -657,7 +729,11 @@ function createMacDriver(client, options = {}) {
|
|
|
657
729
|
return rejectUnsupported("evalScript");
|
|
658
730
|
},
|
|
659
731
|
async screenshot(screenshotOptions) {
|
|
660
|
-
await client.request("screenshot", { path: screenshotOptions.path });
|
|
732
|
+
const result = await client.request("screenshot", { path: screenshotOptions.path });
|
|
733
|
+
const warning = result.warning;
|
|
734
|
+
if (typeof warning === "string" && warning.length > 0) {
|
|
735
|
+
console.warn(`macOS screenshot fell back to full screen: ${warning}`);
|
|
736
|
+
}
|
|
661
737
|
},
|
|
662
738
|
// network / dialogs / downloads (all web-only) -------------------------
|
|
663
739
|
onResponse(_handler) {
|
|
@@ -887,187 +963,2278 @@ async function closeMacSession(session) {
|
|
|
887
963
|
}
|
|
888
964
|
}
|
|
889
965
|
|
|
890
|
-
// src/
|
|
891
|
-
var
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
966
|
+
// src/analyzer/xml.ts
|
|
967
|
+
var ENTITIES = {
|
|
968
|
+
amp: "&",
|
|
969
|
+
lt: "<",
|
|
970
|
+
gt: ">",
|
|
971
|
+
quot: '"',
|
|
972
|
+
apos: "'"
|
|
973
|
+
};
|
|
974
|
+
function decodeXmlEntities(value) {
|
|
975
|
+
if (!value.includes("&")) {
|
|
976
|
+
return value;
|
|
900
977
|
}
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
words.push(lower);
|
|
907
|
-
}
|
|
978
|
+
return value.replace(/&(#(?:[xX][0-9a-fA-F]+|[0-9]+)|[a-zA-Z]+);/g, (match, code) => {
|
|
979
|
+
if (code[0] === "#") {
|
|
980
|
+
const hex = code[1] === "x" || code[1] === "X";
|
|
981
|
+
const num2 = Number.parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10);
|
|
982
|
+
return Number.isSafeInteger(num2) && num2 <= 1114111 ? String.fromCodePoint(num2) : match;
|
|
908
983
|
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
}
|
|
912
|
-
function splitToken(token) {
|
|
913
|
-
return token.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[\s\-_.:]+/).filter((part) => part.length > 0);
|
|
914
|
-
}
|
|
915
|
-
function buildHealCandidates(selector) {
|
|
916
|
-
const { words, label } = extractSelectorIntent(selector);
|
|
917
|
-
if (words.length === 0) return [];
|
|
918
|
-
const escaped = label.replace(/"/g, '\\"');
|
|
919
|
-
const candidates = [];
|
|
920
|
-
candidates.push({ selector: `text=${label}`, strategy: "text" });
|
|
921
|
-
candidates.push({ selector: `[aria-label*="${escaped}" i]`, strategy: "aria" });
|
|
922
|
-
for (const tag of INTERACTIVE_TAGS) {
|
|
923
|
-
candidates.push({ selector: `${tag}:has-text("${escaped}")`, strategy: "structural" });
|
|
924
|
-
}
|
|
925
|
-
return candidates;
|
|
984
|
+
const named = ENTITIES[code];
|
|
985
|
+
return named ?? match;
|
|
986
|
+
});
|
|
926
987
|
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
988
|
+
var ATTR_RE = /([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
|
989
|
+
function parseTagBody(body) {
|
|
990
|
+
const trimmed = body.trim();
|
|
991
|
+
const nameMatch = /^([^\s/>]+)/.exec(trimmed);
|
|
992
|
+
const tag = nameMatch ? nameMatch[1] : "";
|
|
993
|
+
const attrs = {};
|
|
994
|
+
const rest = trimmed.slice(tag.length);
|
|
995
|
+
ATTR_RE.lastIndex = 0;
|
|
996
|
+
let m;
|
|
997
|
+
while ((m = ATTR_RE.exec(rest)) !== null) {
|
|
998
|
+
const rawValue = m[3] !== void 0 ? m[3] : m[4] ?? "";
|
|
999
|
+
attrs[m[1]] = decodeXmlEntities(rawValue);
|
|
1000
|
+
}
|
|
1001
|
+
return { tag, attrs };
|
|
1002
|
+
}
|
|
1003
|
+
function parseXml(input) {
|
|
1004
|
+
const n = input.length;
|
|
1005
|
+
const stack = [];
|
|
1006
|
+
let root = null;
|
|
1007
|
+
let i = 0;
|
|
1008
|
+
while (i < n) {
|
|
1009
|
+
const lt = input.indexOf("<", i);
|
|
1010
|
+
if (lt < 0) {
|
|
1011
|
+
break;
|
|
1012
|
+
}
|
|
1013
|
+
i = lt + 1;
|
|
1014
|
+
const ch = input[i];
|
|
1015
|
+
if (ch === "?") {
|
|
1016
|
+
const end = input.indexOf("?>", i);
|
|
1017
|
+
i = end < 0 ? n : end + 2;
|
|
935
1018
|
continue;
|
|
936
1019
|
}
|
|
937
|
-
if (
|
|
938
|
-
|
|
1020
|
+
if (ch === "!") {
|
|
1021
|
+
if (input.startsWith("!--", i)) {
|
|
1022
|
+
const end = input.indexOf("-->", i);
|
|
1023
|
+
i = end < 0 ? n : end + 3;
|
|
1024
|
+
} else {
|
|
1025
|
+
const end = input.indexOf(">", i);
|
|
1026
|
+
i = end < 0 ? n : end + 1;
|
|
1027
|
+
}
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
if (ch === "/") {
|
|
1031
|
+
const gt = input.indexOf(">", i);
|
|
1032
|
+
i = gt < 0 ? n : gt + 1;
|
|
1033
|
+
stack.pop();
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
let j = i;
|
|
1037
|
+
let quote2 = null;
|
|
1038
|
+
while (j < n) {
|
|
1039
|
+
const c = input[j];
|
|
1040
|
+
if (quote2 !== null) {
|
|
1041
|
+
if (c === quote2) {
|
|
1042
|
+
quote2 = null;
|
|
1043
|
+
}
|
|
1044
|
+
} else if (c === '"' || c === "'") {
|
|
1045
|
+
quote2 = c;
|
|
1046
|
+
} else if (c === ">") {
|
|
1047
|
+
break;
|
|
1048
|
+
}
|
|
1049
|
+
j += 1;
|
|
1050
|
+
}
|
|
1051
|
+
const inner = input.slice(i, j);
|
|
1052
|
+
i = j + 1;
|
|
1053
|
+
const selfClose = inner.endsWith("/");
|
|
1054
|
+
const body = selfClose ? inner.slice(0, -1) : inner;
|
|
1055
|
+
const { tag, attrs } = parseTagBody(body);
|
|
1056
|
+
if (tag.length === 0) {
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
const element = { tag, attrs, children: [] };
|
|
1060
|
+
const parent = stack[stack.length - 1];
|
|
1061
|
+
if (parent) {
|
|
1062
|
+
parent.children.push(element);
|
|
1063
|
+
}
|
|
1064
|
+
if (root === null) {
|
|
1065
|
+
root = element;
|
|
1066
|
+
}
|
|
1067
|
+
if (!selfClose) {
|
|
1068
|
+
stack.push(element);
|
|
939
1069
|
}
|
|
940
1070
|
}
|
|
941
|
-
return
|
|
1071
|
+
return root;
|
|
942
1072
|
}
|
|
943
1073
|
|
|
944
|
-
// src/
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
function historyPath(configDir) {
|
|
953
|
-
return path3.join(configDir, HISTORY_FILE);
|
|
1074
|
+
// src/selector/native.ts
|
|
1075
|
+
function unquoteSelectorValue(value) {
|
|
1076
|
+
const trimmed = value.trim();
|
|
1077
|
+
const first = trimmed[0];
|
|
1078
|
+
if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
|
|
1079
|
+
return trimmed.slice(1, -1);
|
|
1080
|
+
}
|
|
1081
|
+
return trimmed;
|
|
954
1082
|
}
|
|
955
|
-
function
|
|
956
|
-
|
|
957
|
-
|
|
1083
|
+
function quoteSelectorValue(value) {
|
|
1084
|
+
return `"${value}"`;
|
|
1085
|
+
}
|
|
1086
|
+
var ROLE_SELECTOR_RE = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s;
|
|
1087
|
+
var NATIVE_SELECTOR_PREFIX_RE = /^(id|label|text|role)=/;
|
|
1088
|
+
function invalidSelectorMessage(selector, reason) {
|
|
1089
|
+
return `Invalid native selector ${JSON.stringify(selector)}: ${reason}.`;
|
|
1090
|
+
}
|
|
1091
|
+
function parseNativeSelector(selector) {
|
|
1092
|
+
const trimmed = selector.trim();
|
|
1093
|
+
if (trimmed.length === 0) {
|
|
1094
|
+
throw new Error(
|
|
1095
|
+
invalidSelectorMessage(
|
|
1096
|
+
selector,
|
|
1097
|
+
"selector is empty; use id=, label=, text=, role=, :focus, or a bare text value"
|
|
1098
|
+
)
|
|
1099
|
+
);
|
|
958
1100
|
}
|
|
959
|
-
|
|
960
|
-
|
|
1101
|
+
if (trimmed === ":focus") {
|
|
1102
|
+
return { kind: "focused" };
|
|
1103
|
+
}
|
|
1104
|
+
const idMatch = /^id=(.+)$/s.exec(trimmed);
|
|
1105
|
+
if (idMatch) {
|
|
1106
|
+
return { kind: "id", value: unquoteSelectorValue(idMatch[1]) };
|
|
1107
|
+
}
|
|
1108
|
+
const roleMatch = ROLE_SELECTOR_RE.exec(trimmed);
|
|
1109
|
+
if (roleMatch) {
|
|
1110
|
+
const name = roleMatch[2] !== void 0 ? unquoteSelectorValue(roleMatch[2]) : void 0;
|
|
1111
|
+
return name !== void 0 && name.length > 0 ? { kind: "role", role: roleMatch[1], name } : { kind: "role", role: roleMatch[1] };
|
|
1112
|
+
}
|
|
1113
|
+
const labelMatch = /^label=(.+)$/s.exec(trimmed);
|
|
1114
|
+
if (labelMatch) {
|
|
1115
|
+
return { kind: "label", value: unquoteSelectorValue(labelMatch[1]) };
|
|
1116
|
+
}
|
|
1117
|
+
const textMatch = /^text=(.+)$/s.exec(trimmed);
|
|
1118
|
+
if (textMatch) {
|
|
1119
|
+
return { kind: "text", value: unquoteSelectorValue(textMatch[1]) };
|
|
1120
|
+
}
|
|
1121
|
+
const prefix = NATIVE_SELECTOR_PREFIX_RE.exec(trimmed);
|
|
1122
|
+
if (prefix) {
|
|
1123
|
+
throw new Error(
|
|
1124
|
+
invalidSelectorMessage(
|
|
1125
|
+
selector,
|
|
1126
|
+
`malformed ${prefix[1]}= selector; expected id=<value>, label=<value>, text=<value>, or role=<Type>[name=<value>]`
|
|
1127
|
+
)
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
return { kind: "text", value: trimmed };
|
|
961
1131
|
}
|
|
962
|
-
function
|
|
963
|
-
const
|
|
964
|
-
if (!
|
|
965
|
-
return
|
|
1132
|
+
function unwrapNativeTextSelector(selector) {
|
|
1133
|
+
const trimmed = selector.trim();
|
|
1134
|
+
if (!trimmed.startsWith("text=")) {
|
|
1135
|
+
return null;
|
|
966
1136
|
}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
return { entries: validatedEntries };
|
|
973
|
-
}
|
|
974
|
-
return { entries: [] };
|
|
975
|
-
} catch (error) {
|
|
976
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
977
|
-
console.warn(`Failed to read history file at ${filePath}: ${message}`);
|
|
978
|
-
return { entries: [] };
|
|
1137
|
+
return unquoteSelectorValue(trimmed.slice("text=".length));
|
|
1138
|
+
}
|
|
1139
|
+
function qualifyResourceId(value, appPackage) {
|
|
1140
|
+
if (value.includes(":") || !appPackage) {
|
|
1141
|
+
return value;
|
|
979
1142
|
}
|
|
1143
|
+
return `${appPackage}:id/${value}`;
|
|
980
1144
|
}
|
|
981
|
-
function
|
|
982
|
-
|
|
983
|
-
return entries.filter((entry) => entry.hunt === huntName);
|
|
1145
|
+
function normalizeXcuiClassName(role) {
|
|
1146
|
+
return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
|
|
984
1147
|
}
|
|
985
|
-
function
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1148
|
+
function shortIosType(type) {
|
|
1149
|
+
return type.startsWith("XCUIElementType") ? type.slice("XCUIElementType".length) : type;
|
|
1150
|
+
}
|
|
1151
|
+
function rankNativeSelectors(fields) {
|
|
1152
|
+
const selectors = [];
|
|
1153
|
+
if (fields.id) {
|
|
1154
|
+
selectors.push(`id=${fields.id}`);
|
|
991
1155
|
}
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
const kept = list.length > maxRuns ? list.slice(list.length - maxRuns) : list;
|
|
995
|
-
for (const entry of kept) {
|
|
996
|
-
keptEntries.add(entry);
|
|
997
|
-
}
|
|
1156
|
+
if (fields.label) {
|
|
1157
|
+
selectors.push(`label=${quoteSelectorValue(fields.label)}`);
|
|
998
1158
|
}
|
|
999
|
-
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1159
|
+
if (fields.role && fields.name) {
|
|
1160
|
+
selectors.push(`role=${fields.role}[name=${quoteSelectorValue(fields.name)}]`);
|
|
1161
|
+
}
|
|
1162
|
+
if (fields.name) {
|
|
1163
|
+
selectors.push(`text=${quoteSelectorValue(fields.name)}`);
|
|
1164
|
+
}
|
|
1165
|
+
if (selectors.length === 0 && fields.role) {
|
|
1166
|
+
selectors.push(`role=${fields.role}`);
|
|
1167
|
+
}
|
|
1168
|
+
return selectors;
|
|
1003
1169
|
}
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1170
|
+
var NATIVE_ATTRIBUTE_MAP = {
|
|
1171
|
+
android: {
|
|
1172
|
+
id: { attribute: "resource-id", match: "exact (package-qualified)" },
|
|
1173
|
+
label: { attribute: "content-desc", match: "exact" },
|
|
1174
|
+
text: { attribute: "text", match: "substring" },
|
|
1175
|
+
role: { attribute: "class", match: "exact" }
|
|
1176
|
+
},
|
|
1177
|
+
ios: {
|
|
1178
|
+
id: { attribute: "accessibility id (name)", match: "exact" },
|
|
1179
|
+
label: { attribute: "label", match: "exact" },
|
|
1180
|
+
text: { attribute: "label | value", match: "substring" },
|
|
1181
|
+
role: { attribute: "type (XCUIElementType\u2026)", match: "exact" }
|
|
1182
|
+
},
|
|
1183
|
+
macos: {
|
|
1184
|
+
id: { attribute: "AXIdentifier", match: "exact" },
|
|
1185
|
+
label: { attribute: "title | description", match: "exact" },
|
|
1186
|
+
text: { attribute: "title | description | value", match: "substring" },
|
|
1187
|
+
role: { attribute: "AXRole", match: "exact" }
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
var ANDROID_MATCH_DIALECT = {
|
|
1191
|
+
platform: "android",
|
|
1192
|
+
normalizeRole: (role) => role,
|
|
1193
|
+
normalizeId: (value, options) => qualifyResourceId(value, options.appPackage)
|
|
1194
|
+
};
|
|
1195
|
+
var IOS_MATCH_DIALECT = {
|
|
1196
|
+
platform: "ios",
|
|
1197
|
+
normalizeRole: (role) => normalizeXcuiClassName(role),
|
|
1198
|
+
normalizeId: (value) => value
|
|
1199
|
+
};
|
|
1200
|
+
var MACOS_MATCH_DIALECT = {
|
|
1201
|
+
platform: "macos",
|
|
1202
|
+
normalizeRole: (role) => role,
|
|
1203
|
+
normalizeId: (value) => value
|
|
1204
|
+
};
|
|
1205
|
+
function nodeMatchesSelector(dialect, selector, node, options = {}) {
|
|
1206
|
+
switch (selector.kind) {
|
|
1207
|
+
case "id":
|
|
1208
|
+
return node.id !== void 0 && node.id === dialect.normalizeId(selector.value, options);
|
|
1209
|
+
case "label":
|
|
1210
|
+
return node.label !== void 0 && node.label === selector.value;
|
|
1211
|
+
case "text":
|
|
1212
|
+
return node.textValues.some((t) => t.includes(selector.value));
|
|
1213
|
+
case "focused":
|
|
1214
|
+
return node.focused === true;
|
|
1215
|
+
case "role": {
|
|
1216
|
+
if (node.role === void 0) {
|
|
1217
|
+
return false;
|
|
1017
1218
|
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
try {
|
|
1021
|
-
return fn();
|
|
1022
|
-
} finally {
|
|
1023
|
-
try {
|
|
1024
|
-
fs3.closeSync(fd);
|
|
1025
|
-
} catch {
|
|
1219
|
+
if (dialect.normalizeRole(node.role) !== dialect.normalizeRole(selector.role)) {
|
|
1220
|
+
return false;
|
|
1026
1221
|
}
|
|
1027
|
-
|
|
1222
|
+
if (selector.name === void 0 || selector.name.length === 0) {
|
|
1223
|
+
return true;
|
|
1224
|
+
}
|
|
1225
|
+
const name = selector.name;
|
|
1226
|
+
return node.textValues.some((t) => t.includes(name));
|
|
1028
1227
|
}
|
|
1029
1228
|
}
|
|
1030
|
-
throw new Error(
|
|
1031
|
-
`Failed to acquire history lock before timeout (${LOCK_TIMEOUT_MS}ms): ${lockPath}; started waiting at ${new Date(startedAt).toISOString()}`
|
|
1032
|
-
);
|
|
1033
1229
|
}
|
|
1034
|
-
function
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
}
|
|
1230
|
+
function matchNativeTree(dialect, selector, root, project, children, options = {}) {
|
|
1231
|
+
const out = [];
|
|
1232
|
+
const visit = (node) => {
|
|
1233
|
+
if (nodeMatchesSelector(dialect, selector, project(node), options)) {
|
|
1234
|
+
out.push(node);
|
|
1235
|
+
}
|
|
1236
|
+
for (const child of children(node)) {
|
|
1237
|
+
visit(child);
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
visit(root);
|
|
1241
|
+
return out;
|
|
1242
|
+
}
|
|
1243
|
+
function parseSnapshot(xml) {
|
|
1244
|
+
return parseXml(xml);
|
|
1044
1245
|
}
|
|
1045
1246
|
|
|
1046
|
-
// src/
|
|
1047
|
-
import
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1247
|
+
// src/browser/android-driver.ts
|
|
1248
|
+
import fs3 from "fs";
|
|
1249
|
+
var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
1250
|
+
"query",
|
|
1251
|
+
"interact",
|
|
1252
|
+
"wait",
|
|
1253
|
+
"screenshot"
|
|
1254
|
+
]);
|
|
1255
|
+
var WAIT_POLL_INTERVAL_MS = 250;
|
|
1256
|
+
var DEFAULT_WAIT_TIMEOUT_MS = 5e3;
|
|
1257
|
+
var ANDROID_KEYCODES = {
|
|
1258
|
+
enter: 66,
|
|
1259
|
+
return: 66,
|
|
1260
|
+
tab: 61,
|
|
1261
|
+
space: 62,
|
|
1262
|
+
backspace: 67,
|
|
1263
|
+
delete: 67,
|
|
1264
|
+
del: 67,
|
|
1265
|
+
escape: 111,
|
|
1266
|
+
esc: 111,
|
|
1267
|
+
back: 4,
|
|
1268
|
+
home: 3,
|
|
1269
|
+
menu: 82,
|
|
1270
|
+
search: 84,
|
|
1271
|
+
up: 19,
|
|
1272
|
+
arrowup: 19,
|
|
1273
|
+
down: 20,
|
|
1274
|
+
arrowdown: 20,
|
|
1275
|
+
left: 21,
|
|
1276
|
+
arrowleft: 21,
|
|
1277
|
+
right: 22,
|
|
1278
|
+
arrowright: 22,
|
|
1279
|
+
pageup: 92,
|
|
1280
|
+
pagedown: 93
|
|
1281
|
+
};
|
|
1282
|
+
function escapeUiSelectorArg(value) {
|
|
1283
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1284
|
+
}
|
|
1285
|
+
function toAndroidQuery(selector) {
|
|
1286
|
+
switch (selector.kind) {
|
|
1287
|
+
case "focused":
|
|
1288
|
+
return { by: "focused" };
|
|
1289
|
+
case "id":
|
|
1290
|
+
return { by: "id", value: selector.value };
|
|
1291
|
+
case "role":
|
|
1292
|
+
return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
|
|
1293
|
+
case "label":
|
|
1294
|
+
return { by: "accessibilityId", value: selector.value };
|
|
1295
|
+
case "text":
|
|
1296
|
+
return { by: "text", value: selector.value };
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
function parseAndroidSelector(selector) {
|
|
1300
|
+
return toAndroidQuery(parseNativeSelector(selector));
|
|
1301
|
+
}
|
|
1302
|
+
function locator(strategy, selector) {
|
|
1303
|
+
return { strategy, selector, context: "" };
|
|
1304
|
+
}
|
|
1305
|
+
function androidQueryToLocator(query, options = {}) {
|
|
1306
|
+
switch (query.by) {
|
|
1307
|
+
case "id":
|
|
1308
|
+
return locator("id", qualifyResourceId(query.value, options.appPackage));
|
|
1309
|
+
case "accessibilityId":
|
|
1310
|
+
return locator("accessibility id", query.value);
|
|
1311
|
+
case "text":
|
|
1312
|
+
return locator(
|
|
1313
|
+
"-android uiautomator",
|
|
1314
|
+
`new UiSelector().textContains("${escapeUiSelectorArg(query.value)}")`
|
|
1315
|
+
);
|
|
1316
|
+
case "focused":
|
|
1317
|
+
return locator("-android uiautomator", "new UiSelector().focused(true)");
|
|
1318
|
+
case "role": {
|
|
1319
|
+
const className = escapeUiSelectorArg(query.role);
|
|
1320
|
+
if (query.name === void 0 || query.name.length === 0) {
|
|
1321
|
+
return locator("class name", query.role);
|
|
1322
|
+
}
|
|
1323
|
+
return locator(
|
|
1324
|
+
"-android uiautomator",
|
|
1325
|
+
`new UiSelector().className("${className}").textContains("${escapeUiSelectorArg(query.name)}")`
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
function unwrapAndroidTextSelector(selector) {
|
|
1331
|
+
return unwrapNativeTextSelector(selector);
|
|
1332
|
+
}
|
|
1333
|
+
function keyCodeFor(key) {
|
|
1334
|
+
const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
|
|
1335
|
+
if (code === void 0) {
|
|
1063
1336
|
throw new Error(
|
|
1064
|
-
`Unsupported
|
|
1337
|
+
`Unsupported key "${key}" for the Android target. Supported keys: ${Object.keys(ANDROID_KEYCODES).sort().join(", ")}.`
|
|
1065
1338
|
);
|
|
1066
1339
|
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1340
|
+
return code;
|
|
1341
|
+
}
|
|
1342
|
+
function delay(ms) {
|
|
1343
|
+
return new Promise((resolve) => {
|
|
1344
|
+
setTimeout(resolve, ms);
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
function createAndroidDriver(client, options = {}) {
|
|
1348
|
+
const unsupported = (verb) => new Error(`${verb} is not supported by the Android target`);
|
|
1349
|
+
const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
|
|
1350
|
+
async function resolveOne(selector) {
|
|
1351
|
+
const id = await client.findElement(parseAndroidSelector(selector));
|
|
1352
|
+
if (id === null) {
|
|
1353
|
+
throw new Error(`No element matched selector: ${selector}`);
|
|
1354
|
+
}
|
|
1355
|
+
return id;
|
|
1356
|
+
}
|
|
1357
|
+
async function clickSelector(selector) {
|
|
1358
|
+
await client.click(await resolveOne(selector));
|
|
1359
|
+
}
|
|
1360
|
+
async function fillSelector(selector, value) {
|
|
1361
|
+
await client.setValue(await resolveOne(selector), value);
|
|
1362
|
+
}
|
|
1363
|
+
return {
|
|
1364
|
+
capabilities: ANDROID_CAPABILITIES,
|
|
1365
|
+
// navigation -----------------------------------------------------------
|
|
1366
|
+
goto(_url, _options) {
|
|
1367
|
+
return rejectUnsupported("navigate");
|
|
1368
|
+
},
|
|
1369
|
+
currentUrl() {
|
|
1370
|
+
return `android:${options.appLabel ?? ""}`;
|
|
1371
|
+
},
|
|
1372
|
+
// queries --------------------------------------------------------------
|
|
1373
|
+
async count(selector) {
|
|
1374
|
+
return (await client.findElements(parseAndroidSelector(selector))).length;
|
|
1375
|
+
},
|
|
1376
|
+
async textContent(selector) {
|
|
1377
|
+
const id = await client.findElement(parseAndroidSelector(selector));
|
|
1378
|
+
if (id === null) {
|
|
1379
|
+
return null;
|
|
1380
|
+
}
|
|
1381
|
+
return client.getText(id);
|
|
1382
|
+
},
|
|
1383
|
+
// interactions ---------------------------------------------------------
|
|
1384
|
+
click: clickSelector,
|
|
1385
|
+
clickFirst: clickSelector,
|
|
1386
|
+
fill: fillSelector,
|
|
1387
|
+
fillFirst: fillSelector,
|
|
1388
|
+
async press(_selector, key) {
|
|
1389
|
+
await client.pressKeyCode(keyCodeFor(key));
|
|
1390
|
+
},
|
|
1391
|
+
selectOption() {
|
|
1392
|
+
return rejectUnsupported("select");
|
|
1393
|
+
},
|
|
1394
|
+
selectOptionFirst() {
|
|
1395
|
+
return rejectUnsupported("select");
|
|
1396
|
+
},
|
|
1397
|
+
hover() {
|
|
1398
|
+
return rejectUnsupported("hover");
|
|
1399
|
+
},
|
|
1400
|
+
scrollIntoView() {
|
|
1401
|
+
return rejectUnsupported("scrollTo");
|
|
1402
|
+
},
|
|
1403
|
+
setInputFiles() {
|
|
1404
|
+
return rejectUnsupported("setInputFiles");
|
|
1405
|
+
},
|
|
1406
|
+
// semantic locators ----------------------------------------------------
|
|
1407
|
+
async countByRole(role, name) {
|
|
1408
|
+
return (await client.findElements({ by: "role", role, name })).length;
|
|
1409
|
+
},
|
|
1410
|
+
async clickFirstByRole(role, name) {
|
|
1411
|
+
const id = await client.findElement({ by: "role", role, name });
|
|
1412
|
+
if (id === null) {
|
|
1413
|
+
throw new Error(`No element matched role=${role}[name="${name}"]`);
|
|
1414
|
+
}
|
|
1415
|
+
await client.click(id);
|
|
1416
|
+
},
|
|
1417
|
+
async countByLabel(label) {
|
|
1418
|
+
return (await client.findElements({ by: "accessibilityId", value: label })).length;
|
|
1419
|
+
},
|
|
1420
|
+
async fillFirstByLabel(label, value) {
|
|
1421
|
+
const id = await client.findElement({ by: "accessibilityId", value: label });
|
|
1422
|
+
if (id === null) {
|
|
1423
|
+
throw new Error(`No element matched label="${label}"`);
|
|
1424
|
+
}
|
|
1425
|
+
await client.setValue(id, value);
|
|
1426
|
+
},
|
|
1427
|
+
selectOptionFirstByLabel() {
|
|
1428
|
+
return rejectUnsupported("select");
|
|
1429
|
+
},
|
|
1430
|
+
// waiting --------------------------------------------------------------
|
|
1431
|
+
async waitForSelector(selector, waitOptions) {
|
|
1432
|
+
const query = parseAndroidSelector(selector);
|
|
1433
|
+
const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
1434
|
+
const deadline = Date.now() + timeoutMs;
|
|
1435
|
+
for (; ; ) {
|
|
1436
|
+
if ((await client.findElements(query)).length > 0) {
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
if (Date.now() >= deadline) {
|
|
1440
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
|
|
1441
|
+
}
|
|
1442
|
+
await delay(Math.min(WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
|
|
1443
|
+
}
|
|
1444
|
+
},
|
|
1445
|
+
waitForUrl() {
|
|
1446
|
+
return rejectUnsupported("waitForUrl");
|
|
1447
|
+
},
|
|
1448
|
+
waitForNetworkIdle() {
|
|
1449
|
+
return rejectUnsupported("waitForNetworkIdle");
|
|
1450
|
+
},
|
|
1451
|
+
// scripting & artifacts ------------------------------------------------
|
|
1452
|
+
evaluate() {
|
|
1453
|
+
return rejectUnsupported("evalScript");
|
|
1454
|
+
},
|
|
1455
|
+
async screenshot(screenshotOptions) {
|
|
1456
|
+
const png = await client.screenshotPng();
|
|
1457
|
+
fs3.writeFileSync(screenshotOptions.path, png);
|
|
1458
|
+
},
|
|
1459
|
+
// network / dialogs / downloads (all web-only) -------------------------
|
|
1460
|
+
onResponse(_handler) {
|
|
1461
|
+
throw unsupported("onResponse");
|
|
1462
|
+
},
|
|
1463
|
+
route(_url, _handler) {
|
|
1464
|
+
return rejectUnsupported("mockRoute");
|
|
1465
|
+
},
|
|
1466
|
+
unroute() {
|
|
1467
|
+
return rejectUnsupported("unmockRoute");
|
|
1468
|
+
},
|
|
1469
|
+
onDialog(_action) {
|
|
1470
|
+
throw unsupported("onDialog");
|
|
1471
|
+
},
|
|
1472
|
+
waitForDownloadEvent() {
|
|
1473
|
+
return rejectUnsupported("waitForDownload");
|
|
1474
|
+
},
|
|
1475
|
+
parseTextSelector(selector) {
|
|
1476
|
+
return unwrapAndroidTextSelector(selector);
|
|
1477
|
+
}
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// src/browser/android-adb.ts
|
|
1482
|
+
import { execFile, spawn as spawn2 } from "child_process";
|
|
1483
|
+
var execFileAdbRunner = (args, options) => new Promise((resolve) => {
|
|
1484
|
+
execFile(
|
|
1485
|
+
"adb",
|
|
1486
|
+
args,
|
|
1487
|
+
{ encoding: "utf-8", timeout: options?.timeoutMs, maxBuffer: 16 * 1024 * 1024 },
|
|
1488
|
+
(error, stdout, stderr) => {
|
|
1489
|
+
const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
|
|
1490
|
+
const capturedStderr = stderr ?? "";
|
|
1491
|
+
resolve({
|
|
1492
|
+
stdout: stdout ?? "",
|
|
1493
|
+
stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
|
|
1494
|
+
code
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
);
|
|
1498
|
+
});
|
|
1499
|
+
var spawnAdbProcess = (args) => {
|
|
1500
|
+
const child = spawn2("adb", args, { stdio: "ignore" });
|
|
1501
|
+
child.on("error", () => {
|
|
1502
|
+
});
|
|
1503
|
+
return { kill: () => child.kill() };
|
|
1504
|
+
};
|
|
1505
|
+
function withSerial(serial, args) {
|
|
1506
|
+
return serial ? ["-s", serial, ...args] : args;
|
|
1507
|
+
}
|
|
1508
|
+
function parseAdbDevices(stdout) {
|
|
1509
|
+
const devices = [];
|
|
1510
|
+
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
1511
|
+
const line = rawLine.trim();
|
|
1512
|
+
if (!line || /^list of devices attached/i.test(line)) {
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1515
|
+
const [serial, state, ...rest] = line.split(/\s+/);
|
|
1516
|
+
if (!serial || !state) {
|
|
1517
|
+
continue;
|
|
1518
|
+
}
|
|
1519
|
+
const description = {};
|
|
1520
|
+
for (const token of rest) {
|
|
1521
|
+
const eq = token.indexOf(":");
|
|
1522
|
+
if (eq > 0) {
|
|
1523
|
+
description[token.slice(0, eq)] = token.slice(eq + 1);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
devices.push({ serial, state, description });
|
|
1527
|
+
}
|
|
1528
|
+
return devices;
|
|
1529
|
+
}
|
|
1530
|
+
function bootedDevices(devices) {
|
|
1531
|
+
return devices.filter((device) => device.state === "device");
|
|
1532
|
+
}
|
|
1533
|
+
function selectDeviceSerial(devices, requested) {
|
|
1534
|
+
const booted = bootedDevices(devices);
|
|
1535
|
+
if (requested) {
|
|
1536
|
+
const match = devices.find((device) => device.serial === requested);
|
|
1537
|
+
if (!match) {
|
|
1538
|
+
const attached = devices.length > 0 ? devices.map((d) => d.serial).join(", ") : "none";
|
|
1539
|
+
throw new Error(
|
|
1540
|
+
`Android device "${requested}" is not attached. Attached devices: ${attached}. Check \`adb devices -l\`.`
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
if (match.state !== "device") {
|
|
1544
|
+
throw new Error(
|
|
1545
|
+
`Android device "${requested}" is present but not ready (state: ${match.state}). Boot or authorize it, then retry.`
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
return requested;
|
|
1549
|
+
}
|
|
1550
|
+
if (booted.length === 0) {
|
|
1551
|
+
throw new Error(
|
|
1552
|
+
"No booted Android device found. Start an emulator or connect a device with USB debugging, then confirm it appears in `adb devices -l`."
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
if (booted.length > 1) {
|
|
1556
|
+
throw new Error(
|
|
1557
|
+
`Multiple Android devices attached (${booted.map((d) => d.serial).join(", ")}). Set target.deviceSerial to pick one.`
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
return booted[0].serial;
|
|
1561
|
+
}
|
|
1562
|
+
async function listDevices(runner) {
|
|
1563
|
+
const result = await runner(["devices", "-l"], { timeoutMs: 1e4 });
|
|
1564
|
+
if (result.code !== 0) {
|
|
1565
|
+
throw new Error(
|
|
1566
|
+
`\`adb devices\` failed (exit ${result.code}). Is adb on PATH and the server running? ` + (result.stderr.trim() || "").slice(0, 400)
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
return parseAdbDevices(result.stdout);
|
|
1570
|
+
}
|
|
1571
|
+
function parseForwardPort(stdout) {
|
|
1572
|
+
const port = Number.parseInt(stdout.trim(), 10);
|
|
1573
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
1574
|
+
throw new Error(`Could not parse a forwarded port from adb output: "${stdout.trim()}"`);
|
|
1575
|
+
}
|
|
1576
|
+
return port;
|
|
1577
|
+
}
|
|
1578
|
+
function parseAaptPackage(stdout) {
|
|
1579
|
+
const match = /package:\s*name='([^']+)'/.exec(stdout);
|
|
1580
|
+
return match?.[1] ?? null;
|
|
1581
|
+
}
|
|
1582
|
+
async function forwardDynamicPort(runner, serial, remotePort) {
|
|
1583
|
+
const result = await runner(withSerial(serial, ["forward", "tcp:0", `tcp:${remotePort}`]), {
|
|
1584
|
+
timeoutMs: 1e4
|
|
1585
|
+
});
|
|
1586
|
+
if (result.code !== 0) {
|
|
1587
|
+
throw new Error(`adb forward failed (exit ${result.code}): ${result.stderr.trim()}`);
|
|
1588
|
+
}
|
|
1589
|
+
return parseForwardPort(result.stdout);
|
|
1590
|
+
}
|
|
1591
|
+
async function removeForward(runner, serial, localPort) {
|
|
1592
|
+
await runner(withSerial(serial, ["forward", "--remove", `tcp:${localPort}`]), { timeoutMs: 1e4 }).catch(
|
|
1593
|
+
() => void 0
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
async function installApk(runner, serial, apkPath) {
|
|
1597
|
+
const result = await runner(withSerial(serial, ["install", "-r", "-t", "-g", apkPath]), {
|
|
1598
|
+
timeoutMs: 12e4
|
|
1599
|
+
});
|
|
1600
|
+
if (result.code !== 0 || /failure/i.test(result.stdout)) {
|
|
1601
|
+
throw new Error(
|
|
1602
|
+
`Failed to install APK "${apkPath}" (exit ${result.code}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
function formatUnknownError(error) {
|
|
1607
|
+
return error instanceof Error ? error.message : String(error);
|
|
1608
|
+
}
|
|
1609
|
+
function runnerPhaseError(message, error) {
|
|
1610
|
+
return new Error(`${message}: ${formatUnknownError(error)}`, { cause: error });
|
|
1611
|
+
}
|
|
1612
|
+
function parseResolvedComponent(stdout, pkg) {
|
|
1613
|
+
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
1614
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1615
|
+
if (/^[\w.]+\/[\w.$]+$/.test(lines[i]) && lines[i].startsWith(`${pkg}/`)) {
|
|
1616
|
+
return lines[i];
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
return null;
|
|
1620
|
+
}
|
|
1621
|
+
async function launchPackage(runner, serial, pkg) {
|
|
1622
|
+
let resolved;
|
|
1623
|
+
try {
|
|
1624
|
+
resolved = await runner(
|
|
1625
|
+
withSerial(serial, [
|
|
1626
|
+
"shell",
|
|
1627
|
+
"cmd",
|
|
1628
|
+
"package",
|
|
1629
|
+
"resolve-activity",
|
|
1630
|
+
"--brief",
|
|
1631
|
+
"-c",
|
|
1632
|
+
"android.intent.category.LAUNCHER",
|
|
1633
|
+
pkg
|
|
1634
|
+
]),
|
|
1635
|
+
{ timeoutMs: 3e4 }
|
|
1636
|
+
);
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
throw runnerPhaseError(
|
|
1639
|
+
`Failed to resolve the launcher activity for Android package "${pkg}" via adb`,
|
|
1640
|
+
error
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
const component = parseResolvedComponent(resolved.stdout, pkg);
|
|
1644
|
+
if (resolved.code !== 0 || !component) {
|
|
1645
|
+
throw new Error(
|
|
1646
|
+
`Could not resolve a launcher activity for Android package "${pkg}" (exit ${resolved.code})${(resolved.stdout + resolved.stderr).trim() ? `: ${(resolved.stdout + resolved.stderr).trim().slice(0, 300)}` : ""}. Is it installed?`
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
let start;
|
|
1650
|
+
try {
|
|
1651
|
+
start = await runner(withSerial(serial, ["shell", "am", "start", "-n", component]), {
|
|
1652
|
+
timeoutMs: 3e4
|
|
1653
|
+
});
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
throw runnerPhaseError(
|
|
1656
|
+
`Failed to launch Android package "${pkg}" with \`am start\` (${component}) via adb`,
|
|
1657
|
+
error
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
if (start.code !== 0 || /error|does not exist|cannot start/i.test(start.stdout + start.stderr)) {
|
|
1661
|
+
throw new Error(
|
|
1662
|
+
`Failed to launch Android package "${pkg}" (${component}, exit ${start.code}): ${(start.stdout + start.stderr).trim().slice(0, 400)}.`
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
async function forceStop(runner, serial, pkg) {
|
|
1667
|
+
await runner(withSerial(serial, ["shell", "am", "force-stop", pkg]), { timeoutMs: 3e4 });
|
|
1668
|
+
}
|
|
1669
|
+
async function clearPackage(runner, serial, pkg) {
|
|
1670
|
+
const result = await runner(withSerial(serial, ["shell", "pm", "clear", pkg]), { timeoutMs: 3e4 });
|
|
1671
|
+
if (result.code !== 0 || !/success/i.test(result.stdout)) {
|
|
1672
|
+
throw new Error(
|
|
1673
|
+
`Failed to clear Android package "${pkg}" for cold start (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 300)}`
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
function startInstrumentation(spawner, serial) {
|
|
1678
|
+
return spawner(
|
|
1679
|
+
withSerial(serial, [
|
|
1680
|
+
"shell",
|
|
1681
|
+
"am",
|
|
1682
|
+
"instrument",
|
|
1683
|
+
"-w",
|
|
1684
|
+
"-e",
|
|
1685
|
+
"disableAnalytics",
|
|
1686
|
+
"true",
|
|
1687
|
+
"io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner"
|
|
1688
|
+
])
|
|
1689
|
+
);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// src/browser/android-agent.ts
|
|
1693
|
+
var DEFAULT_AGENT_REQUEST_TIMEOUT_MS = 3e4;
|
|
1694
|
+
var W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
|
|
1695
|
+
var Uia2HttpError = class extends Error {
|
|
1696
|
+
constructor(message, status, webdriverError) {
|
|
1697
|
+
super(message);
|
|
1698
|
+
this.status = status;
|
|
1699
|
+
this.webdriverError = webdriverError;
|
|
1700
|
+
this.name = "Uia2HttpError";
|
|
1701
|
+
}
|
|
1702
|
+
};
|
|
1703
|
+
var Uia2Transport = class {
|
|
1704
|
+
baseUrl;
|
|
1705
|
+
requestTimeoutMs;
|
|
1706
|
+
fetchImpl;
|
|
1707
|
+
constructor(options) {
|
|
1708
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
1709
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_AGENT_REQUEST_TIMEOUT_MS;
|
|
1710
|
+
const injected = options.fetchImpl;
|
|
1711
|
+
if (injected) {
|
|
1712
|
+
this.fetchImpl = injected;
|
|
1713
|
+
} else if (typeof fetch === "function") {
|
|
1714
|
+
this.fetchImpl = (url, init) => fetch(url, init);
|
|
1715
|
+
} else {
|
|
1716
|
+
throw new Error("global fetch is unavailable; Node 20+ is required for the Android target");
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Send one request and return the parsed `value` field. Rejects with a
|
|
1721
|
+
* {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
|
|
1722
|
+
* per-request deadline elapses.
|
|
1723
|
+
*/
|
|
1724
|
+
async request(method, path16, body, timeoutMs) {
|
|
1725
|
+
const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
|
|
1726
|
+
const controller = new AbortController();
|
|
1727
|
+
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
1728
|
+
timer.unref?.();
|
|
1729
|
+
const url = `${this.baseUrl}${path16}`;
|
|
1730
|
+
let response;
|
|
1731
|
+
try {
|
|
1732
|
+
response = await this.fetchImpl(url, {
|
|
1733
|
+
method,
|
|
1734
|
+
signal: controller.signal,
|
|
1735
|
+
headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
|
|
1736
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
1737
|
+
});
|
|
1738
|
+
} catch (error) {
|
|
1739
|
+
if (controller.signal.aborted) {
|
|
1740
|
+
const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
|
|
1741
|
+
throw new Error(`uiautomator2 request ${method} ${path16} timed out after ${shown}`);
|
|
1742
|
+
}
|
|
1743
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
1744
|
+
} finally {
|
|
1745
|
+
clearTimeout(timer);
|
|
1746
|
+
}
|
|
1747
|
+
const text = await response.text();
|
|
1748
|
+
const parsed = parseJson(text);
|
|
1749
|
+
if (!response.ok) {
|
|
1750
|
+
const wdError = extractWebdriverError(parsed);
|
|
1751
|
+
throw new Uia2HttpError(
|
|
1752
|
+
`uiautomator2 ${method} ${path16} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
|
|
1753
|
+
response.status,
|
|
1754
|
+
wdError
|
|
1755
|
+
);
|
|
1756
|
+
}
|
|
1757
|
+
return parsed?.value;
|
|
1758
|
+
}
|
|
1759
|
+
};
|
|
1760
|
+
function parseJson(text) {
|
|
1761
|
+
if (!text) {
|
|
1762
|
+
return void 0;
|
|
1763
|
+
}
|
|
1764
|
+
try {
|
|
1765
|
+
return JSON.parse(text);
|
|
1766
|
+
} catch {
|
|
1767
|
+
return void 0;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
function extractWebdriverError(parsed) {
|
|
1771
|
+
const value = parsed?.value;
|
|
1772
|
+
if (value && typeof value === "object") {
|
|
1773
|
+
const record = value;
|
|
1774
|
+
const error = typeof record.error === "string" ? record.error : void 0;
|
|
1775
|
+
const message = typeof record.message === "string" ? record.message : void 0;
|
|
1776
|
+
return error ?? message;
|
|
1777
|
+
}
|
|
1778
|
+
return void 0;
|
|
1779
|
+
}
|
|
1780
|
+
function extractElementId(value) {
|
|
1781
|
+
if (!value || typeof value !== "object") {
|
|
1782
|
+
return null;
|
|
1783
|
+
}
|
|
1784
|
+
const record = value;
|
|
1785
|
+
const id = record[W3C_ELEMENT_KEY] ?? record.ELEMENT;
|
|
1786
|
+
return typeof id === "string" ? id : null;
|
|
1787
|
+
}
|
|
1788
|
+
function isNoSuchElement(error) {
|
|
1789
|
+
if (error instanceof Uia2HttpError) {
|
|
1790
|
+
return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
|
|
1791
|
+
}
|
|
1792
|
+
return false;
|
|
1793
|
+
}
|
|
1794
|
+
async function createUia2Session(transport) {
|
|
1795
|
+
const value = await transport.request("POST", "/session", {
|
|
1796
|
+
capabilities: { alwaysMatch: {}, firstMatch: [{}] }
|
|
1797
|
+
});
|
|
1798
|
+
const record = value ?? {};
|
|
1799
|
+
const sessionId = record.sessionId;
|
|
1800
|
+
if (typeof sessionId === "string" && sessionId.length > 0) {
|
|
1801
|
+
return sessionId;
|
|
1802
|
+
}
|
|
1803
|
+
throw new Error("uiautomator2 did not return a session id");
|
|
1804
|
+
}
|
|
1805
|
+
async function waitForAgentReady(transport, options = { deadlineMs: 3e4 }) {
|
|
1806
|
+
const interval = options.intervalMs ?? 300;
|
|
1807
|
+
const deadline = Date.now() + options.deadlineMs;
|
|
1808
|
+
let lastError;
|
|
1809
|
+
for (; ; ) {
|
|
1810
|
+
try {
|
|
1811
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
1812
|
+
const value = await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
|
|
1813
|
+
const ready = value?.ready;
|
|
1814
|
+
if (ready === void 0 || ready === true) {
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
} catch (error) {
|
|
1818
|
+
lastError = error;
|
|
1819
|
+
}
|
|
1820
|
+
if (Date.now() >= deadline) {
|
|
1821
|
+
const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
|
|
1822
|
+
throw new Error(`uiautomator2 agent did not become ready within ${options.deadlineMs}ms${detail}`);
|
|
1823
|
+
}
|
|
1824
|
+
await sleep(Math.min(interval, Math.max(0, deadline - Date.now())));
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
function sleep(ms) {
|
|
1828
|
+
return new Promise((resolve) => {
|
|
1829
|
+
const timer = setTimeout(resolve, ms);
|
|
1830
|
+
timer.unref?.();
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
function createUia2AgentClient(transport, sessionId, options = {}) {
|
|
1834
|
+
const base = `/session/${sessionId}`;
|
|
1835
|
+
async function locate(query, path16) {
|
|
1836
|
+
return transport.request(
|
|
1837
|
+
"POST",
|
|
1838
|
+
`${base}${path16}`,
|
|
1839
|
+
androidQueryToLocator(query, { appPackage: options.appPackage })
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
return {
|
|
1843
|
+
async findElement(query) {
|
|
1844
|
+
try {
|
|
1845
|
+
return extractElementId(await locate(query, "/element"));
|
|
1846
|
+
} catch (error) {
|
|
1847
|
+
if (isNoSuchElement(error)) {
|
|
1848
|
+
return null;
|
|
1849
|
+
}
|
|
1850
|
+
throw error;
|
|
1851
|
+
}
|
|
1852
|
+
},
|
|
1853
|
+
async findElements(query) {
|
|
1854
|
+
const value = await locate(query, "/elements");
|
|
1855
|
+
if (!Array.isArray(value)) {
|
|
1856
|
+
return [];
|
|
1857
|
+
}
|
|
1858
|
+
return value.map((entry) => extractElementId(entry)).filter((id) => id !== null);
|
|
1859
|
+
},
|
|
1860
|
+
async click(elementId) {
|
|
1861
|
+
await transport.request("POST", `${base}/element/${elementId}/click`, {});
|
|
1862
|
+
},
|
|
1863
|
+
async setValue(elementId, text) {
|
|
1864
|
+
await transport.request("POST", `${base}/element/${elementId}/value`, { text });
|
|
1865
|
+
},
|
|
1866
|
+
async getText(elementId) {
|
|
1867
|
+
const value = await transport.request("GET", `${base}/element/${elementId}/text`);
|
|
1868
|
+
return typeof value === "string" ? value : value == null ? null : String(value);
|
|
1869
|
+
},
|
|
1870
|
+
async pressKeyCode(keyCode) {
|
|
1871
|
+
await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
|
|
1872
|
+
},
|
|
1873
|
+
async screenshotPng() {
|
|
1874
|
+
const value = await transport.request("GET", `${base}/screenshot`);
|
|
1875
|
+
if (typeof value !== "string") {
|
|
1876
|
+
throw new Error("uiautomator2 screenshot did not return base64 data");
|
|
1877
|
+
}
|
|
1878
|
+
return Buffer.from(value, "base64");
|
|
1879
|
+
},
|
|
1880
|
+
async source() {
|
|
1881
|
+
const value = await transport.request("GET", `${base}/source`);
|
|
1882
|
+
if (typeof value !== "string") {
|
|
1883
|
+
throw new Error("uiautomator2 /source did not return XML text; cannot analyze Android UI hierarchy");
|
|
1884
|
+
}
|
|
1885
|
+
return value;
|
|
1886
|
+
},
|
|
1887
|
+
async close() {
|
|
1888
|
+
await transport.request("DELETE", base).catch(() => void 0);
|
|
1889
|
+
}
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// src/browser/android-helper.ts
|
|
1894
|
+
import { createRequire } from "module";
|
|
1895
|
+
import { execFile as execFile2 } from "child_process";
|
|
1896
|
+
import fs4 from "fs";
|
|
1897
|
+
import path3 from "path";
|
|
1898
|
+
var UIA2_REMOTE_PORT = 6790;
|
|
1899
|
+
function resolveAgentApks(requireFn = createRequire(import.meta.url)) {
|
|
1900
|
+
let pkgJsonPath;
|
|
1901
|
+
try {
|
|
1902
|
+
pkgJsonPath = requireFn.resolve("appium-uiautomator2-server/package.json");
|
|
1903
|
+
} catch {
|
|
1904
|
+
throw new Error(
|
|
1905
|
+
"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"
|
|
1906
|
+
);
|
|
1907
|
+
}
|
|
1908
|
+
const pkgDir = path3.dirname(pkgJsonPath);
|
|
1909
|
+
const version = requireFn(pkgJsonPath).version;
|
|
1910
|
+
const serverApk = path3.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
|
|
1911
|
+
const testApk = path3.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
|
|
1912
|
+
for (const apk of [serverApk, testApk]) {
|
|
1913
|
+
if (!fs4.existsSync(apk)) {
|
|
1914
|
+
throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
return { serverApk, testApk };
|
|
1918
|
+
}
|
|
1919
|
+
function looksLikeApk(app) {
|
|
1920
|
+
return app.toLowerCase().endsWith(".apk");
|
|
1921
|
+
}
|
|
1922
|
+
var execFileAaptResolver = async (apkPath) => {
|
|
1923
|
+
for (const tool of ["aapt", "aapt2"]) {
|
|
1924
|
+
const output = await new Promise((resolve) => {
|
|
1925
|
+
execFile2(
|
|
1926
|
+
tool,
|
|
1927
|
+
["dump", "badging", apkPath],
|
|
1928
|
+
{ encoding: "utf-8", timeout: 2e4, maxBuffer: 8 * 1024 * 1024 },
|
|
1929
|
+
(error, stdout) => resolve(error ? null : stdout)
|
|
1930
|
+
);
|
|
1931
|
+
});
|
|
1932
|
+
const pkg = output ? parseAaptPackage(output) : null;
|
|
1933
|
+
if (pkg) {
|
|
1934
|
+
return pkg;
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
return null;
|
|
1938
|
+
};
|
|
1939
|
+
var defaultAgentConnector = async ({
|
|
1940
|
+
host,
|
|
1941
|
+
port,
|
|
1942
|
+
requestTimeoutMs,
|
|
1943
|
+
readyDeadlineMs,
|
|
1944
|
+
appPackage
|
|
1945
|
+
}) => {
|
|
1946
|
+
const transport = new Uia2Transport({
|
|
1947
|
+
baseUrl: `http://${host}:${port}/wd/hub`,
|
|
1948
|
+
requestTimeoutMs
|
|
1949
|
+
});
|
|
1950
|
+
await waitForAgentReady(transport, { deadlineMs: readyDeadlineMs });
|
|
1951
|
+
const sessionId = await createUia2Session(transport);
|
|
1952
|
+
return createUia2AgentClient(transport, sessionId, { appPackage });
|
|
1953
|
+
};
|
|
1954
|
+
async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
|
|
1955
|
+
if (!looksLikeApk(app)) {
|
|
1956
|
+
assertAndroidAppAllowed(allowedApps, app);
|
|
1957
|
+
return app;
|
|
1958
|
+
}
|
|
1959
|
+
const apkPath = path3.resolve(app);
|
|
1960
|
+
if (!fs4.existsSync(apkPath)) {
|
|
1961
|
+
throw new Error(`APK not found: ${apkPath}`);
|
|
1962
|
+
}
|
|
1963
|
+
const pkg = await aaptResolver(apkPath);
|
|
1964
|
+
if (!pkg) {
|
|
1965
|
+
throw new Error(
|
|
1966
|
+
`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.`
|
|
1967
|
+
);
|
|
1968
|
+
}
|
|
1969
|
+
assertAndroidAppAllowed(allowedApps, apkPath, pkg);
|
|
1970
|
+
await installApk(runner, serial, apkPath);
|
|
1971
|
+
return pkg;
|
|
1972
|
+
}
|
|
1973
|
+
async function launchAndroidSession(options) {
|
|
1974
|
+
const runner = options.runner ?? execFileAdbRunner;
|
|
1975
|
+
const spawner = options.spawner ?? spawnAdbProcess;
|
|
1976
|
+
const connector = options.agentConnector ?? defaultAgentConnector;
|
|
1977
|
+
const aaptResolver = options.aaptResolver ?? execFileAaptResolver;
|
|
1978
|
+
const apks = options.apks ?? resolveAgentApks();
|
|
1979
|
+
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS) + 5e3;
|
|
1980
|
+
const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS);
|
|
1981
|
+
const devices = await listDevices(runner);
|
|
1982
|
+
const serial = selectDeviceSerial(devices, options.deviceSerial);
|
|
1983
|
+
const pkg = await resolvePackage(options.app, runner, serial, aaptResolver, options.allowedApps ?? []);
|
|
1984
|
+
await installApk(runner, serial, apks.serverApk);
|
|
1985
|
+
await installApk(runner, serial, apks.testApk);
|
|
1986
|
+
if (options.coldStart) {
|
|
1987
|
+
await clearPackage(runner, serial, pkg);
|
|
1988
|
+
}
|
|
1989
|
+
await launchPackage(runner, serial, pkg);
|
|
1990
|
+
let instrumentation;
|
|
1991
|
+
let localPort;
|
|
1992
|
+
let client;
|
|
1993
|
+
let tornDown = false;
|
|
1994
|
+
const teardown = async () => {
|
|
1995
|
+
if (tornDown) {
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
tornDown = true;
|
|
1999
|
+
if (client) {
|
|
2000
|
+
await client.close().catch(() => void 0);
|
|
2001
|
+
}
|
|
2002
|
+
instrumentation?.kill();
|
|
2003
|
+
const forwardedPort = localPort;
|
|
2004
|
+
localPort = void 0;
|
|
2005
|
+
if (forwardedPort !== void 0) {
|
|
2006
|
+
await removeForward(runner, serial, forwardedPort);
|
|
2007
|
+
}
|
|
2008
|
+
await forceStop(runner, serial, pkg).catch(() => void 0);
|
|
2009
|
+
};
|
|
2010
|
+
try {
|
|
2011
|
+
instrumentation = startInstrumentation(spawner, serial);
|
|
2012
|
+
localPort = await forwardDynamicPort(runner, serial, UIA2_REMOTE_PORT);
|
|
2013
|
+
client = await connector({
|
|
2014
|
+
host: "127.0.0.1",
|
|
2015
|
+
port: localPort,
|
|
2016
|
+
requestTimeoutMs,
|
|
2017
|
+
readyDeadlineMs,
|
|
2018
|
+
appPackage: pkg
|
|
2019
|
+
});
|
|
2020
|
+
const driver = createAndroidDriver(client, { appLabel: pkg });
|
|
2021
|
+
return { client, driver, package: pkg, serial, teardown };
|
|
2022
|
+
} catch (error) {
|
|
2023
|
+
await teardown();
|
|
2024
|
+
throw error;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
async function closeAndroidSession(session) {
|
|
2028
|
+
await session.teardown();
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// src/browser/ios-driver.ts
|
|
2032
|
+
var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
2033
|
+
"query",
|
|
2034
|
+
"interact",
|
|
2035
|
+
"wait",
|
|
2036
|
+
"screenshot"
|
|
2037
|
+
]);
|
|
2038
|
+
var WAIT_POLL_INTERVAL_MS2 = 250;
|
|
2039
|
+
var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
|
|
2040
|
+
var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
|
|
2041
|
+
function escapePredicateArg(value) {
|
|
2042
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
2043
|
+
}
|
|
2044
|
+
function toIosQuery(selector) {
|
|
2045
|
+
switch (selector.kind) {
|
|
2046
|
+
case "focused":
|
|
2047
|
+
return { by: "focused" };
|
|
2048
|
+
case "id":
|
|
2049
|
+
return { by: "accessibilityId", value: selector.value };
|
|
2050
|
+
case "role":
|
|
2051
|
+
return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
|
|
2052
|
+
case "label":
|
|
2053
|
+
return { by: "label", value: selector.value };
|
|
2054
|
+
case "text":
|
|
2055
|
+
return { by: "text", value: selector.value };
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
function parseIosSelector(selector) {
|
|
2059
|
+
return toIosQuery(parseNativeSelector(selector));
|
|
2060
|
+
}
|
|
2061
|
+
function iosQueryToLocator(query) {
|
|
2062
|
+
switch (query.by) {
|
|
2063
|
+
case "accessibilityId":
|
|
2064
|
+
return { using: "accessibility id", value: query.value };
|
|
2065
|
+
case "label":
|
|
2066
|
+
return { using: "predicate string", value: `label == "${escapePredicateArg(query.value)}"` };
|
|
2067
|
+
case "text": {
|
|
2068
|
+
const escaped = escapePredicateArg(query.value);
|
|
2069
|
+
return {
|
|
2070
|
+
using: "predicate string",
|
|
2071
|
+
value: `label CONTAINS "${escaped}" OR value CONTAINS "${escaped}"`
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
case "focused":
|
|
2075
|
+
return { using: "predicate string", value: "hasKeyboardFocus == 1" };
|
|
2076
|
+
case "role": {
|
|
2077
|
+
const className = normalizeXcuiClassName(query.role);
|
|
2078
|
+
if (query.name === void 0 || query.name.length === 0) {
|
|
2079
|
+
return { using: "class name", value: className };
|
|
2080
|
+
}
|
|
2081
|
+
const escapedClass = escapePredicateArg(className);
|
|
2082
|
+
const escapedName = escapePredicateArg(query.name);
|
|
2083
|
+
return {
|
|
2084
|
+
using: "predicate string",
|
|
2085
|
+
value: `type == "${escapedClass}" AND (label CONTAINS "${escapedName}" OR value CONTAINS "${escapedName}")`
|
|
2086
|
+
};
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
function unwrapIosTextSelector(selector) {
|
|
2091
|
+
return unwrapNativeTextSelector(selector);
|
|
2092
|
+
}
|
|
2093
|
+
function delay2(ms) {
|
|
2094
|
+
return new Promise((resolve) => {
|
|
2095
|
+
setTimeout(resolve, ms);
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
function createIosDriver(client, options) {
|
|
2099
|
+
const unsupported = (verb) => new Error(`${verb} is not supported by the iOS target`);
|
|
2100
|
+
const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
|
|
2101
|
+
async function resolveOne(selector) {
|
|
2102
|
+
const id = await client.findElement(parseIosSelector(selector));
|
|
2103
|
+
if (id === null) {
|
|
2104
|
+
throw new Error(`No element matched selector: ${selector}`);
|
|
2105
|
+
}
|
|
2106
|
+
return id;
|
|
2107
|
+
}
|
|
2108
|
+
async function clickSelector(selector) {
|
|
2109
|
+
await client.click(await resolveOne(selector));
|
|
2110
|
+
}
|
|
2111
|
+
async function fillSelector(selector, value) {
|
|
2112
|
+
await client.setValue(await resolveOne(selector), value);
|
|
2113
|
+
}
|
|
2114
|
+
async function pressKey(key) {
|
|
2115
|
+
const name = key.trim().toLowerCase();
|
|
2116
|
+
if (name === "enter" || name === "return") {
|
|
2117
|
+
await client.sendKeys(["\n"]);
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
if (name === "delete" || name === "backspace" || name === "del") {
|
|
2121
|
+
await client.sendKeys(["\b"]);
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
if (name === "home") {
|
|
2125
|
+
await client.homescreen();
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
throw new Error(
|
|
2129
|
+
`Unsupported key "${key}" for the iOS target. Supported keys: ${IOS_PRESS_KEYS.join(", ")}.`
|
|
2130
|
+
);
|
|
2131
|
+
}
|
|
2132
|
+
return {
|
|
2133
|
+
capabilities: IOS_CAPABILITIES,
|
|
2134
|
+
// navigation -----------------------------------------------------------
|
|
2135
|
+
goto(_url, _options) {
|
|
2136
|
+
return rejectUnsupported("navigate");
|
|
2137
|
+
},
|
|
2138
|
+
currentUrl() {
|
|
2139
|
+
return `ios:${options.appLabel ?? ""}`;
|
|
2140
|
+
},
|
|
2141
|
+
// queries --------------------------------------------------------------
|
|
2142
|
+
async count(selector) {
|
|
2143
|
+
return (await client.findElements(parseIosSelector(selector))).length;
|
|
2144
|
+
},
|
|
2145
|
+
async textContent(selector) {
|
|
2146
|
+
const id = await client.findElement(parseIosSelector(selector));
|
|
2147
|
+
if (id === null) {
|
|
2148
|
+
return null;
|
|
2149
|
+
}
|
|
2150
|
+
return client.getText(id);
|
|
2151
|
+
},
|
|
2152
|
+
// interactions ---------------------------------------------------------
|
|
2153
|
+
click: clickSelector,
|
|
2154
|
+
clickFirst: clickSelector,
|
|
2155
|
+
fill: fillSelector,
|
|
2156
|
+
fillFirst: fillSelector,
|
|
2157
|
+
async press(_selector, key) {
|
|
2158
|
+
await pressKey(key);
|
|
2159
|
+
},
|
|
2160
|
+
selectOption() {
|
|
2161
|
+
return rejectUnsupported("select");
|
|
2162
|
+
},
|
|
2163
|
+
selectOptionFirst() {
|
|
2164
|
+
return rejectUnsupported("select");
|
|
2165
|
+
},
|
|
2166
|
+
hover() {
|
|
2167
|
+
return rejectUnsupported("hover");
|
|
2168
|
+
},
|
|
2169
|
+
scrollIntoView() {
|
|
2170
|
+
return rejectUnsupported("scrollTo");
|
|
2171
|
+
},
|
|
2172
|
+
setInputFiles() {
|
|
2173
|
+
return rejectUnsupported("setInputFiles");
|
|
2174
|
+
},
|
|
2175
|
+
// semantic locators ----------------------------------------------------
|
|
2176
|
+
async countByRole(role, name) {
|
|
2177
|
+
return (await client.findElements({ by: "role", role, name })).length;
|
|
2178
|
+
},
|
|
2179
|
+
async clickFirstByRole(role, name) {
|
|
2180
|
+
const id = await client.findElement({ by: "role", role, name });
|
|
2181
|
+
if (id === null) {
|
|
2182
|
+
throw new Error(`No element matched role=${role}[name="${name}"]`);
|
|
2183
|
+
}
|
|
2184
|
+
await client.click(id);
|
|
2185
|
+
},
|
|
2186
|
+
async countByLabel(label) {
|
|
2187
|
+
return (await client.findElements({ by: "label", value: label })).length;
|
|
2188
|
+
},
|
|
2189
|
+
async fillFirstByLabel(label, value) {
|
|
2190
|
+
const id = await client.findElement({ by: "label", value: label });
|
|
2191
|
+
if (id === null) {
|
|
2192
|
+
throw new Error(`No element matched label="${label}"`);
|
|
2193
|
+
}
|
|
2194
|
+
await client.setValue(id, value);
|
|
2195
|
+
},
|
|
2196
|
+
selectOptionFirstByLabel() {
|
|
2197
|
+
return rejectUnsupported("select");
|
|
2198
|
+
},
|
|
2199
|
+
// waiting --------------------------------------------------------------
|
|
2200
|
+
async waitForSelector(selector, waitOptions) {
|
|
2201
|
+
const query = parseIosSelector(selector);
|
|
2202
|
+
const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
|
|
2203
|
+
const deadline = Date.now() + timeoutMs;
|
|
2204
|
+
for (; ; ) {
|
|
2205
|
+
if ((await client.findElements(query)).length > 0) {
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
if (Date.now() >= deadline) {
|
|
2209
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
|
|
2210
|
+
}
|
|
2211
|
+
await delay2(Math.min(WAIT_POLL_INTERVAL_MS2, Math.max(0, deadline - Date.now())));
|
|
2212
|
+
}
|
|
2213
|
+
},
|
|
2214
|
+
waitForUrl() {
|
|
2215
|
+
return rejectUnsupported("waitForUrl");
|
|
2216
|
+
},
|
|
2217
|
+
waitForNetworkIdle() {
|
|
2218
|
+
return rejectUnsupported("waitForNetworkIdle");
|
|
2219
|
+
},
|
|
2220
|
+
// scripting & artifacts ------------------------------------------------
|
|
2221
|
+
evaluate() {
|
|
2222
|
+
return rejectUnsupported("evalScript");
|
|
2223
|
+
},
|
|
2224
|
+
async screenshot(screenshotOptions) {
|
|
2225
|
+
await options.captureScreenshot(screenshotOptions.path);
|
|
2226
|
+
},
|
|
2227
|
+
// network / dialogs / downloads (all web-only) -------------------------
|
|
2228
|
+
onResponse(_handler) {
|
|
2229
|
+
throw unsupported("onResponse");
|
|
2230
|
+
},
|
|
2231
|
+
route(_url, _handler) {
|
|
2232
|
+
return rejectUnsupported("mockRoute");
|
|
2233
|
+
},
|
|
2234
|
+
unroute() {
|
|
2235
|
+
return rejectUnsupported("unmockRoute");
|
|
2236
|
+
},
|
|
2237
|
+
onDialog(_action) {
|
|
2238
|
+
throw unsupported("onDialog");
|
|
2239
|
+
},
|
|
2240
|
+
waitForDownloadEvent() {
|
|
2241
|
+
return rejectUnsupported("waitForDownload");
|
|
2242
|
+
},
|
|
2243
|
+
parseTextSelector(selector) {
|
|
2244
|
+
return unwrapIosTextSelector(selector);
|
|
2245
|
+
}
|
|
2246
|
+
};
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
// src/browser/ios-simctl.ts
|
|
2250
|
+
import { execFile as execFile3, spawn as spawn3 } from "child_process";
|
|
2251
|
+
import { mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
2252
|
+
import net from "net";
|
|
2253
|
+
import os from "os";
|
|
2254
|
+
import path4 from "path";
|
|
2255
|
+
var spawnXcrunProcess = (args, options) => {
|
|
2256
|
+
const child = spawn3("xcrun", args, {
|
|
2257
|
+
stdio: "ignore",
|
|
2258
|
+
env: options?.env ? { ...process.env, ...options.env } : process.env
|
|
2259
|
+
});
|
|
2260
|
+
child.on("error", () => {
|
|
2261
|
+
});
|
|
2262
|
+
return {
|
|
2263
|
+
kill: () => {
|
|
2264
|
+
child.kill();
|
|
2265
|
+
}
|
|
2266
|
+
};
|
|
2267
|
+
};
|
|
2268
|
+
var DEFAULT_SIMULATOR_LOCK_ROOT = path4.join(os.tmpdir(), "prowl-ios-simulator-locks");
|
|
2269
|
+
var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
|
|
2270
|
+
var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
|
|
2271
|
+
execFile3(
|
|
2272
|
+
"xcrun",
|
|
2273
|
+
args,
|
|
2274
|
+
{
|
|
2275
|
+
encoding: "utf-8",
|
|
2276
|
+
timeout: options?.timeoutMs,
|
|
2277
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
2278
|
+
env: options?.env ? { ...process.env, ...options.env } : process.env
|
|
2279
|
+
},
|
|
2280
|
+
(error, stdout, stderr) => {
|
|
2281
|
+
const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
|
|
2282
|
+
const capturedStderr = stderr ?? "";
|
|
2283
|
+
resolve({
|
|
2284
|
+
stdout: stdout ?? "",
|
|
2285
|
+
stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
|
|
2286
|
+
code
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
);
|
|
2290
|
+
});
|
|
2291
|
+
function simulatorLockName(udid) {
|
|
2292
|
+
const safe = udid.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
2293
|
+
return `${safe || "simulator"}.lock`;
|
|
2294
|
+
}
|
|
2295
|
+
function isErrno(error, code) {
|
|
2296
|
+
return error?.code === code;
|
|
2297
|
+
}
|
|
2298
|
+
function isProcessAlive(pid) {
|
|
2299
|
+
try {
|
|
2300
|
+
process.kill(pid, 0);
|
|
2301
|
+
return true;
|
|
2302
|
+
} catch (error) {
|
|
2303
|
+
return isErrno(error, "EPERM");
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
async function removeStaleSimulatorLock(lockPath) {
|
|
2307
|
+
try {
|
|
2308
|
+
const ownerText = await readFile(path4.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
|
|
2309
|
+
const owner = JSON.parse(ownerText);
|
|
2310
|
+
if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
|
|
2311
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
2312
|
+
return true;
|
|
2313
|
+
}
|
|
2314
|
+
} catch {
|
|
2315
|
+
return false;
|
|
2316
|
+
}
|
|
2317
|
+
return false;
|
|
2318
|
+
}
|
|
2319
|
+
function simulatorReservedError(udid) {
|
|
2320
|
+
return new Error(
|
|
2321
|
+
`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.`
|
|
2322
|
+
);
|
|
2323
|
+
}
|
|
2324
|
+
async function reserveSimulatorUdid(udid, options = {}) {
|
|
2325
|
+
const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
|
|
2326
|
+
const lockPath = path4.join(lockRoot, simulatorLockName(udid));
|
|
2327
|
+
await mkdir(lockRoot, { recursive: true });
|
|
2328
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
2329
|
+
try {
|
|
2330
|
+
await mkdir(lockPath);
|
|
2331
|
+
} catch (error) {
|
|
2332
|
+
if (!isErrno(error, "EEXIST")) {
|
|
2333
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
2334
|
+
}
|
|
2335
|
+
if (attempt === 0 && await removeStaleSimulatorLock(lockPath)) {
|
|
2336
|
+
continue;
|
|
2337
|
+
}
|
|
2338
|
+
throw simulatorReservedError(udid);
|
|
2339
|
+
}
|
|
2340
|
+
let released = false;
|
|
2341
|
+
const release = async () => {
|
|
2342
|
+
if (released) {
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
released = true;
|
|
2346
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
2347
|
+
};
|
|
2348
|
+
try {
|
|
2349
|
+
await writeFile(
|
|
2350
|
+
path4.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
|
|
2351
|
+
`${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
|
|
2352
|
+
`,
|
|
2353
|
+
{ flag: "wx" }
|
|
2354
|
+
);
|
|
2355
|
+
} catch (error) {
|
|
2356
|
+
await release().catch(() => void 0);
|
|
2357
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
2358
|
+
}
|
|
2359
|
+
return { udid, release };
|
|
2360
|
+
}
|
|
2361
|
+
throw simulatorReservedError(udid);
|
|
2362
|
+
}
|
|
2363
|
+
function findFreePort() {
|
|
2364
|
+
return new Promise((resolve, reject) => {
|
|
2365
|
+
const server = net.createServer();
|
|
2366
|
+
server.on("error", reject);
|
|
2367
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2368
|
+
const address = server.address();
|
|
2369
|
+
if (address && typeof address === "object") {
|
|
2370
|
+
const { port } = address;
|
|
2371
|
+
server.close(() => resolve(port));
|
|
2372
|
+
} else {
|
|
2373
|
+
server.close(() => reject(new Error("Could not allocate a local port")));
|
|
2374
|
+
}
|
|
2375
|
+
});
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
function parseSimctlDevices(json) {
|
|
2379
|
+
let parsed;
|
|
2380
|
+
try {
|
|
2381
|
+
parsed = JSON.parse(json);
|
|
2382
|
+
} catch {
|
|
2383
|
+
throw new Error("Could not parse `simctl list devices --json` output.");
|
|
2384
|
+
}
|
|
2385
|
+
const byRuntime = parsed?.devices;
|
|
2386
|
+
if (!byRuntime || typeof byRuntime !== "object") {
|
|
2387
|
+
return [];
|
|
2388
|
+
}
|
|
2389
|
+
const devices = [];
|
|
2390
|
+
for (const [runtime, entries] of Object.entries(byRuntime)) {
|
|
2391
|
+
if (!Array.isArray(entries)) {
|
|
2392
|
+
continue;
|
|
2393
|
+
}
|
|
2394
|
+
for (const entry of entries) {
|
|
2395
|
+
if (!entry || typeof entry !== "object") {
|
|
2396
|
+
continue;
|
|
2397
|
+
}
|
|
2398
|
+
const record = entry;
|
|
2399
|
+
const udid = typeof record.udid === "string" ? record.udid : void 0;
|
|
2400
|
+
const name = typeof record.name === "string" ? record.name : void 0;
|
|
2401
|
+
const state = typeof record.state === "string" ? record.state : "Unknown";
|
|
2402
|
+
if (!udid || !name) {
|
|
2403
|
+
continue;
|
|
2404
|
+
}
|
|
2405
|
+
devices.push({
|
|
2406
|
+
udid,
|
|
2407
|
+
name,
|
|
2408
|
+
state,
|
|
2409
|
+
runtime,
|
|
2410
|
+
isAvailable: record.isAvailable !== false
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
return devices;
|
|
2415
|
+
}
|
|
2416
|
+
function bootedSimulators(devices) {
|
|
2417
|
+
return devices.filter((device) => device.state === "Booted");
|
|
2418
|
+
}
|
|
2419
|
+
function describeSimulator(device) {
|
|
2420
|
+
const runtime = device.runtime.replace(/^com\.apple\.CoreSimulator\.SimRuntime\./, "");
|
|
2421
|
+
return `${device.name} [${runtime}] (${device.udid})`;
|
|
2422
|
+
}
|
|
2423
|
+
function selectSimulatorUdid(devices, requested) {
|
|
2424
|
+
const booted = bootedSimulators(devices);
|
|
2425
|
+
if (requested) {
|
|
2426
|
+
const match = devices.find((device) => device.udid === requested);
|
|
2427
|
+
if (!match) {
|
|
2428
|
+
const known = devices.length > 0 ? devices.map((d) => d.udid).join(", ") : "none";
|
|
2429
|
+
throw new Error(
|
|
2430
|
+
`iOS simulator "${requested}" was not found. Known simulators: ${known}. Check \`xcrun simctl list devices\`.`
|
|
2431
|
+
);
|
|
2432
|
+
}
|
|
2433
|
+
if (match.state !== "Booted") {
|
|
2434
|
+
throw new Error(
|
|
2435
|
+
`iOS simulator "${requested}" is not booted (state: ${match.state}). Boot it with \`xcrun simctl boot ${requested}\`, then retry.`
|
|
2436
|
+
);
|
|
2437
|
+
}
|
|
2438
|
+
return requested;
|
|
2439
|
+
}
|
|
2440
|
+
if (booted.length === 0) {
|
|
2441
|
+
throw new Error(
|
|
2442
|
+
"No booted iOS simulator found. Boot one from Xcode or with `xcrun simctl boot <udid>` (see `xcrun simctl list devices available`), then retry."
|
|
2443
|
+
);
|
|
2444
|
+
}
|
|
2445
|
+
if (booted.length > 1) {
|
|
2446
|
+
throw new Error(
|
|
2447
|
+
`Multiple iOS simulators are booted (${booted.map(describeSimulator).join("; ")}). Set target.udid to pick one.`
|
|
2448
|
+
);
|
|
2449
|
+
}
|
|
2450
|
+
return booted[0].udid;
|
|
2451
|
+
}
|
|
2452
|
+
async function listSimulators(runner) {
|
|
2453
|
+
const result = await runner(["simctl", "list", "devices", "--json"], { timeoutMs: 3e4 });
|
|
2454
|
+
if (result.code !== 0) {
|
|
2455
|
+
throw new Error(
|
|
2456
|
+
`\`xcrun simctl list devices\` failed. Is Xcode installed and are the command-line tools selected (\`xcode-select -p\`)? ${(result.stderr.trim() || "").slice(0, 400)}`
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
2459
|
+
return parseSimctlDevices(result.stdout);
|
|
2460
|
+
}
|
|
2461
|
+
async function installApp(runner, udid, appPath) {
|
|
2462
|
+
const result = await runner(["simctl", "install", udid, appPath], { timeoutMs: 12e4 });
|
|
2463
|
+
if (result.code !== 0) {
|
|
2464
|
+
throw new Error(
|
|
2465
|
+
`Failed to install "${appPath}" onto simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
|
|
2466
|
+
);
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
async function uninstallApp(runner, udid, bundleId) {
|
|
2470
|
+
await runner(["simctl", "uninstall", udid, bundleId], { timeoutMs: 6e4 }).catch(() => void 0);
|
|
2471
|
+
}
|
|
2472
|
+
async function launchApp(runner, udid, bundleId, childEnv = {}) {
|
|
2473
|
+
const env = {};
|
|
2474
|
+
for (const [key, value] of Object.entries(childEnv)) {
|
|
2475
|
+
env[`SIMCTL_CHILD_${key}`] = value;
|
|
2476
|
+
}
|
|
2477
|
+
const result = await runner(["simctl", "launch", udid, bundleId], {
|
|
2478
|
+
timeoutMs: 6e4,
|
|
2479
|
+
env: Object.keys(env).length > 0 ? env : void 0
|
|
2480
|
+
});
|
|
2481
|
+
if (result.code !== 0) {
|
|
2482
|
+
throw new Error(
|
|
2483
|
+
`Failed to launch "${bundleId}" on simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}. Is it installed?`
|
|
2484
|
+
);
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
async function terminateApp(runner, udid, bundleId) {
|
|
2488
|
+
await runner(["simctl", "terminate", udid, bundleId], { timeoutMs: 3e4 }).catch(() => void 0);
|
|
2489
|
+
}
|
|
2490
|
+
async function captureScreenshot(runner, udid, outPath) {
|
|
2491
|
+
const result = await runner(["simctl", "io", udid, "screenshot", outPath], { timeoutMs: 3e4 });
|
|
2492
|
+
if (result.code !== 0) {
|
|
2493
|
+
throw new Error(
|
|
2494
|
+
`Failed to capture a simulator screenshot (${udid}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 300)}`
|
|
2495
|
+
);
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
function parseXcodeVersion(stdout) {
|
|
2499
|
+
const match = /Xcode\s+([\d.]+)/i.exec(stdout);
|
|
2500
|
+
return match?.[1] ?? null;
|
|
2501
|
+
}
|
|
2502
|
+
async function xcodeVersion(runner) {
|
|
2503
|
+
const result = await runner(["xcodebuild", "-version"], { timeoutMs: 3e4 });
|
|
2504
|
+
if (result.code !== 0) {
|
|
2505
|
+
return null;
|
|
2506
|
+
}
|
|
2507
|
+
return parseXcodeVersion(result.stdout);
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
// src/browser/ios-agent.ts
|
|
2511
|
+
var DEFAULT_WDA_REQUEST_TIMEOUT_MS = 3e4;
|
|
2512
|
+
var W3C_ELEMENT_KEY2 = "element-6066-11e4-a52e-4f735466cecf";
|
|
2513
|
+
var WdaHttpError = class extends Error {
|
|
2514
|
+
constructor(message, status, webdriverError) {
|
|
2515
|
+
super(message);
|
|
2516
|
+
this.status = status;
|
|
2517
|
+
this.webdriverError = webdriverError;
|
|
2518
|
+
this.name = "WdaHttpError";
|
|
2519
|
+
}
|
|
2520
|
+
};
|
|
2521
|
+
var WdaTransport = class {
|
|
2522
|
+
baseUrl;
|
|
2523
|
+
requestTimeoutMs;
|
|
2524
|
+
fetchImpl;
|
|
2525
|
+
constructor(options) {
|
|
2526
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
2527
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_WDA_REQUEST_TIMEOUT_MS;
|
|
2528
|
+
const injected = options.fetchImpl;
|
|
2529
|
+
if (injected) {
|
|
2530
|
+
this.fetchImpl = injected;
|
|
2531
|
+
} else if (typeof fetch === "function") {
|
|
2532
|
+
this.fetchImpl = (url, init) => fetch(url, init);
|
|
2533
|
+
} else {
|
|
2534
|
+
throw new Error("global fetch is unavailable; Node 20+ is required for the iOS target");
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
/**
|
|
2538
|
+
* Send one request and return the full parsed JSON body. Rejects with a
|
|
2539
|
+
* {@link WdaHttpError} on a non-2xx response, or a timeout error when the
|
|
2540
|
+
* per-request deadline elapses.
|
|
2541
|
+
*/
|
|
2542
|
+
async requestFull(method, path16, body, timeoutMs) {
|
|
2543
|
+
const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
|
|
2544
|
+
const controller = new AbortController();
|
|
2545
|
+
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
2546
|
+
timer.unref?.();
|
|
2547
|
+
const url = `${this.baseUrl}${path16}`;
|
|
2548
|
+
const timeoutError = () => {
|
|
2549
|
+
const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
|
|
2550
|
+
return new Error(`WebDriverAgent request ${method} ${path16} timed out after ${shown}`);
|
|
2551
|
+
};
|
|
2552
|
+
let response;
|
|
2553
|
+
try {
|
|
2554
|
+
response = await this.fetchImpl(url, {
|
|
2555
|
+
method,
|
|
2556
|
+
signal: controller.signal,
|
|
2557
|
+
headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
|
|
2558
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
2559
|
+
});
|
|
2560
|
+
} catch (error) {
|
|
2561
|
+
clearTimeout(timer);
|
|
2562
|
+
if (controller.signal.aborted) {
|
|
2563
|
+
throw timeoutError();
|
|
2564
|
+
}
|
|
2565
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
2566
|
+
}
|
|
2567
|
+
let text;
|
|
2568
|
+
try {
|
|
2569
|
+
text = await response.text();
|
|
2570
|
+
} catch (error) {
|
|
2571
|
+
if (controller.signal.aborted) {
|
|
2572
|
+
throw timeoutError();
|
|
2573
|
+
}
|
|
2574
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
2575
|
+
} finally {
|
|
2576
|
+
clearTimeout(timer);
|
|
2577
|
+
}
|
|
2578
|
+
const parsed = parseJson2(text);
|
|
2579
|
+
if (!response.ok) {
|
|
2580
|
+
const wdError = extractWebdriverError2(parsed);
|
|
2581
|
+
throw new WdaHttpError(
|
|
2582
|
+
`WebDriverAgent ${method} ${path16} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
|
|
2583
|
+
response.status,
|
|
2584
|
+
wdError
|
|
2585
|
+
);
|
|
2586
|
+
}
|
|
2587
|
+
return parsed;
|
|
2588
|
+
}
|
|
2589
|
+
/** Like {@link requestFull} but returns just the `value` field. */
|
|
2590
|
+
async request(method, path16, body, timeoutMs) {
|
|
2591
|
+
const parsed = await this.requestFull(method, path16, body, timeoutMs);
|
|
2592
|
+
return parsed?.value;
|
|
2593
|
+
}
|
|
2594
|
+
};
|
|
2595
|
+
function parseJson2(text) {
|
|
2596
|
+
if (!text) {
|
|
2597
|
+
return void 0;
|
|
2598
|
+
}
|
|
2599
|
+
try {
|
|
2600
|
+
return JSON.parse(text);
|
|
2601
|
+
} catch {
|
|
2602
|
+
return void 0;
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
function extractWebdriverError2(parsed) {
|
|
2606
|
+
const value = parsed?.value;
|
|
2607
|
+
if (value && typeof value === "object") {
|
|
2608
|
+
const record = value;
|
|
2609
|
+
const error = typeof record.error === "string" ? record.error : void 0;
|
|
2610
|
+
const message = typeof record.message === "string" ? record.message : void 0;
|
|
2611
|
+
return error ?? message;
|
|
2612
|
+
}
|
|
2613
|
+
return void 0;
|
|
2614
|
+
}
|
|
2615
|
+
function extractElementId2(value) {
|
|
2616
|
+
if (!value || typeof value !== "object") {
|
|
2617
|
+
return null;
|
|
2618
|
+
}
|
|
2619
|
+
const record = value;
|
|
2620
|
+
const id = record[W3C_ELEMENT_KEY2] ?? record.ELEMENT;
|
|
2621
|
+
return typeof id === "string" ? id : null;
|
|
2622
|
+
}
|
|
2623
|
+
function isNoSuchElement2(error) {
|
|
2624
|
+
if (error instanceof WdaHttpError) {
|
|
2625
|
+
return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
|
|
2626
|
+
}
|
|
2627
|
+
return false;
|
|
2628
|
+
}
|
|
2629
|
+
async function createWdaSession(transport, bundleId) {
|
|
2630
|
+
const body = await transport.requestFull("POST", "/session", {
|
|
2631
|
+
capabilities: { alwaysMatch: { bundleId }, firstMatch: [{}] }
|
|
2632
|
+
});
|
|
2633
|
+
const envelope = body ?? {};
|
|
2634
|
+
const value = envelope.value ?? {};
|
|
2635
|
+
const sessionId = typeof value.sessionId === "string" && value.sessionId || typeof envelope.sessionId === "string" && envelope.sessionId || "";
|
|
2636
|
+
if (sessionId.length > 0) {
|
|
2637
|
+
return sessionId;
|
|
2638
|
+
}
|
|
2639
|
+
throw new Error("WebDriverAgent did not return a session id");
|
|
2640
|
+
}
|
|
2641
|
+
async function waitForWdaReady(transport, options = { deadlineMs: 6e4 }) {
|
|
2642
|
+
const interval = options.intervalMs ?? 300;
|
|
2643
|
+
const deadline = Date.now() + options.deadlineMs;
|
|
2644
|
+
let lastError;
|
|
2645
|
+
for (; ; ) {
|
|
2646
|
+
try {
|
|
2647
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
2648
|
+
await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
|
|
2649
|
+
return;
|
|
2650
|
+
} catch (error) {
|
|
2651
|
+
lastError = error;
|
|
2652
|
+
}
|
|
2653
|
+
if (Date.now() >= deadline) {
|
|
2654
|
+
const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
|
|
2655
|
+
throw new Error(`WebDriverAgent did not become ready within ${options.deadlineMs}ms${detail}`);
|
|
2656
|
+
}
|
|
2657
|
+
await sleep2(Math.min(interval, Math.max(0, deadline - Date.now())));
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
function sleep2(ms) {
|
|
2661
|
+
return new Promise((resolve) => {
|
|
2662
|
+
setTimeout(resolve, ms);
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
function createWdaAgentClient(transport, sessionId) {
|
|
2666
|
+
const base = `/session/${sessionId}`;
|
|
2667
|
+
async function locate(query, path16) {
|
|
2668
|
+
return transport.request("POST", `${base}${path16}`, iosQueryToLocator(query));
|
|
2669
|
+
}
|
|
2670
|
+
return {
|
|
2671
|
+
async findElement(query) {
|
|
2672
|
+
try {
|
|
2673
|
+
return extractElementId2(await locate(query, "/element"));
|
|
2674
|
+
} catch (error) {
|
|
2675
|
+
if (isNoSuchElement2(error)) {
|
|
2676
|
+
return null;
|
|
2677
|
+
}
|
|
2678
|
+
throw error;
|
|
2679
|
+
}
|
|
2680
|
+
},
|
|
2681
|
+
async findElements(query) {
|
|
2682
|
+
const value = await locate(query, "/elements");
|
|
2683
|
+
if (!Array.isArray(value)) {
|
|
2684
|
+
return [];
|
|
2685
|
+
}
|
|
2686
|
+
return value.map((entry) => extractElementId2(entry)).filter((id) => id !== null);
|
|
2687
|
+
},
|
|
2688
|
+
async click(elementId) {
|
|
2689
|
+
await transport.request("POST", `${base}/element/${elementId}/click`, {});
|
|
2690
|
+
},
|
|
2691
|
+
async setValue(elementId, text) {
|
|
2692
|
+
await transport.request("POST", `${base}/element/${elementId}/value`, { text });
|
|
2693
|
+
},
|
|
2694
|
+
async getText(elementId) {
|
|
2695
|
+
const value = await transport.request("GET", `${base}/element/${elementId}/text`);
|
|
2696
|
+
return typeof value === "string" ? value : value == null ? null : String(value);
|
|
2697
|
+
},
|
|
2698
|
+
async sendKeys(keys) {
|
|
2699
|
+
await transport.request("POST", `${base}/wda/keys`, { value: keys });
|
|
2700
|
+
},
|
|
2701
|
+
async homescreen() {
|
|
2702
|
+
await transport.request("POST", "/wda/homescreen", {});
|
|
2703
|
+
},
|
|
2704
|
+
async source() {
|
|
2705
|
+
const value = await transport.request("GET", "/source");
|
|
2706
|
+
if (typeof value !== "string") {
|
|
2707
|
+
throw new Error("WebDriverAgent /source did not return XML text; cannot analyze iOS UI hierarchy");
|
|
2708
|
+
}
|
|
2709
|
+
return value;
|
|
2710
|
+
},
|
|
2711
|
+
async close() {
|
|
2712
|
+
await transport.request("DELETE", base).catch(() => void 0);
|
|
2713
|
+
}
|
|
2714
|
+
};
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
// src/browser/ios-helper.ts
|
|
2718
|
+
import { createRequire as createRequire2 } from "module";
|
|
2719
|
+
import { execFile as execFile4 } from "child_process";
|
|
2720
|
+
import fs5 from "fs";
|
|
2721
|
+
import os2 from "os";
|
|
2722
|
+
import path5 from "path";
|
|
2723
|
+
var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
|
|
2724
|
+
var WDA_USE_PORT_ENV = "USE_PORT";
|
|
2725
|
+
var PREPARED_XCTESTRUN_PREFIX = "prowl-wda-xctestrun-";
|
|
2726
|
+
var WDA_STARTUP_ATTEMPTS = 2;
|
|
2727
|
+
var defaultIosAgentConnector = async ({
|
|
2728
|
+
host,
|
|
2729
|
+
port,
|
|
2730
|
+
bundleId,
|
|
2731
|
+
requestTimeoutMs,
|
|
2732
|
+
readyDeadlineMs
|
|
2733
|
+
}) => {
|
|
2734
|
+
const transport = new WdaTransport({ baseUrl: `http://${host}:${port}`, requestTimeoutMs });
|
|
2735
|
+
await waitForWdaReady(transport, { deadlineMs: readyDeadlineMs });
|
|
2736
|
+
const sessionId = await createWdaSession(transport, bundleId);
|
|
2737
|
+
return createWdaAgentClient(transport, sessionId);
|
|
2738
|
+
};
|
|
2739
|
+
function resolveWdaProject(requireFn = createRequire2(import.meta.url)) {
|
|
2740
|
+
let pkgJsonPath;
|
|
2741
|
+
try {
|
|
2742
|
+
pkgJsonPath = requireFn.resolve("appium-webdriveragent/package.json");
|
|
2743
|
+
} catch {
|
|
2744
|
+
throw new Error(
|
|
2745
|
+
"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"
|
|
2746
|
+
);
|
|
2747
|
+
}
|
|
2748
|
+
const pkgDir = path5.dirname(pkgJsonPath);
|
|
2749
|
+
const version = requireFn(pkgJsonPath).version;
|
|
2750
|
+
const projectPath = path5.join(pkgDir, "WebDriverAgent.xcodeproj");
|
|
2751
|
+
if (!fs5.existsSync(projectPath)) {
|
|
2752
|
+
throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
|
|
2753
|
+
}
|
|
2754
|
+
return { projectPath, version };
|
|
2755
|
+
}
|
|
2756
|
+
function wdaCacheDir(wdaVersion, xcode, homeDir = os2.homedir()) {
|
|
2757
|
+
return path5.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
|
|
2758
|
+
}
|
|
2759
|
+
function productsDir(derivedDataPath) {
|
|
2760
|
+
return path5.join(derivedDataPath, "Build", "Products");
|
|
2761
|
+
}
|
|
2762
|
+
function findXctestrunIn(dir) {
|
|
2763
|
+
let entries;
|
|
2764
|
+
try {
|
|
2765
|
+
entries = fs5.readdirSync(dir);
|
|
2766
|
+
} catch {
|
|
2767
|
+
return null;
|
|
2768
|
+
}
|
|
2769
|
+
const match = entries.filter((name) => name.endsWith(".xctestrun") && !name.startsWith(PREPARED_XCTESTRUN_PREFIX)).sort()[0];
|
|
2770
|
+
return match ? path5.join(dir, match) : null;
|
|
2771
|
+
}
|
|
2772
|
+
function resolveOverrideXctestrun(override) {
|
|
2773
|
+
if (!fs5.existsSync(override)) {
|
|
2774
|
+
throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
|
|
2775
|
+
}
|
|
2776
|
+
if (override.endsWith(".xctestrun")) {
|
|
2777
|
+
return override;
|
|
2778
|
+
}
|
|
2779
|
+
const candidates = [];
|
|
2780
|
+
const stat = fs5.statSync(override);
|
|
2781
|
+
if (override.endsWith(".app")) {
|
|
2782
|
+
candidates.push(path5.dirname(path5.dirname(override)));
|
|
2783
|
+
} else if (stat.isDirectory()) {
|
|
2784
|
+
candidates.push(override, productsDir(override));
|
|
2785
|
+
}
|
|
2786
|
+
for (const dir of candidates) {
|
|
2787
|
+
const found = findXctestrunIn(dir);
|
|
2788
|
+
if (found) {
|
|
2789
|
+
return found;
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
throw new Error(
|
|
2793
|
+
`PROWL_WDA_RUNNER (${override}) does not resolve to a WebDriverAgent .xctestrun. Point it at the generated \`*.xctestrun\`, at the \`-derivedDataPath\` directory from \`xcodebuild build-for-testing\`, or at that build's runner \`.app\`.`
|
|
2794
|
+
);
|
|
2795
|
+
}
|
|
2796
|
+
async function resolveWdaTestRun(options = {}) {
|
|
2797
|
+
const runner = options.runner ?? execFileXcrunRunner;
|
|
2798
|
+
const env = options.env ?? process.env;
|
|
2799
|
+
const homeDir = options.homeDir ?? os2.homedir();
|
|
2800
|
+
const log = options.logger ?? ((message) => process.stderr.write(`${message}
|
|
2801
|
+
`));
|
|
2802
|
+
const override = env.PROWL_WDA_RUNNER;
|
|
2803
|
+
if (override) {
|
|
2804
|
+
return resolveOverrideXctestrun(override);
|
|
2805
|
+
}
|
|
2806
|
+
const { projectPath, version } = resolveWdaProject(options.requireFn);
|
|
2807
|
+
const xcode = await xcodeVersion(runner);
|
|
2808
|
+
if (!xcode) {
|
|
2809
|
+
throw new Error(
|
|
2810
|
+
"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."
|
|
2811
|
+
);
|
|
2812
|
+
}
|
|
2813
|
+
const cacheDir = wdaCacheDir(version, xcode, homeDir);
|
|
2814
|
+
const cached = findXctestrunIn(productsDir(cacheDir));
|
|
2815
|
+
if (cached) {
|
|
2816
|
+
return cached;
|
|
2817
|
+
}
|
|
2818
|
+
log(
|
|
2819
|
+
`Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
|
|
2820
|
+
);
|
|
2821
|
+
fs5.mkdirSync(cacheDir, { recursive: true });
|
|
2822
|
+
const result = await runner(
|
|
2823
|
+
[
|
|
2824
|
+
"xcodebuild",
|
|
2825
|
+
"build-for-testing",
|
|
2826
|
+
"-project",
|
|
2827
|
+
projectPath,
|
|
2828
|
+
"-scheme",
|
|
2829
|
+
"WebDriverAgentRunner",
|
|
2830
|
+
"-destination",
|
|
2831
|
+
"generic/platform=iOS Simulator",
|
|
2832
|
+
"-derivedDataPath",
|
|
2833
|
+
cacheDir,
|
|
2834
|
+
"CODE_SIGNING_ALLOWED=NO"
|
|
2835
|
+
],
|
|
2836
|
+
{ timeoutMs: 12e5 }
|
|
2837
|
+
);
|
|
2838
|
+
if (result.code !== 0) {
|
|
2839
|
+
throw new Error(
|
|
2840
|
+
`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)}`
|
|
2841
|
+
);
|
|
2842
|
+
}
|
|
2843
|
+
const built = findXctestrunIn(productsDir(cacheDir));
|
|
2844
|
+
if (!built) {
|
|
2845
|
+
throw new Error(
|
|
2846
|
+
`WebDriverAgent build succeeded but no .xctestrun was found under ${productsDir(cacheDir)}. This may indicate an Xcode layout change; set PROWL_WDA_RUNNER to a prebuilt runner/xctestrun.`
|
|
2847
|
+
);
|
|
2848
|
+
}
|
|
2849
|
+
return built;
|
|
2850
|
+
}
|
|
2851
|
+
function injectUsePortIntoXctestrun(plist, port) {
|
|
2852
|
+
const clone = structuredClone(plist);
|
|
2853
|
+
let injected = 0;
|
|
2854
|
+
const visit = (node) => {
|
|
2855
|
+
if (Array.isArray(node)) {
|
|
2856
|
+
for (const entry of node) {
|
|
2857
|
+
visit(entry);
|
|
2858
|
+
}
|
|
2859
|
+
return;
|
|
2860
|
+
}
|
|
2861
|
+
if (!node || typeof node !== "object") {
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
const obj = node;
|
|
2865
|
+
const isTarget = typeof obj.TestBundlePath === "string" || typeof obj.TestHostPath === "string";
|
|
2866
|
+
const existingEnv = obj.EnvironmentVariables && typeof obj.EnvironmentVariables === "object" && !Array.isArray(obj.EnvironmentVariables) ? obj.EnvironmentVariables : void 0;
|
|
2867
|
+
if (isTarget || existingEnv) {
|
|
2868
|
+
const env = existingEnv ?? {};
|
|
2869
|
+
env[WDA_USE_PORT_ENV] = String(port);
|
|
2870
|
+
obj.EnvironmentVariables = env;
|
|
2871
|
+
injected += 1;
|
|
2872
|
+
}
|
|
2873
|
+
for (const value of Object.values(obj)) {
|
|
2874
|
+
visit(value);
|
|
2875
|
+
}
|
|
2876
|
+
};
|
|
2877
|
+
visit(clone);
|
|
2878
|
+
if (injected === 0) {
|
|
2879
|
+
throw new Error(
|
|
2880
|
+
"Could not find a test target in the WebDriverAgent .xctestrun to set USE_PORT. The xctestrun format may have changed; set PROWL_WDA_RUNNER to a compatible runner."
|
|
2881
|
+
);
|
|
2882
|
+
}
|
|
2883
|
+
return clone;
|
|
2884
|
+
}
|
|
2885
|
+
function runPlutil(args) {
|
|
2886
|
+
return new Promise((resolve, reject) => {
|
|
2887
|
+
execFile4(
|
|
2888
|
+
"plutil",
|
|
2889
|
+
args,
|
|
2890
|
+
{ encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 },
|
|
2891
|
+
(error, stdout, stderr) => {
|
|
2892
|
+
if (error) {
|
|
2893
|
+
reject(
|
|
2894
|
+
new Error(
|
|
2895
|
+
`\`plutil ${args.join(" ")}\` failed: ${(stderr || error.message).slice(0, 400)}`
|
|
2896
|
+
)
|
|
2897
|
+
);
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
resolve(stdout ?? "");
|
|
2901
|
+
}
|
|
2902
|
+
);
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2905
|
+
var defaultWdaTestRunPreparer = async ({ xctestrunPath, port }) => {
|
|
2906
|
+
const json = await runPlutil(["-convert", "json", "-o", "-", xctestrunPath]);
|
|
2907
|
+
let parsed;
|
|
2908
|
+
try {
|
|
2909
|
+
parsed = JSON.parse(json);
|
|
2910
|
+
} catch {
|
|
2911
|
+
throw new Error(`Could not parse the WebDriverAgent .xctestrun as JSON: ${xctestrunPath}`);
|
|
2912
|
+
}
|
|
2913
|
+
const injected = injectUsePortIntoXctestrun(parsed, port);
|
|
2914
|
+
const stem = `${PREPARED_XCTESTRUN_PREFIX}${port}-${process.pid}`;
|
|
2915
|
+
const outPath = path5.join(path5.dirname(xctestrunPath), `${stem}.xctestrun`);
|
|
2916
|
+
const jsonPath = path5.join(os2.tmpdir(), `${stem}.json`);
|
|
2917
|
+
fs5.writeFileSync(jsonPath, JSON.stringify(injected));
|
|
2918
|
+
try {
|
|
2919
|
+
await runPlutil(["-convert", "xml1", jsonPath, "-o", outPath]);
|
|
2920
|
+
} finally {
|
|
2921
|
+
fs5.rmSync(jsonPath, { force: true });
|
|
2922
|
+
}
|
|
2923
|
+
return outPath;
|
|
2924
|
+
};
|
|
2925
|
+
function cleanupPreparedTestRun(preparedPath) {
|
|
2926
|
+
if (!preparedPath) {
|
|
2927
|
+
return;
|
|
2928
|
+
}
|
|
2929
|
+
if (!path5.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
fs5.rmSync(preparedPath, { force: true });
|
|
2933
|
+
}
|
|
2934
|
+
function wdaTestRunArgs(xctestrunPath, udid) {
|
|
2935
|
+
return [
|
|
2936
|
+
"xcodebuild",
|
|
2937
|
+
"test-without-building",
|
|
2938
|
+
"-xctestrun",
|
|
2939
|
+
xctestrunPath,
|
|
2940
|
+
"-destination",
|
|
2941
|
+
`id=${udid}`
|
|
2942
|
+
];
|
|
2943
|
+
}
|
|
2944
|
+
async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
|
|
2945
|
+
if (!looksLikeIosAppPath(app)) {
|
|
2946
|
+
assertIosAppAllowed(allowedApps, app);
|
|
2947
|
+
if (coldStart) {
|
|
2948
|
+
throw new Error(
|
|
2949
|
+
`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.`
|
|
2950
|
+
);
|
|
2951
|
+
}
|
|
2952
|
+
return app;
|
|
2953
|
+
}
|
|
2954
|
+
const appPath = path5.resolve(app);
|
|
2955
|
+
if (!fs5.existsSync(appPath)) {
|
|
2956
|
+
throw new Error(`.app bundle not found: ${appPath}`);
|
|
2957
|
+
}
|
|
2958
|
+
const bundleId = readIosBundleIdentifier(appPath);
|
|
2959
|
+
if (!bundleId) {
|
|
2960
|
+
throw new Error(
|
|
2961
|
+
`Could not read CFBundleIdentifier from "${appPath}" (root Info.plist). Ensure target.app points at a built iOS .app bundle.`
|
|
2962
|
+
);
|
|
2963
|
+
}
|
|
2964
|
+
assertIosAppAllowed(allowedApps, appPath);
|
|
2965
|
+
if (coldStart) {
|
|
2966
|
+
await uninstallApp(runner, udid, bundleId);
|
|
2967
|
+
}
|
|
2968
|
+
await installApp(runner, udid, appPath);
|
|
2969
|
+
return bundleId;
|
|
2970
|
+
}
|
|
2971
|
+
async function launchIosSession(options) {
|
|
2972
|
+
const runner = options.runner ?? execFileXcrunRunner;
|
|
2973
|
+
const spawner = options.spawner ?? spawnXcrunProcess;
|
|
2974
|
+
const preparer = options.testRunPreparer ?? defaultWdaTestRunPreparer;
|
|
2975
|
+
const portAllocator = options.portAllocator ?? findFreePort;
|
|
2976
|
+
const connector = options.agentConnector ?? defaultIosAgentConnector;
|
|
2977
|
+
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
|
|
2978
|
+
const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, 6e4);
|
|
2979
|
+
const devices = await listSimulators(runner);
|
|
2980
|
+
const udid = selectSimulatorUdid(devices, options.udid);
|
|
2981
|
+
const reservation = await reserveSimulatorUdid(udid, { lockRoot: options.simulatorLockRoot });
|
|
2982
|
+
let reservationReleased = false;
|
|
2983
|
+
const releaseReservation = async () => {
|
|
2984
|
+
if (reservationReleased) {
|
|
2985
|
+
return;
|
|
2986
|
+
}
|
|
2987
|
+
reservationReleased = true;
|
|
2988
|
+
await reservation.release();
|
|
2989
|
+
};
|
|
2990
|
+
try {
|
|
2991
|
+
const bundleId = await resolveBundleId(
|
|
2992
|
+
options.app,
|
|
2993
|
+
runner,
|
|
2994
|
+
udid,
|
|
2995
|
+
options.coldStart ?? false,
|
|
2996
|
+
options.allowedApps ?? []
|
|
2997
|
+
);
|
|
2998
|
+
const baseTestRun = options.wdaTestRun ?? await resolveWdaTestRun({ runner, logger: options.logger });
|
|
2999
|
+
for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
|
|
3000
|
+
let client;
|
|
3001
|
+
let wdaProcess;
|
|
3002
|
+
let preparedTestRun;
|
|
3003
|
+
let tornDown = false;
|
|
3004
|
+
let terminateTarget = false;
|
|
3005
|
+
let stage = "prepare";
|
|
3006
|
+
const teardownAttempt = async () => {
|
|
3007
|
+
if (tornDown) {
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
3010
|
+
tornDown = true;
|
|
3011
|
+
if (client) {
|
|
3012
|
+
await client.close().catch(() => void 0);
|
|
3013
|
+
}
|
|
3014
|
+
wdaProcess?.kill();
|
|
3015
|
+
await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
|
|
3016
|
+
if (terminateTarget) {
|
|
3017
|
+
await terminateApp(runner, udid, bundleId);
|
|
3018
|
+
}
|
|
3019
|
+
cleanupPreparedTestRun(preparedTestRun);
|
|
3020
|
+
};
|
|
3021
|
+
try {
|
|
3022
|
+
const port = await portAllocator();
|
|
3023
|
+
preparedTestRun = await preparer({ xctestrunPath: baseTestRun, port });
|
|
3024
|
+
stage = "wda-launch";
|
|
3025
|
+
wdaProcess = spawner(wdaTestRunArgs(preparedTestRun, udid));
|
|
3026
|
+
stage = "target-launch";
|
|
3027
|
+
terminateTarget = true;
|
|
3028
|
+
await launchApp(runner, udid, bundleId);
|
|
3029
|
+
stage = "connect";
|
|
3030
|
+
client = await connector({ host: "127.0.0.1", port, bundleId, requestTimeoutMs, readyDeadlineMs });
|
|
3031
|
+
const driver = createIosDriver(client, {
|
|
3032
|
+
appLabel: bundleId,
|
|
3033
|
+
captureScreenshot: (outPath) => captureScreenshot(runner, udid, outPath)
|
|
3034
|
+
});
|
|
3035
|
+
const teardown = async () => {
|
|
3036
|
+
await teardownAttempt();
|
|
3037
|
+
await releaseReservation();
|
|
3038
|
+
};
|
|
3039
|
+
return { client, driver, bundleId, udid, teardown };
|
|
3040
|
+
} catch (error) {
|
|
3041
|
+
await teardownAttempt();
|
|
3042
|
+
if (stage === "target-launch" || attempt >= WDA_STARTUP_ATTEMPTS) {
|
|
3043
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
throw new Error("WebDriverAgent startup failed without an error.");
|
|
3048
|
+
} catch (error) {
|
|
3049
|
+
await releaseReservation().catch(() => void 0);
|
|
3050
|
+
throw error;
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
async function closeIosSession(session) {
|
|
3054
|
+
await session.teardown();
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
// src/runner/healing.ts
|
|
3058
|
+
var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
|
|
3059
|
+
function extractSelectorIntent(selector) {
|
|
3060
|
+
const raw = [];
|
|
3061
|
+
for (const match of selector.matchAll(/[#.]([A-Za-z_][\w-]*)/g)) {
|
|
3062
|
+
raw.push(match[1]);
|
|
3063
|
+
}
|
|
3064
|
+
for (const match of selector.matchAll(/\[[A-Za-z_:-]+\s*[~|^$*]?=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\]/g)) {
|
|
3065
|
+
const value = match[1] ?? match[2] ?? match[3];
|
|
3066
|
+
if (value) raw.push(value);
|
|
3067
|
+
}
|
|
3068
|
+
const words = [];
|
|
3069
|
+
for (const token of raw) {
|
|
3070
|
+
for (const part of splitToken(token)) {
|
|
3071
|
+
const lower = part.toLowerCase();
|
|
3072
|
+
if (lower.length > 0 && !words.includes(lower)) {
|
|
3073
|
+
words.push(lower);
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
return { words, label: words.join(" ") };
|
|
3078
|
+
}
|
|
3079
|
+
function splitToken(token) {
|
|
3080
|
+
return token.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[\s\-_.:]+/).filter((part) => part.length > 0);
|
|
3081
|
+
}
|
|
3082
|
+
function buildHealCandidates(selector) {
|
|
3083
|
+
const { words, label } = extractSelectorIntent(selector);
|
|
3084
|
+
if (words.length === 0) return [];
|
|
3085
|
+
const escaped = label.replace(/"/g, '\\"');
|
|
3086
|
+
const candidates = [];
|
|
3087
|
+
candidates.push({ selector: `text=${label}`, strategy: "text" });
|
|
3088
|
+
candidates.push({ selector: `[aria-label*="${escaped}" i]`, strategy: "aria" });
|
|
3089
|
+
for (const tag of INTERACTIVE_TAGS) {
|
|
3090
|
+
candidates.push({ selector: `${tag}:has-text("${escaped}")`, strategy: "structural" });
|
|
3091
|
+
}
|
|
3092
|
+
return candidates;
|
|
3093
|
+
}
|
|
3094
|
+
async function healSelector(probe, selector, options) {
|
|
3095
|
+
if (!options.enabled) return null;
|
|
3096
|
+
for (const candidate of buildHealCandidates(selector)) {
|
|
3097
|
+
let count;
|
|
3098
|
+
try {
|
|
3099
|
+
const locator2 = probe.locator(candidate.selector);
|
|
3100
|
+
count = await locator2.count();
|
|
3101
|
+
} catch {
|
|
3102
|
+
continue;
|
|
3103
|
+
}
|
|
3104
|
+
if (count === 1) {
|
|
3105
|
+
return { selector: candidate.selector, healedFrom: selector, strategy: candidate.strategy };
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
return null;
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
// src/runner/history.ts
|
|
3112
|
+
import fs6 from "fs";
|
|
3113
|
+
import path6 from "path";
|
|
3114
|
+
var HISTORY_FILE = "history.json";
|
|
3115
|
+
var LOCK_FILE_SUFFIX = ".lock";
|
|
3116
|
+
var LOCK_RETRY_MS = 10;
|
|
3117
|
+
var LOCK_TIMEOUT_MS = 5e3;
|
|
3118
|
+
var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
3119
|
+
function historyPath(configDir) {
|
|
3120
|
+
return path6.join(configDir, HISTORY_FILE);
|
|
3121
|
+
}
|
|
3122
|
+
function isHistoryEntry(value) {
|
|
3123
|
+
if (!value || typeof value !== "object") {
|
|
3124
|
+
return false;
|
|
3125
|
+
}
|
|
3126
|
+
const entry = value;
|
|
3127
|
+
return typeof entry.hunt === "string" && (entry.status === "pass" || entry.status === "fail") && typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && typeof entry.startedAt === "string" && (entry.runDir === void 0 || typeof entry.runDir === "string");
|
|
3128
|
+
}
|
|
3129
|
+
function readHistory(configDir) {
|
|
3130
|
+
const filePath = historyPath(configDir);
|
|
3131
|
+
if (!fs6.existsSync(filePath)) {
|
|
3132
|
+
return { entries: [] };
|
|
3133
|
+
}
|
|
3134
|
+
try {
|
|
3135
|
+
const raw = fs6.readFileSync(filePath, "utf-8");
|
|
3136
|
+
const parsed = JSON.parse(raw);
|
|
3137
|
+
if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
|
|
3138
|
+
const validatedEntries = parsed.entries.filter(isHistoryEntry);
|
|
3139
|
+
return { entries: validatedEntries };
|
|
3140
|
+
}
|
|
3141
|
+
return { entries: [] };
|
|
3142
|
+
} catch (error) {
|
|
3143
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3144
|
+
console.warn(`Failed to read history file at ${filePath}: ${message}`);
|
|
3145
|
+
return { entries: [] };
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
function readHuntHistory(configDir, huntName) {
|
|
3149
|
+
const { entries } = readHistory(configDir);
|
|
3150
|
+
return entries.filter((entry) => entry.hunt === huntName);
|
|
3151
|
+
}
|
|
3152
|
+
function pruneEntries(entries, maxRuns) {
|
|
3153
|
+
const perHunt = /* @__PURE__ */ new Map();
|
|
3154
|
+
for (const entry of entries) {
|
|
3155
|
+
const list = perHunt.get(entry.hunt) ?? [];
|
|
3156
|
+
list.push(entry);
|
|
3157
|
+
perHunt.set(entry.hunt, list);
|
|
3158
|
+
}
|
|
3159
|
+
const keptEntries = /* @__PURE__ */ new Set();
|
|
3160
|
+
for (const list of perHunt.values()) {
|
|
3161
|
+
const kept = list.length > maxRuns ? list.slice(list.length - maxRuns) : list;
|
|
3162
|
+
for (const entry of kept) {
|
|
3163
|
+
keptEntries.add(entry);
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
return entries.filter((entry) => keptEntries.has(entry));
|
|
3167
|
+
}
|
|
3168
|
+
function sleepSync(ms) {
|
|
3169
|
+
Atomics.wait(SLEEP_BUFFER, 0, 0, ms);
|
|
3170
|
+
}
|
|
3171
|
+
function withHistoryLock(configDir, fn) {
|
|
3172
|
+
const filePath = historyPath(configDir);
|
|
3173
|
+
const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
|
|
3174
|
+
fs6.mkdirSync(path6.dirname(filePath), { recursive: true });
|
|
3175
|
+
const startedAt = Date.now();
|
|
3176
|
+
while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
|
|
3177
|
+
let fd;
|
|
3178
|
+
try {
|
|
3179
|
+
fd = fs6.openSync(lockPath, "wx");
|
|
3180
|
+
} catch (error) {
|
|
3181
|
+
if (error.code === "EEXIST") {
|
|
3182
|
+
sleepSync(LOCK_RETRY_MS);
|
|
3183
|
+
continue;
|
|
3184
|
+
}
|
|
3185
|
+
throw error;
|
|
3186
|
+
}
|
|
3187
|
+
try {
|
|
3188
|
+
return fn();
|
|
3189
|
+
} finally {
|
|
3190
|
+
try {
|
|
3191
|
+
fs6.closeSync(fd);
|
|
3192
|
+
} catch {
|
|
3193
|
+
}
|
|
3194
|
+
fs6.rmSync(lockPath, { force: true });
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
throw new Error(
|
|
3198
|
+
`Failed to acquire history lock before timeout (${LOCK_TIMEOUT_MS}ms): ${lockPath}; started waiting at ${new Date(startedAt).toISOString()}`
|
|
3199
|
+
);
|
|
3200
|
+
}
|
|
3201
|
+
function appendEntry(configDir, entry, maxRuns) {
|
|
3202
|
+
const filePath = historyPath(configDir);
|
|
3203
|
+
withHistoryLock(configDir, () => {
|
|
3204
|
+
const current = readHistory(configDir);
|
|
3205
|
+
const next = pruneEntries([...current.entries, entry], maxRuns);
|
|
3206
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
3207
|
+
fs6.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
|
|
3208
|
+
`);
|
|
3209
|
+
fs6.renameSync(tempPath, filePath);
|
|
3210
|
+
});
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
// src/runner/index.ts
|
|
3214
|
+
import fs12 from "fs";
|
|
3215
|
+
import path12 from "path";
|
|
3216
|
+
|
|
3217
|
+
// src/browser/playwright-driver.ts
|
|
3218
|
+
import fs7 from "fs";
|
|
3219
|
+
import path7 from "path";
|
|
3220
|
+
import {
|
|
3221
|
+
chromium,
|
|
3222
|
+
firefox,
|
|
3223
|
+
webkit
|
|
3224
|
+
} from "playwright";
|
|
3225
|
+
var ENGINES = { chromium, firefox, webkit };
|
|
3226
|
+
async function launchBrowser(options) {
|
|
3227
|
+
const engineName = options.engine ?? "chromium";
|
|
3228
|
+
const engine = Object.prototype.hasOwnProperty.call(ENGINES, engineName) ? ENGINES[engineName] : void 0;
|
|
3229
|
+
if (!engine) {
|
|
3230
|
+
throw new Error(
|
|
3231
|
+
`Unsupported browser engine "${String(engineName)}". Available engines: ${Object.keys(ENGINES).join(", ")}.`
|
|
3232
|
+
);
|
|
3233
|
+
}
|
|
3234
|
+
const browser = await engine.launch({
|
|
3235
|
+
headless: options.headless,
|
|
3236
|
+
slowMo: options.slowMo,
|
|
3237
|
+
channel: options.channel
|
|
1071
3238
|
});
|
|
1072
3239
|
try {
|
|
1073
3240
|
const contextOptions = {};
|
|
@@ -1075,14 +3242,14 @@ async function launchBrowser(options) {
|
|
|
1075
3242
|
contextOptions.viewport = options.viewport;
|
|
1076
3243
|
}
|
|
1077
3244
|
if (options.storageStatePath) {
|
|
1078
|
-
if (
|
|
3245
|
+
if (fs7.existsSync(options.storageStatePath)) {
|
|
1079
3246
|
contextOptions.storageState = options.storageStatePath;
|
|
1080
3247
|
} else {
|
|
1081
3248
|
console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
|
|
1082
3249
|
}
|
|
1083
3250
|
}
|
|
1084
3251
|
if (options.recordHar) {
|
|
1085
|
-
contextOptions.recordHar = { path:
|
|
3252
|
+
contextOptions.recordHar = { path: path7.join(options.runDir, "network.har") };
|
|
1086
3253
|
}
|
|
1087
3254
|
const context = await browser.newContext(contextOptions);
|
|
1088
3255
|
const page = await context.newPage();
|
|
@@ -1090,7 +3257,7 @@ async function launchBrowser(options) {
|
|
|
1090
3257
|
page.setDefaultNavigationTimeout(options.timeout);
|
|
1091
3258
|
let tracePath;
|
|
1092
3259
|
if (options.trace) {
|
|
1093
|
-
tracePath =
|
|
3260
|
+
tracePath = path7.join(options.runDir, "trace.zip");
|
|
1094
3261
|
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
1095
3262
|
}
|
|
1096
3263
|
return { browser, context, page, tracePath };
|
|
@@ -1268,8 +3435,8 @@ function createPlaywrightDriver(page) {
|
|
|
1268
3435
|
}
|
|
1269
3436
|
|
|
1270
3437
|
// src/runner/steps.ts
|
|
1271
|
-
import
|
|
1272
|
-
import
|
|
3438
|
+
import fs8 from "fs";
|
|
3439
|
+
import path8 from "path";
|
|
1273
3440
|
|
|
1274
3441
|
// src/runner/policy.ts
|
|
1275
3442
|
var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
|
|
@@ -1348,25 +3515,265 @@ function createRunPolicy(driver, options) {
|
|
|
1348
3515
|
if (matched) {
|
|
1349
3516
|
return { selector };
|
|
1350
3517
|
}
|
|
1351
|
-
const healed = await healSelector(healProbe, selector, { enabled: true });
|
|
1352
|
-
if (!healed) {
|
|
1353
|
-
return { selector };
|
|
3518
|
+
const healed = await healSelector(healProbe, selector, { enabled: true });
|
|
3519
|
+
if (!healed) {
|
|
3520
|
+
return { selector };
|
|
3521
|
+
}
|
|
3522
|
+
assertAllowedSelector(healed.selector);
|
|
3523
|
+
console.warn(
|
|
3524
|
+
`Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
|
|
3525
|
+
);
|
|
3526
|
+
return { selector: healed.selector, healedFrom: healed.healedFrom };
|
|
3527
|
+
}
|
|
3528
|
+
return {
|
|
3529
|
+
assertWithinMaxSteps,
|
|
3530
|
+
ensureUrlAllowed,
|
|
3531
|
+
ensureAppAllowed,
|
|
3532
|
+
ensureLocationAllowed,
|
|
3533
|
+
assertAllowedSelector,
|
|
3534
|
+
resolveActionSelector
|
|
3535
|
+
};
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
// src/generator/ai.ts
|
|
3539
|
+
var DEFAULT_BASE_URL = {
|
|
3540
|
+
anthropic: "https://api.anthropic.com",
|
|
3541
|
+
openai: "https://api.openai.com"
|
|
3542
|
+
};
|
|
3543
|
+
var AI_REQUEST_TIMEOUT_MS = 3e4;
|
|
3544
|
+
function apiRoot(config) {
|
|
3545
|
+
return config.baseUrl ?? DEFAULT_BASE_URL[config.provider];
|
|
3546
|
+
}
|
|
3547
|
+
function defaultModelFor(provider) {
|
|
3548
|
+
return provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
|
|
3549
|
+
}
|
|
3550
|
+
function resolveAiEnv() {
|
|
3551
|
+
const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
|
|
3552
|
+
if (provider !== "anthropic" && provider !== "openai") {
|
|
3553
|
+
throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
|
|
3554
|
+
}
|
|
3555
|
+
const model = process.env.PROWL_AI_MODEL ?? defaultModelFor(provider);
|
|
3556
|
+
const baseUrl = normalizeBaseUrl(process.env.PROWL_AI_BASE_URL) ?? DEFAULT_BASE_URL[provider];
|
|
3557
|
+
return { provider, model, baseUrl, apiKey: process.env.PROWL_AI_KEY };
|
|
3558
|
+
}
|
|
3559
|
+
function normalizeBaseUrl(value) {
|
|
3560
|
+
if (value === void 0) return void 0;
|
|
3561
|
+
const trimmed = value.trim().replace(/\/+$/, "");
|
|
3562
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
3563
|
+
}
|
|
3564
|
+
function resolveAiConfig() {
|
|
3565
|
+
const env = resolveAiEnv();
|
|
3566
|
+
if (!env.apiKey) {
|
|
3567
|
+
throw new Error(
|
|
3568
|
+
"PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
|
|
3569
|
+
);
|
|
3570
|
+
}
|
|
3571
|
+
return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
|
|
3572
|
+
}
|
|
3573
|
+
function tryResolveAiConfig() {
|
|
3574
|
+
const env = resolveAiEnv();
|
|
3575
|
+
if (!env.apiKey) {
|
|
3576
|
+
return null;
|
|
3577
|
+
}
|
|
3578
|
+
return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
|
|
3579
|
+
}
|
|
3580
|
+
async function generateWithAi(prompt, config) {
|
|
3581
|
+
if (config.provider === "anthropic") {
|
|
3582
|
+
return generateWithAnthropic(prompt, config);
|
|
3583
|
+
}
|
|
3584
|
+
return generateWithOpenAi(prompt, config);
|
|
3585
|
+
}
|
|
3586
|
+
async function generateWithAnthropic(prompt, config) {
|
|
3587
|
+
const data = await postJson(
|
|
3588
|
+
"Anthropic",
|
|
3589
|
+
`${apiRoot(config)}/v1/messages`,
|
|
3590
|
+
anthropicHeaders(config),
|
|
3591
|
+
{
|
|
3592
|
+
model: config.model,
|
|
3593
|
+
max_tokens: 4096,
|
|
3594
|
+
messages: [
|
|
3595
|
+
{ role: "user", content: prompt }
|
|
3596
|
+
]
|
|
3597
|
+
}
|
|
3598
|
+
);
|
|
3599
|
+
return extractAnthropicText(data);
|
|
3600
|
+
}
|
|
3601
|
+
async function generateWithOpenAi(prompt, config) {
|
|
3602
|
+
const data = await postJson(
|
|
3603
|
+
"OpenAI",
|
|
3604
|
+
`${apiRoot(config)}/v1/chat/completions`,
|
|
3605
|
+
openAiHeaders(config),
|
|
3606
|
+
{
|
|
3607
|
+
model: config.model,
|
|
3608
|
+
messages: [
|
|
3609
|
+
{ role: "user", content: prompt }
|
|
3610
|
+
],
|
|
3611
|
+
max_tokens: 4096
|
|
3612
|
+
}
|
|
3613
|
+
);
|
|
3614
|
+
return extractOpenAiText(data);
|
|
3615
|
+
}
|
|
3616
|
+
function buildVisionPrompt(assertion) {
|
|
3617
|
+
return [
|
|
3618
|
+
"You are a meticulous QA reviewer. You are given a screenshot of an application",
|
|
3619
|
+
"and a single assertion describing what should be true about it.",
|
|
3620
|
+
"",
|
|
3621
|
+
"Assertion:",
|
|
3622
|
+
assertion,
|
|
3623
|
+
"",
|
|
3624
|
+
"Decide whether the assertion holds for the screenshot. Judge ONLY what is",
|
|
3625
|
+
"visible; do not assume behavior you cannot see. Be strict: if the assertion",
|
|
3626
|
+
"is not clearly satisfied, it fails.",
|
|
3627
|
+
"",
|
|
3628
|
+
"Reply with ONLY a single JSON object on one line, no markdown, no code fences:",
|
|
3629
|
+
'{"pass": <true|false>, "reason": "<one concise sentence explaining the verdict>"}'
|
|
3630
|
+
].join("\n");
|
|
3631
|
+
}
|
|
3632
|
+
function parseVisionVerdict(raw) {
|
|
3633
|
+
const cleaned = stripCodeFences(raw).trim();
|
|
3634
|
+
const candidate = extractJsonObject(cleaned);
|
|
3635
|
+
if (candidate === null) {
|
|
3636
|
+
throw new Error(
|
|
3637
|
+
`Could not parse a JSON verdict from the AI response: ${truncateForError(raw)}`
|
|
3638
|
+
);
|
|
3639
|
+
}
|
|
3640
|
+
let parsed;
|
|
3641
|
+
try {
|
|
3642
|
+
parsed = JSON.parse(candidate);
|
|
3643
|
+
} catch {
|
|
3644
|
+
throw new Error(
|
|
3645
|
+
`AI verdict was not valid JSON: ${truncateForError(raw)}`
|
|
3646
|
+
);
|
|
3647
|
+
}
|
|
3648
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
3649
|
+
throw new Error(`AI verdict was not a JSON object: ${truncateForError(raw)}`);
|
|
3650
|
+
}
|
|
3651
|
+
const record = parsed;
|
|
3652
|
+
if (typeof record.pass !== "boolean") {
|
|
3653
|
+
throw new Error(
|
|
3654
|
+
`AI verdict is missing a boolean "pass" field: ${truncateForError(raw)}`
|
|
3655
|
+
);
|
|
3656
|
+
}
|
|
3657
|
+
const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : record.pass ? "Assertion satisfied." : "Assertion not satisfied.";
|
|
3658
|
+
return { pass: record.pass, reason };
|
|
3659
|
+
}
|
|
3660
|
+
function stripCodeFences(text) {
|
|
3661
|
+
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
3662
|
+
return fence ? fence[1] : text;
|
|
3663
|
+
}
|
|
3664
|
+
function extractJsonObject(text) {
|
|
3665
|
+
const trimmed = text.trim();
|
|
3666
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
3667
|
+
return trimmed;
|
|
3668
|
+
}
|
|
3669
|
+
const first = trimmed.indexOf("{");
|
|
3670
|
+
const last = trimmed.lastIndexOf("}");
|
|
3671
|
+
if (first === -1 || last === -1 || last <= first) {
|
|
3672
|
+
return null;
|
|
3673
|
+
}
|
|
3674
|
+
return trimmed.slice(first, last + 1);
|
|
3675
|
+
}
|
|
3676
|
+
function truncateForError(text) {
|
|
3677
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
3678
|
+
return collapsed.length > 200 ? `${collapsed.slice(0, 199)}\u2026` : collapsed;
|
|
3679
|
+
}
|
|
3680
|
+
async function assertWithAiVision(input, config) {
|
|
3681
|
+
const raw = config.provider === "anthropic" ? await visionWithAnthropic(input, config) : await visionWithOpenAi(input, config);
|
|
3682
|
+
return parseVisionVerdict(raw);
|
|
3683
|
+
}
|
|
3684
|
+
async function visionWithAnthropic(input, config) {
|
|
3685
|
+
const data = await postJson(
|
|
3686
|
+
"Anthropic",
|
|
3687
|
+
`${apiRoot(config)}/v1/messages`,
|
|
3688
|
+
anthropicHeaders(config),
|
|
3689
|
+
{
|
|
3690
|
+
model: config.model,
|
|
3691
|
+
max_tokens: 1024,
|
|
3692
|
+
temperature: 0,
|
|
3693
|
+
messages: [
|
|
3694
|
+
{
|
|
3695
|
+
role: "user",
|
|
3696
|
+
content: [
|
|
3697
|
+
{
|
|
3698
|
+
type: "image",
|
|
3699
|
+
source: {
|
|
3700
|
+
type: "base64",
|
|
3701
|
+
media_type: input.mediaType,
|
|
3702
|
+
data: input.imageBase64
|
|
3703
|
+
}
|
|
3704
|
+
},
|
|
3705
|
+
{ type: "text", text: buildVisionPrompt(input.assertion) }
|
|
3706
|
+
]
|
|
3707
|
+
}
|
|
3708
|
+
]
|
|
3709
|
+
}
|
|
3710
|
+
);
|
|
3711
|
+
return extractAnthropicText(data);
|
|
3712
|
+
}
|
|
3713
|
+
async function visionWithOpenAi(input, config) {
|
|
3714
|
+
const data = await postJson(
|
|
3715
|
+
"OpenAI",
|
|
3716
|
+
`${apiRoot(config)}/v1/chat/completions`,
|
|
3717
|
+
openAiHeaders(config),
|
|
3718
|
+
{
|
|
3719
|
+
model: config.model,
|
|
3720
|
+
max_tokens: 1024,
|
|
3721
|
+
temperature: 0,
|
|
3722
|
+
messages: [
|
|
3723
|
+
{
|
|
3724
|
+
role: "user",
|
|
3725
|
+
content: [
|
|
3726
|
+
{ type: "text", text: buildVisionPrompt(input.assertion) },
|
|
3727
|
+
{
|
|
3728
|
+
type: "image_url",
|
|
3729
|
+
image_url: { url: `data:${input.mediaType};base64,${input.imageBase64}` }
|
|
3730
|
+
}
|
|
3731
|
+
]
|
|
3732
|
+
}
|
|
3733
|
+
]
|
|
1354
3734
|
}
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
3735
|
+
);
|
|
3736
|
+
return extractOpenAiText(data);
|
|
3737
|
+
}
|
|
3738
|
+
async function postJson(provider, url, headers, body) {
|
|
3739
|
+
const response = await fetch(url, {
|
|
3740
|
+
method: "POST",
|
|
3741
|
+
headers,
|
|
3742
|
+
body: JSON.stringify(body),
|
|
3743
|
+
signal: AbortSignal.timeout(AI_REQUEST_TIMEOUT_MS)
|
|
3744
|
+
});
|
|
3745
|
+
if (!response.ok) {
|
|
3746
|
+
const responseBody = await response.text();
|
|
3747
|
+
throw new Error(`${provider} API error (${response.status}): ${responseBody}`);
|
|
1360
3748
|
}
|
|
3749
|
+
return await response.json();
|
|
3750
|
+
}
|
|
3751
|
+
function anthropicHeaders(config) {
|
|
1361
3752
|
return {
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
3753
|
+
"Content-Type": "application/json",
|
|
3754
|
+
"x-api-key": config.apiKey,
|
|
3755
|
+
"anthropic-version": "2023-06-01"
|
|
3756
|
+
};
|
|
3757
|
+
}
|
|
3758
|
+
function openAiHeaders(config) {
|
|
3759
|
+
return {
|
|
3760
|
+
"Content-Type": "application/json",
|
|
3761
|
+
"Authorization": `Bearer ${config.apiKey}`
|
|
1368
3762
|
};
|
|
1369
3763
|
}
|
|
3764
|
+
function extractAnthropicText(data) {
|
|
3765
|
+
const textBlock = data.content?.find((c) => c.type === "text");
|
|
3766
|
+
if (!textBlock?.text) {
|
|
3767
|
+
throw new Error("Anthropic API returned no text content");
|
|
3768
|
+
}
|
|
3769
|
+
return textBlock.text;
|
|
3770
|
+
}
|
|
3771
|
+
function extractOpenAiText(data) {
|
|
3772
|
+
if (!data.choices?.[0]?.message?.content) {
|
|
3773
|
+
throw new Error("OpenAI API returned no content");
|
|
3774
|
+
}
|
|
3775
|
+
return data.choices[0].message.content;
|
|
3776
|
+
}
|
|
1370
3777
|
|
|
1371
3778
|
// src/runner/steps.ts
|
|
1372
3779
|
function getStepType(step) {
|
|
@@ -1396,6 +3803,7 @@ function getStepType(step) {
|
|
|
1396
3803
|
if ("evalScript" in step) return "evalScript";
|
|
1397
3804
|
if ("runScript" in step) return "runScript";
|
|
1398
3805
|
if ("assertScreenshot" in step) return "assertScreenshot";
|
|
3806
|
+
if ("assertWithAI" in step) return "assertWithAI";
|
|
1399
3807
|
if ("copyText" in step) return "copyText";
|
|
1400
3808
|
if ("waitForDownload" in step) return "waitForDownload";
|
|
1401
3809
|
return "step";
|
|
@@ -1455,6 +3863,9 @@ function applyRuntimeVars(step, vars) {
|
|
|
1455
3863
|
}
|
|
1456
3864
|
};
|
|
1457
3865
|
}
|
|
3866
|
+
if ("assertWithAI" in step) {
|
|
3867
|
+
return { assertWithAI: sub(step.assertWithAI) };
|
|
3868
|
+
}
|
|
1458
3869
|
if ("copyText" in step) {
|
|
1459
3870
|
return { copyText: { selector: sub(step.copyText.selector), as: step.copyText.as } };
|
|
1460
3871
|
}
|
|
@@ -1705,7 +4116,7 @@ async function runInlineAssert(driver, policy, assertion) {
|
|
|
1705
4116
|
throw new Error("assert step is missing an assertion type");
|
|
1706
4117
|
}
|
|
1707
4118
|
function screenshotPath(screenshotsDir, fileName) {
|
|
1708
|
-
return
|
|
4119
|
+
return path8.join(screenshotsDir, fileName);
|
|
1709
4120
|
}
|
|
1710
4121
|
function stepPath(prefix, index) {
|
|
1711
4122
|
return prefix ? `${prefix}.${index}` : `${index}`;
|
|
@@ -1722,12 +4133,12 @@ function validateDownloadFilename(suggestedFilename) {
|
|
|
1722
4133
|
const safeFilename = suggestedFilename.trim();
|
|
1723
4134
|
const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
|
|
1724
4135
|
const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
|
|
1725
|
-
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !==
|
|
4136
|
+
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== path8.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
|
|
1726
4137
|
throw new Error(`Invalid download filename: "${suggestedFilename}"`);
|
|
1727
4138
|
}
|
|
1728
4139
|
return safeFilename;
|
|
1729
4140
|
}
|
|
1730
|
-
async function
|
|
4141
|
+
async function captureScreenshot2(taker, filePath) {
|
|
1731
4142
|
try {
|
|
1732
4143
|
await taker.screenshot({ path: filePath, fullPage: true });
|
|
1733
4144
|
} catch (error) {
|
|
@@ -1902,7 +4313,7 @@ var STEP_HANDLERS = {
|
|
|
1902
4313
|
if (!("setInputFiles" in h.step)) unknownStep();
|
|
1903
4314
|
const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
|
|
1904
4315
|
const rawFiles = h.step.setInputFiles.files;
|
|
1905
|
-
const resolveFile = (f) =>
|
|
4316
|
+
const resolveFile = (f) => path8.isAbsolute(f) ? f : path8.join(h.context.configDir, f);
|
|
1906
4317
|
const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
|
|
1907
4318
|
await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
|
|
1908
4319
|
h.policy.ensureLocationAllowed(h.driver);
|
|
@@ -1939,7 +4350,7 @@ var STEP_HANDLERS = {
|
|
|
1939
4350
|
redactedFillSteps: subRedacted,
|
|
1940
4351
|
randomVars
|
|
1941
4352
|
} = interpolateHunt(subHunt, process.env, h.context.randomVars);
|
|
1942
|
-
const subTargetType = h.driver.capabilities.has("navigate") ? "web" : "macos";
|
|
4353
|
+
const subTargetType = h.context.targetType ?? (h.driver.capabilities.has("navigate") ? "web" : "macos");
|
|
1943
4354
|
assertStepsSupportedByTarget(interpolatedSubHunt.steps, subTargetType);
|
|
1944
4355
|
assertHuntAssertionsSupportedByTarget(interpolatedSubHunt.assertions, subTargetType);
|
|
1945
4356
|
h.policy.assertWithinMaxSteps(interpolatedSubHunt.steps.length, huntName);
|
|
@@ -2264,15 +4675,15 @@ var STEP_HANDLERS = {
|
|
|
2264
4675
|
if (!responseFile) {
|
|
2265
4676
|
throw new Error("mock.response must include either body or file");
|
|
2266
4677
|
}
|
|
2267
|
-
const candidateFilePath =
|
|
2268
|
-
const resolvedConfigDir =
|
|
2269
|
-
const resolvedFilePath =
|
|
2270
|
-
const relativePath =
|
|
2271
|
-
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${
|
|
4678
|
+
const candidateFilePath = path8.isAbsolute(responseFile) ? responseFile : path8.join(h.context.configDir, responseFile);
|
|
4679
|
+
const resolvedConfigDir = path8.resolve(h.context.configDir);
|
|
4680
|
+
const resolvedFilePath = path8.resolve(candidateFilePath);
|
|
4681
|
+
const relativePath = path8.relative(resolvedConfigDir, resolvedFilePath);
|
|
4682
|
+
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${path8.sep}`) && !path8.isAbsolute(relativePath);
|
|
2272
4683
|
if (!isWithinConfigDir) {
|
|
2273
4684
|
throw new Error("mock.response.file must resolve within config directory");
|
|
2274
4685
|
}
|
|
2275
|
-
responseBody = await
|
|
4686
|
+
responseBody = await fs8.promises.readFile(resolvedFilePath, "utf-8");
|
|
2276
4687
|
}
|
|
2277
4688
|
const contentType = mock.response.contentType ?? "application/json";
|
|
2278
4689
|
const status = mock.response.status;
|
|
@@ -2335,8 +4746,8 @@ var STEP_HANDLERS = {
|
|
|
2335
4746
|
capabilities: ["evaluate"],
|
|
2336
4747
|
run: async (h) => {
|
|
2337
4748
|
if (!("runScript" in h.step)) unknownStep();
|
|
2338
|
-
const filePath =
|
|
2339
|
-
const fileContents =
|
|
4749
|
+
const filePath = path8.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : path8.join(h.context.configDir, h.step.runScript.file);
|
|
4750
|
+
const fileContents = fs8.readFileSync(filePath, "utf-8");
|
|
2340
4751
|
await h.driver.evaluate(fileContents);
|
|
2341
4752
|
return {
|
|
2342
4753
|
kind: "result",
|
|
@@ -2357,19 +4768,19 @@ var STEP_HANDLERS = {
|
|
|
2357
4768
|
const name = h.step.assertScreenshot.name;
|
|
2358
4769
|
const threshold = h.step.assertScreenshot.threshold ?? 0.1;
|
|
2359
4770
|
const baselineDir = ensureBaselineDir(h.context.configDir);
|
|
2360
|
-
const baselinePath =
|
|
2361
|
-
const currentScreenshotPath =
|
|
2362
|
-
|
|
4771
|
+
const baselinePath = path8.join(baselineDir, `${name}.png`);
|
|
4772
|
+
const currentScreenshotPath = path8.join(h.context.runDir, "screenshots", `${name}-current.png`);
|
|
4773
|
+
fs8.mkdirSync(path8.dirname(currentScreenshotPath), { recursive: true });
|
|
2363
4774
|
await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
|
|
2364
|
-
h.screenshots.push(
|
|
2365
|
-
if (!
|
|
2366
|
-
|
|
4775
|
+
h.screenshots.push(path8.join("screenshots", `${name}-current.png`));
|
|
4776
|
+
if (!fs8.existsSync(baselinePath)) {
|
|
4777
|
+
fs8.copyFileSync(currentScreenshotPath, baselinePath);
|
|
2367
4778
|
return {
|
|
2368
4779
|
kind: "result",
|
|
2369
4780
|
result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
|
|
2370
4781
|
};
|
|
2371
4782
|
}
|
|
2372
|
-
const diffPath =
|
|
4783
|
+
const diffPath = path8.join(h.context.runDir, "screenshots", `${name}-diff.png`);
|
|
2373
4784
|
const comparison = await compareScreenshots(baselinePath, currentScreenshotPath, diffPath, threshold);
|
|
2374
4785
|
if (comparison.match) {
|
|
2375
4786
|
return {
|
|
@@ -2382,12 +4793,54 @@ var STEP_HANDLERS = {
|
|
|
2382
4793
|
}
|
|
2383
4794
|
};
|
|
2384
4795
|
}
|
|
2385
|
-
h.screenshots.push(
|
|
4796
|
+
h.screenshots.push(path8.join("screenshots", `${name}-diff.png`));
|
|
2386
4797
|
throw new Error(
|
|
2387
4798
|
`Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
|
|
2388
4799
|
);
|
|
2389
4800
|
}
|
|
2390
4801
|
},
|
|
4802
|
+
assertWithAI: {
|
|
4803
|
+
capabilities: ["screenshot"],
|
|
4804
|
+
run: async (h) => {
|
|
4805
|
+
if (!("assertWithAI" in h.step)) unknownStep();
|
|
4806
|
+
const assertion = h.step.assertWithAI;
|
|
4807
|
+
const resolve = h.context.resolveAiConfig ?? tryResolveAiConfig;
|
|
4808
|
+
const aiConfig = resolve();
|
|
4809
|
+
if (!aiConfig) {
|
|
4810
|
+
return {
|
|
4811
|
+
kind: "result",
|
|
4812
|
+
result: {
|
|
4813
|
+
type: "assertWithAI",
|
|
4814
|
+
status: "warn",
|
|
4815
|
+
durationMs: Date.now() - h.stepStart,
|
|
4816
|
+
value: `skipped: no AI provider configured (set PROWL_AI_KEY to enable) \u2014 "${assertion}"`
|
|
4817
|
+
}
|
|
4818
|
+
};
|
|
4819
|
+
}
|
|
4820
|
+
const fileName = `assertWithAI_step_${h.index + 1}.png`;
|
|
4821
|
+
const relative = await h.addScreenshot(fileName);
|
|
4822
|
+
const screenshotFullPath = path8.join(h.context.runDir, relative);
|
|
4823
|
+
const imageBase64 = fs8.readFileSync(screenshotFullPath).toString("base64");
|
|
4824
|
+
const assertVision = h.context.assertVision ?? assertWithAiVision;
|
|
4825
|
+
const verdict = await assertVision(
|
|
4826
|
+
{ imageBase64, mediaType: "image/png", assertion },
|
|
4827
|
+
aiConfig
|
|
4828
|
+
);
|
|
4829
|
+
if (verdict.pass) {
|
|
4830
|
+
return {
|
|
4831
|
+
kind: "result",
|
|
4832
|
+
result: {
|
|
4833
|
+
type: "assertWithAI",
|
|
4834
|
+
status: "pass",
|
|
4835
|
+
durationMs: Date.now() - h.stepStart,
|
|
4836
|
+
value: verdict.reason,
|
|
4837
|
+
screenshot: relative
|
|
4838
|
+
}
|
|
4839
|
+
};
|
|
4840
|
+
}
|
|
4841
|
+
throw new Error(`AI assertion failed: ${verdict.reason}`);
|
|
4842
|
+
}
|
|
4843
|
+
},
|
|
2391
4844
|
copyText: {
|
|
2392
4845
|
capabilities: ["query"],
|
|
2393
4846
|
run: async (h) => {
|
|
@@ -2424,7 +4877,7 @@ var STEP_HANDLERS = {
|
|
|
2424
4877
|
`Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
|
|
2425
4878
|
);
|
|
2426
4879
|
}
|
|
2427
|
-
const savePath =
|
|
4880
|
+
const savePath = path8.join(h.context.runDir, suggestedFilename);
|
|
2428
4881
|
await download.saveAs(savePath);
|
|
2429
4882
|
return {
|
|
2430
4883
|
kind: "result",
|
|
@@ -2453,8 +4906,8 @@ async function executeSteps(context) {
|
|
|
2453
4906
|
maxSteps: context.maxSteps,
|
|
2454
4907
|
selfHealing: context.selfHealing
|
|
2455
4908
|
});
|
|
2456
|
-
const screenshotsDir =
|
|
2457
|
-
|
|
4909
|
+
const screenshotsDir = path8.join(context.runDir, "screenshots");
|
|
4910
|
+
fs8.mkdirSync(screenshotsDir, { recursive: true });
|
|
2458
4911
|
const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
|
|
2459
4912
|
policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
|
|
2460
4913
|
const results = [];
|
|
@@ -2463,8 +4916,8 @@ async function executeSteps(context) {
|
|
|
2463
4916
|
context.runStartedAtMs = runStartedAtMs;
|
|
2464
4917
|
const addScreenshot = async (fileName) => {
|
|
2465
4918
|
const fullPath = screenshotPath(screenshotsDir, fileName);
|
|
2466
|
-
await
|
|
2467
|
-
const relative =
|
|
4919
|
+
await captureScreenshot2(driver, fullPath);
|
|
4920
|
+
const relative = path8.join("screenshots", fileName);
|
|
2468
4921
|
screenshots.push(relative);
|
|
2469
4922
|
return relative;
|
|
2470
4923
|
};
|
|
@@ -2552,12 +5005,12 @@ async function executeSteps(context) {
|
|
|
2552
5005
|
return { results, screenshots, failed: false };
|
|
2553
5006
|
}
|
|
2554
5007
|
async function captureFinalScreenshot(page, runDir) {
|
|
2555
|
-
const screenshotsDir =
|
|
2556
|
-
|
|
5008
|
+
const screenshotsDir = path8.join(runDir, "screenshots");
|
|
5009
|
+
fs8.mkdirSync(screenshotsDir, { recursive: true });
|
|
2557
5010
|
const fileName = "final.png";
|
|
2558
5011
|
const filePath = screenshotPath(screenshotsDir, fileName);
|
|
2559
|
-
await
|
|
2560
|
-
return
|
|
5012
|
+
await captureScreenshot2(page, filePath);
|
|
5013
|
+
return path8.join("screenshots", fileName);
|
|
2561
5014
|
}
|
|
2562
5015
|
|
|
2563
5016
|
// src/runner/assertions.ts
|
|
@@ -2717,18 +5170,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
|
|
|
2717
5170
|
}
|
|
2718
5171
|
|
|
2719
5172
|
// src/reporter/result.ts
|
|
2720
|
-
import
|
|
2721
|
-
import
|
|
5173
|
+
import fs9 from "fs";
|
|
5174
|
+
import path9 from "path";
|
|
2722
5175
|
function writeResult(runDir, result) {
|
|
2723
5176
|
const fileName = "result.json";
|
|
2724
|
-
const fullPath =
|
|
2725
|
-
|
|
5177
|
+
const fullPath = path9.join(runDir, fileName);
|
|
5178
|
+
fs9.writeFileSync(fullPath, JSON.stringify(result, null, 2));
|
|
2726
5179
|
return fileName;
|
|
2727
5180
|
}
|
|
2728
5181
|
|
|
2729
5182
|
// src/reporter/summary.ts
|
|
2730
|
-
import
|
|
2731
|
-
import
|
|
5183
|
+
import fs10 from "fs";
|
|
5184
|
+
import path10 from "path";
|
|
2732
5185
|
function escapeMd(text) {
|
|
2733
5186
|
return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
|
|
2734
5187
|
}
|
|
@@ -2806,15 +5259,15 @@ function writeSummary(runDir, result) {
|
|
|
2806
5259
|
}
|
|
2807
5260
|
}
|
|
2808
5261
|
const fileName = "summary.md";
|
|
2809
|
-
const fullPath =
|
|
2810
|
-
|
|
5262
|
+
const fullPath = path10.join(runDir, fileName);
|
|
5263
|
+
fs10.writeFileSync(fullPath, `${lines.join("\n")}
|
|
2811
5264
|
`);
|
|
2812
5265
|
return fileName;
|
|
2813
5266
|
}
|
|
2814
5267
|
|
|
2815
5268
|
// src/reporter/junit.ts
|
|
2816
|
-
import
|
|
2817
|
-
import
|
|
5269
|
+
import fs11 from "fs";
|
|
5270
|
+
import path11 from "path";
|
|
2818
5271
|
function escapeXml(text) {
|
|
2819
5272
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2820
5273
|
}
|
|
@@ -2858,8 +5311,8 @@ function writeJunit(runDir, result) {
|
|
|
2858
5311
|
lines.push(" </testsuite>");
|
|
2859
5312
|
lines.push("</testsuites>");
|
|
2860
5313
|
const fileName = "junit.xml";
|
|
2861
|
-
const fullPath =
|
|
2862
|
-
|
|
5314
|
+
const fullPath = path11.join(runDir, fileName);
|
|
5315
|
+
fs11.writeFileSync(fullPath, `${lines.join("\n")}
|
|
2863
5316
|
`);
|
|
2864
5317
|
return fileName;
|
|
2865
5318
|
}
|
|
@@ -2901,11 +5354,11 @@ function parseViewportFlag(value) {
|
|
|
2901
5354
|
return value;
|
|
2902
5355
|
}
|
|
2903
5356
|
function resolvePath(configDir, inputPath) {
|
|
2904
|
-
if (
|
|
5357
|
+
if (path12.isAbsolute(inputPath)) {
|
|
2905
5358
|
return inputPath;
|
|
2906
5359
|
}
|
|
2907
|
-
const projectRoot =
|
|
2908
|
-
return
|
|
5360
|
+
const projectRoot = path12.dirname(configDir);
|
|
5361
|
+
return path12.join(projectRoot, inputPath);
|
|
2909
5362
|
}
|
|
2910
5363
|
function buildRunResult(options) {
|
|
2911
5364
|
return {
|
|
@@ -2924,12 +5377,12 @@ function buildRunResult(options) {
|
|
|
2924
5377
|
}
|
|
2925
5378
|
function writeConsoleLog(runDir, entries) {
|
|
2926
5379
|
const fileName = "console.log";
|
|
2927
|
-
const filePath =
|
|
5380
|
+
const filePath = path12.join(runDir, fileName);
|
|
2928
5381
|
const lines = entries.map((entry) => {
|
|
2929
5382
|
const location = entry.location ? ` (${entry.location})` : "";
|
|
2930
5383
|
return `[${entry.type}] ${entry.text}${location}`;
|
|
2931
5384
|
});
|
|
2932
|
-
|
|
5385
|
+
fs12.writeFileSync(filePath, `${lines.join("\n")}
|
|
2933
5386
|
`);
|
|
2934
5387
|
return fileName;
|
|
2935
5388
|
}
|
|
@@ -2937,8 +5390,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
2937
5390
|
const headless = options.headed ? false : config.browser.headless;
|
|
2938
5391
|
const slowMo = options.slowMo ?? config.browser.slowMo;
|
|
2939
5392
|
const maxSteps = config.guardrails.maxSteps;
|
|
2940
|
-
const runDir =
|
|
2941
|
-
|
|
5393
|
+
const runDir = path12.join(configDir, "runs", timestamp());
|
|
5394
|
+
fs12.mkdirSync(runDir, { recursive: true });
|
|
2942
5395
|
const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
|
|
2943
5396
|
const engine = options.browser ?? config.browser.engine;
|
|
2944
5397
|
const channel = options.channel ?? config.browser.channel;
|
|
@@ -3055,7 +5508,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
3055
5508
|
}
|
|
3056
5509
|
return { result, runDir, steps: interpolatedHunt.steps };
|
|
3057
5510
|
}
|
|
3058
|
-
function
|
|
5511
|
+
function delay3(ms) {
|
|
3059
5512
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3060
5513
|
}
|
|
3061
5514
|
async function runHunt(options) {
|
|
@@ -3063,6 +5516,12 @@ async function runHunt(options) {
|
|
|
3063
5516
|
if (config.target.type === "macos") {
|
|
3064
5517
|
return runMacHunt(options, config, configDir, config.target);
|
|
3065
5518
|
}
|
|
5519
|
+
if (config.target.type === "android") {
|
|
5520
|
+
return runAndroidHunt(options, config, configDir, config.target);
|
|
5521
|
+
}
|
|
5522
|
+
if (config.target.type === "ios") {
|
|
5523
|
+
return runIosHunt(options, config, configDir, config.target);
|
|
5524
|
+
}
|
|
3066
5525
|
const hunt = loadHunt(options.huntName, configDir);
|
|
3067
5526
|
const {
|
|
3068
5527
|
hunt: interpolatedHunt,
|
|
@@ -3084,7 +5543,7 @@ async function runHunt(options) {
|
|
|
3084
5543
|
let lastResult;
|
|
3085
5544
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3086
5545
|
if (attempt > 0 && retryDelay > 0) {
|
|
3087
|
-
await
|
|
5546
|
+
await delay3(retryDelay);
|
|
3088
5547
|
}
|
|
3089
5548
|
lastResult = await executeHuntAttempt(
|
|
3090
5549
|
options,
|
|
@@ -3113,19 +5572,17 @@ async function runHunt(options) {
|
|
|
3113
5572
|
}
|
|
3114
5573
|
return lastResult;
|
|
3115
5574
|
}
|
|
3116
|
-
async function
|
|
5575
|
+
async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
|
|
3117
5576
|
const maxSteps = config.guardrails.maxSteps;
|
|
3118
|
-
const runDir =
|
|
3119
|
-
|
|
3120
|
-
const session = await
|
|
3121
|
-
app: target.app,
|
|
3122
|
-
timeoutMs: config.browser.timeout,
|
|
3123
|
-
clientFactory: options.macClientFactory
|
|
3124
|
-
});
|
|
5577
|
+
const runDir = path12.join(configDir, "runs", timestamp());
|
|
5578
|
+
fs12.mkdirSync(runDir, { recursive: true });
|
|
5579
|
+
const session = await native.launchSession();
|
|
3125
5580
|
let result;
|
|
3126
5581
|
try {
|
|
3127
|
-
const
|
|
3128
|
-
const
|
|
5582
|
+
const driver = native.sessionDriver(session);
|
|
5583
|
+
const appIdentity = native.sessionAppIdentity(session);
|
|
5584
|
+
const targetLabel = `${native.targetType}:${appIdentity}`;
|
|
5585
|
+
const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
|
|
3129
5586
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3130
5587
|
const startTime = Date.now();
|
|
3131
5588
|
let stepResults = [];
|
|
@@ -3133,7 +5590,8 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
|
|
|
3133
5590
|
let stepFailed = false;
|
|
3134
5591
|
try {
|
|
3135
5592
|
const stepExecution = await executeSteps({
|
|
3136
|
-
driver
|
|
5593
|
+
driver,
|
|
5594
|
+
targetType: native.targetType,
|
|
3137
5595
|
steps: interpolatedHunt.steps,
|
|
3138
5596
|
targetUrl: targetLabel,
|
|
3139
5597
|
runDir,
|
|
@@ -3160,7 +5618,7 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
|
|
|
3160
5618
|
}
|
|
3161
5619
|
let finalScreenshot;
|
|
3162
5620
|
try {
|
|
3163
|
-
finalScreenshot = await captureFinalScreenshot(
|
|
5621
|
+
finalScreenshot = await captureFinalScreenshot(driver, runDir);
|
|
3164
5622
|
} catch {
|
|
3165
5623
|
finalScreenshot = void 0;
|
|
3166
5624
|
}
|
|
@@ -3181,16 +5639,116 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
|
|
|
3181
5639
|
});
|
|
3182
5640
|
result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
|
|
3183
5641
|
} finally {
|
|
3184
|
-
await
|
|
5642
|
+
await native.closeSession(session);
|
|
3185
5643
|
}
|
|
3186
5644
|
return { result, runDir, steps: interpolatedHunt.steps };
|
|
3187
5645
|
}
|
|
5646
|
+
async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
5647
|
+
return executeNativeHuntAttempt(
|
|
5648
|
+
options,
|
|
5649
|
+
config,
|
|
5650
|
+
configDir,
|
|
5651
|
+
interpolatedHunt,
|
|
5652
|
+
redactedFillSteps,
|
|
5653
|
+
randomVars,
|
|
5654
|
+
allowedApps,
|
|
5655
|
+
{
|
|
5656
|
+
targetType: "macos",
|
|
5657
|
+
targetApp: target.app,
|
|
5658
|
+
launchSession: () => launchMacSession({
|
|
5659
|
+
app: target.app,
|
|
5660
|
+
timeoutMs: config.browser.timeout,
|
|
5661
|
+
clientFactory: options.macClientFactory
|
|
5662
|
+
}),
|
|
5663
|
+
closeSession: closeMacSession,
|
|
5664
|
+
sessionDriver: (session) => session.driver,
|
|
5665
|
+
sessionAppIdentity: (session) => session.bundleId
|
|
5666
|
+
}
|
|
5667
|
+
);
|
|
5668
|
+
}
|
|
3188
5669
|
async function runMacHunt(options, config, configDir, target) {
|
|
5670
|
+
return runNativeHunt(options, config, configDir, target, {
|
|
5671
|
+
targetType: "macos",
|
|
5672
|
+
assertAppAllowed: (allowedApps, nativeTarget) => assertTargetAppAllowed(allowedApps, nativeTarget.app),
|
|
5673
|
+
attempt: executeMacHuntAttempt
|
|
5674
|
+
});
|
|
5675
|
+
}
|
|
5676
|
+
async function executeAndroidHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
5677
|
+
const launch = options.androidSessionFactory ?? launchAndroidSession;
|
|
5678
|
+
return executeNativeHuntAttempt(
|
|
5679
|
+
options,
|
|
5680
|
+
config,
|
|
5681
|
+
configDir,
|
|
5682
|
+
interpolatedHunt,
|
|
5683
|
+
redactedFillSteps,
|
|
5684
|
+
randomVars,
|
|
5685
|
+
allowedApps,
|
|
5686
|
+
{
|
|
5687
|
+
targetType: "android",
|
|
5688
|
+
targetApp: target.app,
|
|
5689
|
+
launchSession: () => launch({
|
|
5690
|
+
app: target.app,
|
|
5691
|
+
deviceSerial: target.deviceSerial,
|
|
5692
|
+
coldStart: target.coldStart,
|
|
5693
|
+
timeoutMs: config.browser.timeout,
|
|
5694
|
+
allowedApps
|
|
5695
|
+
}),
|
|
5696
|
+
closeSession: closeAndroidSession,
|
|
5697
|
+
sessionDriver: (session) => session.driver,
|
|
5698
|
+
sessionAppIdentity: (session) => session.package
|
|
5699
|
+
}
|
|
5700
|
+
);
|
|
5701
|
+
}
|
|
5702
|
+
async function runAndroidHunt(options, config, configDir, target) {
|
|
5703
|
+
return runNativeHunt(options, config, configDir, target, {
|
|
5704
|
+
targetType: "android",
|
|
5705
|
+
assertAppAllowed: (allowedApps, nativeTarget) => {
|
|
5706
|
+
if (!nativeTarget.app.toLowerCase().endsWith(".apk")) {
|
|
5707
|
+
assertAndroidAppAllowed(allowedApps, nativeTarget.app);
|
|
5708
|
+
}
|
|
5709
|
+
},
|
|
5710
|
+
attempt: executeAndroidHuntAttempt
|
|
5711
|
+
});
|
|
5712
|
+
}
|
|
5713
|
+
async function executeIosHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
5714
|
+
const launch = options.iosSessionFactory ?? launchIosSession;
|
|
5715
|
+
return executeNativeHuntAttempt(
|
|
5716
|
+
options,
|
|
5717
|
+
config,
|
|
5718
|
+
configDir,
|
|
5719
|
+
interpolatedHunt,
|
|
5720
|
+
redactedFillSteps,
|
|
5721
|
+
randomVars,
|
|
5722
|
+
allowedApps,
|
|
5723
|
+
{
|
|
5724
|
+
targetType: "ios",
|
|
5725
|
+
targetApp: target.app,
|
|
5726
|
+
launchSession: () => launch({
|
|
5727
|
+
app: target.app,
|
|
5728
|
+
udid: target.udid,
|
|
5729
|
+
coldStart: target.coldStart,
|
|
5730
|
+
timeoutMs: config.browser.timeout,
|
|
5731
|
+
allowedApps
|
|
5732
|
+
}),
|
|
5733
|
+
closeSession: closeIosSession,
|
|
5734
|
+
sessionDriver: (session) => session.driver,
|
|
5735
|
+
sessionAppIdentity: (session) => session.bundleId
|
|
5736
|
+
}
|
|
5737
|
+
);
|
|
5738
|
+
}
|
|
5739
|
+
async function runIosHunt(options, config, configDir, target) {
|
|
5740
|
+
return runNativeHunt(options, config, configDir, target, {
|
|
5741
|
+
targetType: "ios",
|
|
5742
|
+
assertAppAllowed: (allowedApps, nativeTarget) => assertIosAppAllowed(allowedApps, nativeTarget.app),
|
|
5743
|
+
attempt: executeIosHuntAttempt
|
|
5744
|
+
});
|
|
5745
|
+
}
|
|
5746
|
+
async function runNativeHunt(options, config, configDir, target, native) {
|
|
3189
5747
|
const hunt = loadHunt(options.huntName, configDir);
|
|
3190
5748
|
const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
|
|
3191
|
-
assertStepsSupportedByTarget(interpolatedHunt.steps,
|
|
3192
|
-
assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions,
|
|
3193
|
-
|
|
5749
|
+
assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
|
|
5750
|
+
assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
|
|
5751
|
+
native.assertAppAllowed(config.guardrails.allowedApps, target);
|
|
3194
5752
|
const maxSteps = config.guardrails.maxSteps;
|
|
3195
5753
|
if (interpolatedHunt.steps.length > maxSteps) {
|
|
3196
5754
|
throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
|
|
@@ -3200,9 +5758,9 @@ async function runMacHunt(options, config, configDir, target) {
|
|
|
3200
5758
|
let lastResult;
|
|
3201
5759
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3202
5760
|
if (attempt > 0 && retryDelay > 0) {
|
|
3203
|
-
await
|
|
5761
|
+
await delay3(retryDelay);
|
|
3204
5762
|
}
|
|
3205
|
-
lastResult = await
|
|
5763
|
+
lastResult = await native.attempt(
|
|
3206
5764
|
options,
|
|
3207
5765
|
config,
|
|
3208
5766
|
configDir,
|
|
@@ -3230,7 +5788,7 @@ async function runMacHunt(options, config, configDir, target) {
|
|
|
3230
5788
|
}
|
|
3231
5789
|
function recordHistory(configDir, outcome, maxRuns) {
|
|
3232
5790
|
try {
|
|
3233
|
-
const relativeRunDir =
|
|
5791
|
+
const relativeRunDir = path12.relative(configDir, outcome.runDir);
|
|
3234
5792
|
appendEntry(
|
|
3235
5793
|
configDir,
|
|
3236
5794
|
{
|
|
@@ -3351,8 +5909,8 @@ function clusterFailures(failures) {
|
|
|
3351
5909
|
}
|
|
3352
5910
|
|
|
3353
5911
|
// src/backlog/index.ts
|
|
3354
|
-
import
|
|
3355
|
-
import
|
|
5912
|
+
import fs13 from "fs";
|
|
5913
|
+
import path13 from "path";
|
|
3356
5914
|
|
|
3357
5915
|
// src/backlog/parse.ts
|
|
3358
5916
|
var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
|
|
@@ -3442,7 +6000,7 @@ ${after}`;
|
|
|
3442
6000
|
// src/backlog/index.ts
|
|
3443
6001
|
function readFileOrEmpty(filePath) {
|
|
3444
6002
|
try {
|
|
3445
|
-
return
|
|
6003
|
+
return fs13.readFileSync(filePath, "utf-8");
|
|
3446
6004
|
} catch (error) {
|
|
3447
6005
|
const err = error;
|
|
3448
6006
|
if (err.code === "ENOENT") return "";
|
|
@@ -3458,7 +6016,7 @@ function buildFailure(hunt) {
|
|
|
3458
6016
|
if (!hunt.runDir) return failure;
|
|
3459
6017
|
let run;
|
|
3460
6018
|
try {
|
|
3461
|
-
const resultJson = readFileOrEmpty(
|
|
6019
|
+
const resultJson = readFileOrEmpty(path13.join(hunt.runDir, "result.json"));
|
|
3462
6020
|
if (!resultJson) return failure;
|
|
3463
6021
|
run = JSON.parse(resultJson);
|
|
3464
6022
|
} catch (error) {
|
|
@@ -3487,8 +6045,8 @@ function extractFailures(suiteResult) {
|
|
|
3487
6045
|
}
|
|
3488
6046
|
function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
3489
6047
|
const projectRoot = options.projectRoot ?? process.cwd();
|
|
3490
|
-
const backlogPath = options.backlogPath ??
|
|
3491
|
-
const resolvedPath = options.resolvedPath ??
|
|
6048
|
+
const backlogPath = options.backlogPath ?? path13.join(projectRoot, "docs", "backlog.md");
|
|
6049
|
+
const resolvedPath = options.resolvedPath ?? path13.join(projectRoot, "docs", "resolved.md");
|
|
3492
6050
|
const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3493
6051
|
const summary = { created: [], regressions: [], skipped: [], backlogPath };
|
|
3494
6052
|
const failures = extractFailures(suiteResult);
|
|
@@ -3520,18 +6078,18 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
|
3520
6078
|
}
|
|
3521
6079
|
}
|
|
3522
6080
|
if (ticketsToAdd.length > 0) {
|
|
3523
|
-
|
|
3524
|
-
|
|
6081
|
+
fs13.mkdirSync(path13.dirname(backlogPath), { recursive: true });
|
|
6082
|
+
fs13.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
|
|
3525
6083
|
}
|
|
3526
6084
|
return summary;
|
|
3527
6085
|
}
|
|
3528
6086
|
|
|
3529
6087
|
// src/runner/suite.ts
|
|
3530
|
-
import
|
|
6088
|
+
import path15 from "path";
|
|
3531
6089
|
|
|
3532
6090
|
// src/reporter/ci-summary.ts
|
|
3533
|
-
import
|
|
3534
|
-
import
|
|
6091
|
+
import fs14 from "fs";
|
|
6092
|
+
import path14 from "path";
|
|
3535
6093
|
import chalk from "chalk";
|
|
3536
6094
|
function countCiResults(results) {
|
|
3537
6095
|
return {
|
|
@@ -3595,9 +6153,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
|
|
|
3595
6153
|
...flaky.length > 0 ? { flaky } : {},
|
|
3596
6154
|
...clusters.length > 0 ? { clusters } : {}
|
|
3597
6155
|
};
|
|
3598
|
-
|
|
3599
|
-
const filePath =
|
|
3600
|
-
|
|
6156
|
+
fs14.mkdirSync(ciRunDir, { recursive: true });
|
|
6157
|
+
const filePath = path14.join(ciRunDir, "ci-result.json");
|
|
6158
|
+
fs14.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
|
|
3601
6159
|
return filePath;
|
|
3602
6160
|
}
|
|
3603
6161
|
|
|
@@ -3782,7 +6340,7 @@ async function runSuite(options = {}) {
|
|
|
3782
6340
|
const clusters = clusterFailures(
|
|
3783
6341
|
extractFailures({ result: { hunts: results }, resultPath: null })
|
|
3784
6342
|
).filter((cluster) => cluster.count > 1);
|
|
3785
|
-
const ciRunDir =
|
|
6343
|
+
const ciRunDir = path15.join(configDir, "runs", timestamp("ci"));
|
|
3786
6344
|
const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
|
|
3787
6345
|
const { passed, failed, skipped } = countCiResults(results);
|
|
3788
6346
|
return {
|
|
@@ -3917,6 +6475,401 @@ async function analyzePage(page) {
|
|
|
3917
6475
|
};
|
|
3918
6476
|
}
|
|
3919
6477
|
|
|
6478
|
+
// src/analyzer/mac.ts
|
|
6479
|
+
var INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
|
|
6480
|
+
"AXButton",
|
|
6481
|
+
"AXTextField",
|
|
6482
|
+
"AXSecureTextField",
|
|
6483
|
+
"AXTextArea",
|
|
6484
|
+
"AXCheckBox",
|
|
6485
|
+
"AXRadioButton",
|
|
6486
|
+
"AXPopUpButton",
|
|
6487
|
+
"AXMenuButton",
|
|
6488
|
+
"AXLink",
|
|
6489
|
+
"AXMenuItem",
|
|
6490
|
+
"AXComboBox",
|
|
6491
|
+
"AXSlider",
|
|
6492
|
+
"AXDisclosureTriangle"
|
|
6493
|
+
]);
|
|
6494
|
+
var DEFAULT_ANALYZE_TREE_DEPTH = 20;
|
|
6495
|
+
function str(value) {
|
|
6496
|
+
if (typeof value !== "string") {
|
|
6497
|
+
return void 0;
|
|
6498
|
+
}
|
|
6499
|
+
const trimmed = value.trim();
|
|
6500
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
6501
|
+
}
|
|
6502
|
+
function toNode(raw) {
|
|
6503
|
+
const node = raw ?? {};
|
|
6504
|
+
const children = Array.isArray(node.children) ? node.children.map(toNode) : void 0;
|
|
6505
|
+
return {
|
|
6506
|
+
role: str(node.role),
|
|
6507
|
+
title: str(node.title),
|
|
6508
|
+
description: str(node.description),
|
|
6509
|
+
value: str(node.value),
|
|
6510
|
+
identifier: str(node.identifier),
|
|
6511
|
+
enabled: typeof node.enabled === "boolean" ? node.enabled : void 0,
|
|
6512
|
+
...children ? { children } : {}
|
|
6513
|
+
};
|
|
6514
|
+
}
|
|
6515
|
+
function quote(value) {
|
|
6516
|
+
return `"${value}"`;
|
|
6517
|
+
}
|
|
6518
|
+
function rankMacSelectors(node) {
|
|
6519
|
+
const selectors = [];
|
|
6520
|
+
const identifier = node.identifier;
|
|
6521
|
+
const exactLabel = node.title ?? node.description;
|
|
6522
|
+
const name = node.title ?? node.description ?? node.value;
|
|
6523
|
+
if (identifier) {
|
|
6524
|
+
selectors.push(`id=${identifier}`);
|
|
6525
|
+
}
|
|
6526
|
+
if (exactLabel) {
|
|
6527
|
+
selectors.push(`label=${quote(exactLabel)}`);
|
|
6528
|
+
}
|
|
6529
|
+
if (node.role && name) {
|
|
6530
|
+
selectors.push(`role=${node.role}[name=${quote(name)}]`);
|
|
6531
|
+
}
|
|
6532
|
+
if (name) {
|
|
6533
|
+
selectors.push(`text=${quote(name)}`);
|
|
6534
|
+
}
|
|
6535
|
+
if (selectors.length === 0 && node.role) {
|
|
6536
|
+
selectors.push(`role=${node.role}`);
|
|
6537
|
+
}
|
|
6538
|
+
return selectors;
|
|
6539
|
+
}
|
|
6540
|
+
function toElement(node, source) {
|
|
6541
|
+
return {
|
|
6542
|
+
role: node.role ?? "?",
|
|
6543
|
+
...node.title ? { title: node.title } : {},
|
|
6544
|
+
...node.description ? { description: node.description } : {},
|
|
6545
|
+
...node.value ? { value: node.value } : {},
|
|
6546
|
+
...node.identifier ? { identifier: node.identifier } : {},
|
|
6547
|
+
...node.enabled !== void 0 ? { enabled: node.enabled } : {},
|
|
6548
|
+
source,
|
|
6549
|
+
selectors: rankMacSelectors(node)
|
|
6550
|
+
};
|
|
6551
|
+
}
|
|
6552
|
+
function collectInteractive(root) {
|
|
6553
|
+
const out = [];
|
|
6554
|
+
const visit = (node) => {
|
|
6555
|
+
if (node.role && INTERACTIVE_ROLES.has(node.role)) {
|
|
6556
|
+
out.push(toElement(node, "window"));
|
|
6557
|
+
}
|
|
6558
|
+
for (const child of node.children ?? []) {
|
|
6559
|
+
visit(child);
|
|
6560
|
+
}
|
|
6561
|
+
};
|
|
6562
|
+
visit(root);
|
|
6563
|
+
return out;
|
|
6564
|
+
}
|
|
6565
|
+
function toWindow(node) {
|
|
6566
|
+
const [best] = rankMacSelectors(node);
|
|
6567
|
+
return {
|
|
6568
|
+
...node.title ? { title: node.title } : {},
|
|
6569
|
+
...node.identifier ? { identifier: node.identifier } : {},
|
|
6570
|
+
selector: best ?? "role=AXWindow"
|
|
6571
|
+
};
|
|
6572
|
+
}
|
|
6573
|
+
async function analyzeMacApp(client, options) {
|
|
6574
|
+
const depth = options.treeDepth ?? DEFAULT_ANALYZE_TREE_DEPTH;
|
|
6575
|
+
const treeResult = await client.request("tree", { depth });
|
|
6576
|
+
const elements = collectInteractive(toNode(treeResult.tree));
|
|
6577
|
+
const windowsResult = await client.request("windows");
|
|
6578
|
+
const rawWindows = Array.isArray(windowsResult.windows) ? windowsResult.windows : [];
|
|
6579
|
+
const windows = rawWindows.map((raw) => toWindow(toNode(raw)));
|
|
6580
|
+
const menuItems = await readStatusMenu(client, options.menuTimeoutSeconds);
|
|
6581
|
+
return { app: options.app, elements, windows, menuItems };
|
|
6582
|
+
}
|
|
6583
|
+
async function readStatusMenu(client, menuTimeoutSeconds) {
|
|
6584
|
+
let statusItems;
|
|
6585
|
+
try {
|
|
6586
|
+
const status = await client.request("statusItems");
|
|
6587
|
+
statusItems = Array.isArray(status.items) ? status.items : [];
|
|
6588
|
+
} catch {
|
|
6589
|
+
return [];
|
|
6590
|
+
}
|
|
6591
|
+
if (statusItems.length === 0) {
|
|
6592
|
+
return [];
|
|
6593
|
+
}
|
|
6594
|
+
const params = menuTimeoutSeconds !== void 0 ? { timeout: menuTimeoutSeconds } : {};
|
|
6595
|
+
try {
|
|
6596
|
+
const menu = await client.request("openMenu", params);
|
|
6597
|
+
const rawItems = Array.isArray(menu.items) ? menu.items : [];
|
|
6598
|
+
return rawItems.map((raw) => toNode(raw)).filter((node) => node.role !== "AXMenuItem" || node.title || node.identifier || node.description).map((node) => toElement(node, "menu"));
|
|
6599
|
+
} catch {
|
|
6600
|
+
return [];
|
|
6601
|
+
} finally {
|
|
6602
|
+
await client.request("closeMenu").catch(() => void 0);
|
|
6603
|
+
}
|
|
6604
|
+
}
|
|
6605
|
+
|
|
6606
|
+
// src/analyzer/android.ts
|
|
6607
|
+
var ANDROID_INTERACTIVE_CLASSES = /* @__PURE__ */ new Set([
|
|
6608
|
+
"android.widget.Button",
|
|
6609
|
+
"android.widget.ImageButton",
|
|
6610
|
+
"android.widget.EditText",
|
|
6611
|
+
"android.widget.CheckBox",
|
|
6612
|
+
"android.widget.RadioButton",
|
|
6613
|
+
"android.widget.Switch",
|
|
6614
|
+
"android.widget.ToggleButton",
|
|
6615
|
+
"android.widget.Spinner",
|
|
6616
|
+
"android.widget.SeekBar",
|
|
6617
|
+
"android.widget.RatingBar",
|
|
6618
|
+
"android.widget.CompoundButton",
|
|
6619
|
+
"android.widget.AutoCompleteTextView",
|
|
6620
|
+
"android.widget.MultiAutoCompleteTextView",
|
|
6621
|
+
"android.widget.CheckedTextView",
|
|
6622
|
+
"androidx.appcompat.widget.SwitchCompat",
|
|
6623
|
+
"androidx.appcompat.widget.AppCompatButton",
|
|
6624
|
+
"androidx.appcompat.widget.AppCompatEditText"
|
|
6625
|
+
]);
|
|
6626
|
+
function str2(value) {
|
|
6627
|
+
if (value === void 0) {
|
|
6628
|
+
return void 0;
|
|
6629
|
+
}
|
|
6630
|
+
const trimmed = value.trim();
|
|
6631
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
6632
|
+
}
|
|
6633
|
+
function bool(value) {
|
|
6634
|
+
if (value === void 0) {
|
|
6635
|
+
return void 0;
|
|
6636
|
+
}
|
|
6637
|
+
return value === "true";
|
|
6638
|
+
}
|
|
6639
|
+
function toAndroidNode(element) {
|
|
6640
|
+
const a = element.attrs;
|
|
6641
|
+
return {
|
|
6642
|
+
className: str2(a["class"]),
|
|
6643
|
+
resourceId: str2(a["resource-id"]),
|
|
6644
|
+
contentDesc: str2(a["content-desc"]),
|
|
6645
|
+
text: str2(a.text),
|
|
6646
|
+
package: str2(a.package),
|
|
6647
|
+
clickable: bool(a.clickable),
|
|
6648
|
+
longClickable: bool(a["long-clickable"]),
|
|
6649
|
+
checkable: bool(a.checkable),
|
|
6650
|
+
checked: bool(a.checked),
|
|
6651
|
+
scrollable: bool(a.scrollable),
|
|
6652
|
+
focusable: bool(a.focusable),
|
|
6653
|
+
focused: bool(a.focused),
|
|
6654
|
+
enabled: bool(a.enabled),
|
|
6655
|
+
children: element.children.map(toAndroidNode)
|
|
6656
|
+
};
|
|
6657
|
+
}
|
|
6658
|
+
function parseAndroidHierarchy(xml) {
|
|
6659
|
+
const root = parseXml(xml);
|
|
6660
|
+
return root ? toAndroidNode(root) : null;
|
|
6661
|
+
}
|
|
6662
|
+
function isAndroidInteractive(node) {
|
|
6663
|
+
if (node.clickable || node.longClickable || node.checkable || node.scrollable) {
|
|
6664
|
+
return true;
|
|
6665
|
+
}
|
|
6666
|
+
return node.className !== void 0 && ANDROID_INTERACTIVE_CLASSES.has(node.className);
|
|
6667
|
+
}
|
|
6668
|
+
function rankAndroidSelectors(node) {
|
|
6669
|
+
return rankNativeSelectors({
|
|
6670
|
+
id: node.resourceId,
|
|
6671
|
+
label: node.contentDesc,
|
|
6672
|
+
role: node.className,
|
|
6673
|
+
name: node.text
|
|
6674
|
+
});
|
|
6675
|
+
}
|
|
6676
|
+
function androidNodeToNative(node) {
|
|
6677
|
+
return {
|
|
6678
|
+
...node.resourceId !== void 0 ? { id: node.resourceId } : {},
|
|
6679
|
+
...node.contentDesc !== void 0 ? { label: node.contentDesc } : {},
|
|
6680
|
+
...node.className !== void 0 ? { role: node.className } : {},
|
|
6681
|
+
textValues: node.text !== void 0 ? [node.text] : [],
|
|
6682
|
+
...node.focused !== void 0 ? { focused: node.focused } : {}
|
|
6683
|
+
};
|
|
6684
|
+
}
|
|
6685
|
+
function matchAndroidSelector(xml, selector, options = {}) {
|
|
6686
|
+
const parsedSelector = parseNativeSelector(selector);
|
|
6687
|
+
const root = parseAndroidHierarchy(xml);
|
|
6688
|
+
if (!root) {
|
|
6689
|
+
return [];
|
|
6690
|
+
}
|
|
6691
|
+
return matchNativeTree(
|
|
6692
|
+
ANDROID_MATCH_DIALECT,
|
|
6693
|
+
parsedSelector,
|
|
6694
|
+
root,
|
|
6695
|
+
androidNodeToNative,
|
|
6696
|
+
(node) => node.children,
|
|
6697
|
+
options
|
|
6698
|
+
);
|
|
6699
|
+
}
|
|
6700
|
+
function toElement2(node) {
|
|
6701
|
+
return {
|
|
6702
|
+
className: node.className ?? "?",
|
|
6703
|
+
...node.resourceId ? { resourceId: node.resourceId } : {},
|
|
6704
|
+
...node.contentDesc ? { contentDesc: node.contentDesc } : {},
|
|
6705
|
+
...node.text ? { text: node.text } : {},
|
|
6706
|
+
...node.clickable !== void 0 ? { clickable: node.clickable } : {},
|
|
6707
|
+
...node.checkable !== void 0 ? { checkable: node.checkable } : {},
|
|
6708
|
+
...node.scrollable !== void 0 ? { scrollable: node.scrollable } : {},
|
|
6709
|
+
...node.enabled !== void 0 ? { enabled: node.enabled } : {},
|
|
6710
|
+
selectors: rankAndroidSelectors(node)
|
|
6711
|
+
};
|
|
6712
|
+
}
|
|
6713
|
+
function collectInteractive2(root) {
|
|
6714
|
+
const out = [];
|
|
6715
|
+
const visit = (node) => {
|
|
6716
|
+
if (isAndroidInteractive(node)) {
|
|
6717
|
+
out.push(toElement2(node));
|
|
6718
|
+
}
|
|
6719
|
+
for (const child of node.children) {
|
|
6720
|
+
visit(child);
|
|
6721
|
+
}
|
|
6722
|
+
};
|
|
6723
|
+
visit(root);
|
|
6724
|
+
return out;
|
|
6725
|
+
}
|
|
6726
|
+
async function analyzeAndroidApp(client, options) {
|
|
6727
|
+
const xml = await client.source();
|
|
6728
|
+
const root = parseAndroidHierarchy(xml);
|
|
6729
|
+
const elements = root ? collectInteractive2(root) : [];
|
|
6730
|
+
return { app: options.app, elements };
|
|
6731
|
+
}
|
|
6732
|
+
|
|
6733
|
+
// src/analyzer/ios.ts
|
|
6734
|
+
var IOS_INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
|
|
6735
|
+
"XCUIElementTypeButton",
|
|
6736
|
+
"XCUIElementTypeCell",
|
|
6737
|
+
"XCUIElementTypeTextField",
|
|
6738
|
+
"XCUIElementTypeSecureTextField",
|
|
6739
|
+
"XCUIElementTypeSearchField",
|
|
6740
|
+
"XCUIElementTypeSwitch",
|
|
6741
|
+
"XCUIElementTypeToggle",
|
|
6742
|
+
"XCUIElementTypeLink",
|
|
6743
|
+
"XCUIElementTypeMenuItem",
|
|
6744
|
+
"XCUIElementTypeSlider",
|
|
6745
|
+
"XCUIElementTypeStepper",
|
|
6746
|
+
"XCUIElementTypeTextView",
|
|
6747
|
+
"XCUIElementTypePickerWheel",
|
|
6748
|
+
"XCUIElementTypeTab",
|
|
6749
|
+
"XCUIElementTypeSegmentedControl",
|
|
6750
|
+
"XCUIElementTypeCheckBox",
|
|
6751
|
+
"XCUIElementTypeRadioButton",
|
|
6752
|
+
"XCUIElementTypeKey"
|
|
6753
|
+
]);
|
|
6754
|
+
var IOS_WINDOW_TYPE = "XCUIElementTypeWindow";
|
|
6755
|
+
function str3(value) {
|
|
6756
|
+
if (value === void 0) {
|
|
6757
|
+
return void 0;
|
|
6758
|
+
}
|
|
6759
|
+
const trimmed = value.trim();
|
|
6760
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
6761
|
+
}
|
|
6762
|
+
function bool2(value) {
|
|
6763
|
+
if (value === void 0) {
|
|
6764
|
+
return void 0;
|
|
6765
|
+
}
|
|
6766
|
+
return value === "true" || value === "1";
|
|
6767
|
+
}
|
|
6768
|
+
function toIosNode(element) {
|
|
6769
|
+
const a = element.attrs;
|
|
6770
|
+
return {
|
|
6771
|
+
type: str3(a.type) ?? str3(element.tag),
|
|
6772
|
+
name: str3(a.name),
|
|
6773
|
+
label: str3(a.label),
|
|
6774
|
+
value: str3(a.value),
|
|
6775
|
+
enabled: bool2(a.enabled),
|
|
6776
|
+
visible: bool2(a.visible),
|
|
6777
|
+
children: element.children.map(toIosNode)
|
|
6778
|
+
};
|
|
6779
|
+
}
|
|
6780
|
+
function parseIosHierarchy(xml) {
|
|
6781
|
+
const root = parseXml(xml);
|
|
6782
|
+
return root ? toIosNode(root) : null;
|
|
6783
|
+
}
|
|
6784
|
+
function hasAccessibilityId(node) {
|
|
6785
|
+
return node.name !== void 0 && node.name !== node.label;
|
|
6786
|
+
}
|
|
6787
|
+
function rankIosSelectors(node) {
|
|
6788
|
+
return rankNativeSelectors({
|
|
6789
|
+
id: hasAccessibilityId(node) ? node.name : void 0,
|
|
6790
|
+
label: node.label,
|
|
6791
|
+
role: node.type ? shortIosType(node.type) : void 0,
|
|
6792
|
+
name: node.label ?? node.value
|
|
6793
|
+
});
|
|
6794
|
+
}
|
|
6795
|
+
function iosNodeToNative(node) {
|
|
6796
|
+
const textValues = [];
|
|
6797
|
+
if (node.label !== void 0) {
|
|
6798
|
+
textValues.push(node.label);
|
|
6799
|
+
}
|
|
6800
|
+
if (node.value !== void 0) {
|
|
6801
|
+
textValues.push(node.value);
|
|
6802
|
+
}
|
|
6803
|
+
return {
|
|
6804
|
+
...node.name !== void 0 ? { id: node.name } : {},
|
|
6805
|
+
...node.label !== void 0 ? { label: node.label } : {},
|
|
6806
|
+
...node.type !== void 0 ? { role: node.type } : {},
|
|
6807
|
+
textValues
|
|
6808
|
+
};
|
|
6809
|
+
}
|
|
6810
|
+
function matchIosSelector(xml, selector) {
|
|
6811
|
+
const parsedSelector = parseNativeSelector(selector);
|
|
6812
|
+
const root = parseIosHierarchy(xml);
|
|
6813
|
+
if (!root) {
|
|
6814
|
+
return [];
|
|
6815
|
+
}
|
|
6816
|
+
return matchNativeTree(
|
|
6817
|
+
IOS_MATCH_DIALECT,
|
|
6818
|
+
parsedSelector,
|
|
6819
|
+
root,
|
|
6820
|
+
iosNodeToNative,
|
|
6821
|
+
(node) => node.children
|
|
6822
|
+
);
|
|
6823
|
+
}
|
|
6824
|
+
function toElement3(node) {
|
|
6825
|
+
return {
|
|
6826
|
+
type: node.type ?? "?",
|
|
6827
|
+
...node.name ? { name: node.name } : {},
|
|
6828
|
+
...node.label ? { label: node.label } : {},
|
|
6829
|
+
...node.value ? { value: node.value } : {},
|
|
6830
|
+
...node.enabled !== void 0 ? { enabled: node.enabled } : {},
|
|
6831
|
+
...node.visible !== void 0 ? { visible: node.visible } : {},
|
|
6832
|
+
selectors: rankIosSelectors(node)
|
|
6833
|
+
};
|
|
6834
|
+
}
|
|
6835
|
+
function toWindow2(node) {
|
|
6836
|
+
const [best] = rankIosSelectors(node);
|
|
6837
|
+
return {
|
|
6838
|
+
...node.name ? { name: node.name } : {},
|
|
6839
|
+
...node.label ? { label: node.label } : {},
|
|
6840
|
+
selector: best ?? `role=${shortIosType(IOS_WINDOW_TYPE)}`
|
|
6841
|
+
};
|
|
6842
|
+
}
|
|
6843
|
+
function isIosInteractive(node) {
|
|
6844
|
+
return node.type !== void 0 && IOS_INTERACTIVE_TYPES.has(node.type);
|
|
6845
|
+
}
|
|
6846
|
+
function collect(root) {
|
|
6847
|
+
const elements = [];
|
|
6848
|
+
const windows = [];
|
|
6849
|
+
const visit = (node) => {
|
|
6850
|
+
if (node.type === IOS_WINDOW_TYPE) {
|
|
6851
|
+
windows.push(toWindow2(node));
|
|
6852
|
+
}
|
|
6853
|
+
if (isIosInteractive(node)) {
|
|
6854
|
+
elements.push(toElement3(node));
|
|
6855
|
+
}
|
|
6856
|
+
for (const child of node.children) {
|
|
6857
|
+
visit(child);
|
|
6858
|
+
}
|
|
6859
|
+
};
|
|
6860
|
+
visit(root);
|
|
6861
|
+
return { elements, windows };
|
|
6862
|
+
}
|
|
6863
|
+
async function analyzeIosApp(client, options) {
|
|
6864
|
+
const xml = await client.source();
|
|
6865
|
+
const root = parseIosHierarchy(xml);
|
|
6866
|
+
if (!root) {
|
|
6867
|
+
return { app: options.app, elements: [], windows: [] };
|
|
6868
|
+
}
|
|
6869
|
+
const { elements, windows } = collect(root);
|
|
6870
|
+
return { app: options.app, elements, windows };
|
|
6871
|
+
}
|
|
6872
|
+
|
|
3920
6873
|
// src/generator/index.ts
|
|
3921
6874
|
import yaml from "yaml";
|
|
3922
6875
|
|
|
@@ -3977,6 +6930,7 @@ var STEP_REFERENCE = `
|
|
|
3977
6930
|
|
|
3978
6931
|
### Visual Regression
|
|
3979
6932
|
- assertScreenshot: { name: "baseline-name", threshold: 0.1 }
|
|
6933
|
+
- assertWithAI: "The login form should show email and password fields" \u2014 AI checks the screenshot against the claim
|
|
3980
6934
|
|
|
3981
6935
|
### Control Flow
|
|
3982
6936
|
- if: { visible: ".banner", then: [steps...] }
|
|
@@ -4018,81 +6972,6 @@ function extractYamlFromResponse(response) {
|
|
|
4018
6972
|
return response.trim();
|
|
4019
6973
|
}
|
|
4020
6974
|
|
|
4021
|
-
// src/generator/ai.ts
|
|
4022
|
-
function resolveAiConfig() {
|
|
4023
|
-
const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
|
|
4024
|
-
if (provider !== "anthropic" && provider !== "openai") {
|
|
4025
|
-
throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
|
|
4026
|
-
}
|
|
4027
|
-
const apiKey = process.env.PROWL_AI_KEY;
|
|
4028
|
-
if (!apiKey) {
|
|
4029
|
-
throw new Error(
|
|
4030
|
-
"PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
|
|
4031
|
-
);
|
|
4032
|
-
}
|
|
4033
|
-
const defaultModel = provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
|
|
4034
|
-
const model = process.env.PROWL_AI_MODEL ?? defaultModel;
|
|
4035
|
-
return { provider, model, apiKey };
|
|
4036
|
-
}
|
|
4037
|
-
async function generateWithAi(prompt, config) {
|
|
4038
|
-
if (config.provider === "anthropic") {
|
|
4039
|
-
return generateWithAnthropic(prompt, config);
|
|
4040
|
-
}
|
|
4041
|
-
return generateWithOpenAi(prompt, config);
|
|
4042
|
-
}
|
|
4043
|
-
async function generateWithAnthropic(prompt, config) {
|
|
4044
|
-
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
4045
|
-
method: "POST",
|
|
4046
|
-
headers: {
|
|
4047
|
-
"Content-Type": "application/json",
|
|
4048
|
-
"x-api-key": config.apiKey,
|
|
4049
|
-
"anthropic-version": "2023-06-01"
|
|
4050
|
-
},
|
|
4051
|
-
body: JSON.stringify({
|
|
4052
|
-
model: config.model,
|
|
4053
|
-
max_tokens: 4096,
|
|
4054
|
-
messages: [
|
|
4055
|
-
{ role: "user", content: prompt }
|
|
4056
|
-
]
|
|
4057
|
-
})
|
|
4058
|
-
});
|
|
4059
|
-
if (!response.ok) {
|
|
4060
|
-
const body = await response.text();
|
|
4061
|
-
throw new Error(`Anthropic API error (${response.status}): ${body}`);
|
|
4062
|
-
}
|
|
4063
|
-
const data = await response.json();
|
|
4064
|
-
const textBlock = data.content.find((c) => c.type === "text");
|
|
4065
|
-
if (!textBlock?.text) {
|
|
4066
|
-
throw new Error("Anthropic API returned no text content");
|
|
4067
|
-
}
|
|
4068
|
-
return textBlock.text;
|
|
4069
|
-
}
|
|
4070
|
-
async function generateWithOpenAi(prompt, config) {
|
|
4071
|
-
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
4072
|
-
method: "POST",
|
|
4073
|
-
headers: {
|
|
4074
|
-
"Content-Type": "application/json",
|
|
4075
|
-
"Authorization": `Bearer ${config.apiKey}`
|
|
4076
|
-
},
|
|
4077
|
-
body: JSON.stringify({
|
|
4078
|
-
model: config.model,
|
|
4079
|
-
messages: [
|
|
4080
|
-
{ role: "user", content: prompt }
|
|
4081
|
-
],
|
|
4082
|
-
max_tokens: 4096
|
|
4083
|
-
})
|
|
4084
|
-
});
|
|
4085
|
-
if (!response.ok) {
|
|
4086
|
-
const body = await response.text();
|
|
4087
|
-
throw new Error(`OpenAI API error (${response.status}): ${body}`);
|
|
4088
|
-
}
|
|
4089
|
-
const data = await response.json();
|
|
4090
|
-
if (!data.choices?.[0]?.message?.content) {
|
|
4091
|
-
throw new Error("OpenAI API returned no content");
|
|
4092
|
-
}
|
|
4093
|
-
return data.choices[0].message.content;
|
|
4094
|
-
}
|
|
4095
|
-
|
|
4096
6975
|
// src/generator/index.ts
|
|
4097
6976
|
function parseViewportFlag2(value) {
|
|
4098
6977
|
const match = /^(\d+)x(\d+)$/i.exec(value);
|
|
@@ -4141,7 +7020,12 @@ export {
|
|
|
4141
7020
|
WEB_ONLY_STEP_TYPES,
|
|
4142
7021
|
webOnlyReason,
|
|
4143
7022
|
assertStepsSupportedByTarget,
|
|
7023
|
+
readIosBundleIdentifier,
|
|
7024
|
+
iosAppAllowedIdentities,
|
|
4144
7025
|
assertTargetAppAllowed,
|
|
7026
|
+
assertIosAppAllowed,
|
|
7027
|
+
androidAppAllowedIdentities,
|
|
7028
|
+
assertAndroidAppAllowed,
|
|
4145
7029
|
launchBrowser,
|
|
4146
7030
|
closeBrowser,
|
|
4147
7031
|
saveStorageState,
|
|
@@ -4154,6 +7038,78 @@ export {
|
|
|
4154
7038
|
SpawnMacHelperClient,
|
|
4155
7039
|
launchMacSession,
|
|
4156
7040
|
closeMacSession,
|
|
7041
|
+
decodeXmlEntities,
|
|
7042
|
+
parseXml,
|
|
7043
|
+
unquoteSelectorValue,
|
|
7044
|
+
quoteSelectorValue,
|
|
7045
|
+
parseNativeSelector,
|
|
7046
|
+
unwrapNativeTextSelector,
|
|
7047
|
+
qualifyResourceId,
|
|
7048
|
+
normalizeXcuiClassName,
|
|
7049
|
+
shortIosType,
|
|
7050
|
+
rankNativeSelectors,
|
|
7051
|
+
NATIVE_ATTRIBUTE_MAP,
|
|
7052
|
+
ANDROID_MATCH_DIALECT,
|
|
7053
|
+
IOS_MATCH_DIALECT,
|
|
7054
|
+
MACOS_MATCH_DIALECT,
|
|
7055
|
+
nodeMatchesSelector,
|
|
7056
|
+
matchNativeTree,
|
|
7057
|
+
parseSnapshot,
|
|
7058
|
+
ANDROID_KEYCODES,
|
|
7059
|
+
escapeUiSelectorArg,
|
|
7060
|
+
parseAndroidSelector,
|
|
7061
|
+
androidQueryToLocator,
|
|
7062
|
+
unwrapAndroidTextSelector,
|
|
7063
|
+
createAndroidDriver,
|
|
7064
|
+
parseAdbDevices,
|
|
7065
|
+
bootedDevices,
|
|
7066
|
+
selectDeviceSerial,
|
|
7067
|
+
listDevices,
|
|
7068
|
+
parseForwardPort,
|
|
7069
|
+
parseAaptPackage,
|
|
7070
|
+
DEFAULT_AGENT_REQUEST_TIMEOUT_MS,
|
|
7071
|
+
Uia2HttpError,
|
|
7072
|
+
Uia2Transport,
|
|
7073
|
+
extractElementId,
|
|
7074
|
+
createUia2Session,
|
|
7075
|
+
waitForAgentReady,
|
|
7076
|
+
createUia2AgentClient,
|
|
7077
|
+
UIA2_REMOTE_PORT,
|
|
7078
|
+
resolveAgentApks,
|
|
7079
|
+
defaultAgentConnector,
|
|
7080
|
+
launchAndroidSession,
|
|
7081
|
+
closeAndroidSession,
|
|
7082
|
+
IOS_PRESS_KEYS,
|
|
7083
|
+
escapePredicateArg,
|
|
7084
|
+
parseIosSelector,
|
|
7085
|
+
iosQueryToLocator,
|
|
7086
|
+
unwrapIosTextSelector,
|
|
7087
|
+
createIosDriver,
|
|
7088
|
+
DEFAULT_SIMULATOR_LOCK_ROOT,
|
|
7089
|
+
reserveSimulatorUdid,
|
|
7090
|
+
findFreePort,
|
|
7091
|
+
parseSimctlDevices,
|
|
7092
|
+
bootedSimulators,
|
|
7093
|
+
selectSimulatorUdid,
|
|
7094
|
+
listSimulators,
|
|
7095
|
+
parseXcodeVersion,
|
|
7096
|
+
DEFAULT_WDA_REQUEST_TIMEOUT_MS,
|
|
7097
|
+
WdaHttpError,
|
|
7098
|
+
WdaTransport,
|
|
7099
|
+
createWdaSession,
|
|
7100
|
+
waitForWdaReady,
|
|
7101
|
+
createWdaAgentClient,
|
|
7102
|
+
WDA_RUNNER_BUNDLE_ID,
|
|
7103
|
+
WDA_USE_PORT_ENV,
|
|
7104
|
+
defaultIosAgentConnector,
|
|
7105
|
+
resolveWdaProject,
|
|
7106
|
+
wdaCacheDir,
|
|
7107
|
+
resolveWdaTestRun,
|
|
7108
|
+
injectUsePortIntoXctestrun,
|
|
7109
|
+
defaultWdaTestRunPreparer,
|
|
7110
|
+
wdaTestRunArgs,
|
|
7111
|
+
launchIosSession,
|
|
7112
|
+
closeIosSession,
|
|
4157
7113
|
extractSelectorIntent,
|
|
4158
7114
|
buildHealCandidates,
|
|
4159
7115
|
healSelector,
|
|
@@ -4170,6 +7126,25 @@ export {
|
|
|
4170
7126
|
runSuite,
|
|
4171
7127
|
parseBrowserEngine,
|
|
4172
7128
|
analyzePage,
|
|
7129
|
+
INTERACTIVE_ROLES,
|
|
7130
|
+
DEFAULT_ANALYZE_TREE_DEPTH,
|
|
7131
|
+
rankMacSelectors,
|
|
7132
|
+
analyzeMacApp,
|
|
7133
|
+
ANDROID_INTERACTIVE_CLASSES,
|
|
7134
|
+
parseAndroidHierarchy,
|
|
7135
|
+
isAndroidInteractive,
|
|
7136
|
+
rankAndroidSelectors,
|
|
7137
|
+
androidNodeToNative,
|
|
7138
|
+
matchAndroidSelector,
|
|
7139
|
+
analyzeAndroidApp,
|
|
7140
|
+
IOS_INTERACTIVE_TYPES,
|
|
7141
|
+
IOS_WINDOW_TYPE,
|
|
7142
|
+
parseIosHierarchy,
|
|
7143
|
+
rankIosSelectors,
|
|
7144
|
+
iosNodeToNative,
|
|
7145
|
+
matchIosSelector,
|
|
7146
|
+
isIosInteractive,
|
|
7147
|
+
analyzeIosApp,
|
|
4173
7148
|
generateHunt
|
|
4174
7149
|
};
|
|
4175
|
-
//# sourceMappingURL=chunk-
|
|
7150
|
+
//# sourceMappingURL=chunk-5KQR3IR3.js.map
|