prowl-tools 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +578 -185
- package/dist/{chunk-ITOSUJCN.js → chunk-O3OUTZ2P.js} +22 -2
- package/dist/chunk-O3OUTZ2P.js.map +1 -0
- package/dist/{chunk-2KD2XCTH.js → chunk-R7NUH44M.js} +1690 -298
- package/dist/chunk-R7NUH44M.js.map +1 -0
- package/dist/index.cjs +1839 -365
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +277 -19
- package/dist/index.js.map +1 -1
- package/dist/lib.cjs +1864 -403
- package/dist/lib.cjs.map +1 -1
- package/dist/lib.d.cts +702 -58
- package/dist/lib.d.ts +702 -58
- package/dist/lib.js +130 -8
- package/dist/{loader-FCXPARP7.js → loader-X37URHUV.js} +2 -2
- package/examples/hunts/hello.yml +1 -1
- package/examples/hunts/login-flow.yml +58 -0
- package/package.json +6 -3
- package/dist/chunk-2KD2XCTH.js.map +0 -1
- package/dist/chunk-ITOSUJCN.js.map +0 -1
- /package/dist/{loader-FCXPARP7.js.map → loader-X37URHUV.js.map} +0 -0
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
loadHunt,
|
|
8
8
|
loadHuntTags,
|
|
9
9
|
resolveViewport
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-O3OUTZ2P.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: {
|
|
@@ -752,27 +757,71 @@ function createMacDriver(client, options = {}) {
|
|
|
752
757
|
};
|
|
753
758
|
}
|
|
754
759
|
|
|
760
|
+
// src/browser/macdriver-release.ts
|
|
761
|
+
import os from "os";
|
|
762
|
+
import path2 from "path";
|
|
763
|
+
var HELPER_BINARY = "prowl-macdriver";
|
|
764
|
+
var MACDRIVER_VERSION = "0.1.0";
|
|
765
|
+
var MACDRIVER_REPO = "prowl-tools/prowl";
|
|
766
|
+
var MACDRIVER_SIGNING_IDENTIFIER = "tools.prowl.macdriver";
|
|
767
|
+
var MACDRIVER_SIGNING_AUTHORITY_PREFIX = "Developer ID Application: Genkei Labs";
|
|
768
|
+
var MACDRIVER_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?$/;
|
|
769
|
+
function validateMacdriverVersion(version) {
|
|
770
|
+
if (!MACDRIVER_VERSION_PATTERN.test(version)) {
|
|
771
|
+
throw new Error(
|
|
772
|
+
`Invalid prowl-macdriver version "${version}". Expected a release version like 0.1.0.`
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
return version;
|
|
776
|
+
}
|
|
777
|
+
function macdriverReleaseTag(version = MACDRIVER_VERSION) {
|
|
778
|
+
return `macdriver-v${validateMacdriverVersion(version)}`;
|
|
779
|
+
}
|
|
780
|
+
function macdriverAssetName(version = MACDRIVER_VERSION) {
|
|
781
|
+
return `prowl-macdriver-v${validateMacdriverVersion(version)}-universal.zip`;
|
|
782
|
+
}
|
|
783
|
+
function macdriverChecksumName(version = MACDRIVER_VERSION) {
|
|
784
|
+
return `${macdriverAssetName(version)}.sha256`;
|
|
785
|
+
}
|
|
786
|
+
function macdriverAssetUrl(assetName, version = MACDRIVER_VERSION) {
|
|
787
|
+
return `https://github.com/${MACDRIVER_REPO}/releases/download/${macdriverReleaseTag(version)}/${assetName}`;
|
|
788
|
+
}
|
|
789
|
+
function macdriverInstallRoot(homedir = os.homedir()) {
|
|
790
|
+
return path2.join(homedir, ".prowl", "macdriver");
|
|
791
|
+
}
|
|
792
|
+
function macdriverVersionDir(version = MACDRIVER_VERSION, homedir = os.homedir()) {
|
|
793
|
+
const root = path2.resolve(macdriverInstallRoot(homedir));
|
|
794
|
+
const versionDir = path2.resolve(root, validateMacdriverVersion(version));
|
|
795
|
+
if (!versionDir.startsWith(root + path2.sep)) {
|
|
796
|
+
throw new Error(`Resolved prowl-macdriver version directory escaped install root: ${versionDir}`);
|
|
797
|
+
}
|
|
798
|
+
return versionDir;
|
|
799
|
+
}
|
|
800
|
+
function macdriverInstalledBinary(version = MACDRIVER_VERSION, homedir = os.homedir()) {
|
|
801
|
+
return path2.join(macdriverVersionDir(version, homedir), HELPER_BINARY);
|
|
802
|
+
}
|
|
803
|
+
|
|
755
804
|
// src/browser/mac-helper.ts
|
|
756
805
|
import { spawn } from "child_process";
|
|
757
806
|
import fs2 from "fs";
|
|
758
|
-
import
|
|
807
|
+
import os2 from "os";
|
|
808
|
+
import path3 from "path";
|
|
759
809
|
import { fileURLToPath } from "url";
|
|
760
|
-
var HELPER_BINARY = "prowl-macdriver";
|
|
761
810
|
function macdriverBuildInstructions() {
|
|
762
|
-
return "The macOS target
|
|
811
|
+
return "The macOS target needs the `prowl-macdriver` helper. Install the prebuilt, signed binary (recommended):\n prowl macdriver install\nContributors building from source can instead run:\n cd macdriver && swift build -c release\nor point Prowl at a prebuilt binary via the PROWL_MACDRIVER_BIN environment variable.";
|
|
763
812
|
}
|
|
764
813
|
function getPackageRoot() {
|
|
765
|
-
let dir =
|
|
766
|
-
const root =
|
|
814
|
+
let dir = path3.dirname(fileURLToPath(import.meta.url));
|
|
815
|
+
const root = path3.parse(dir).root;
|
|
767
816
|
while (dir !== root) {
|
|
768
|
-
if (fs2.existsSync(
|
|
817
|
+
if (fs2.existsSync(path3.join(dir, "package.json"))) {
|
|
769
818
|
return dir;
|
|
770
819
|
}
|
|
771
|
-
dir =
|
|
820
|
+
dir = path3.dirname(dir);
|
|
772
821
|
}
|
|
773
822
|
return root;
|
|
774
823
|
}
|
|
775
|
-
function resolveHelperBinary(env = process.env) {
|
|
824
|
+
function resolveHelperBinary(env = process.env, options = {}) {
|
|
776
825
|
const override = env.PROWL_MACDRIVER_BIN;
|
|
777
826
|
if (override) {
|
|
778
827
|
if (!fs2.existsSync(override)) {
|
|
@@ -783,10 +832,15 @@ ${macdriverBuildInstructions()}`
|
|
|
783
832
|
}
|
|
784
833
|
return override;
|
|
785
834
|
}
|
|
835
|
+
const homedir = options.homedir ?? os2.homedir();
|
|
836
|
+
const userBinary = macdriverInstalledBinary(MACDRIVER_VERSION, homedir);
|
|
837
|
+
if (fs2.existsSync(userBinary)) {
|
|
838
|
+
return userBinary;
|
|
839
|
+
}
|
|
786
840
|
const root = getPackageRoot();
|
|
787
841
|
const candidates = [
|
|
788
|
-
|
|
789
|
-
|
|
842
|
+
path3.join(root, "macdriver", ".build", "release", HELPER_BINARY),
|
|
843
|
+
path3.join(root, "macdriver", ".build", "debug", HELPER_BINARY)
|
|
790
844
|
];
|
|
791
845
|
for (const candidate of candidates) {
|
|
792
846
|
if (fs2.existsSync(candidate)) {
|
|
@@ -958,6 +1012,287 @@ async function closeMacSession(session) {
|
|
|
958
1012
|
}
|
|
959
1013
|
}
|
|
960
1014
|
|
|
1015
|
+
// src/analyzer/xml.ts
|
|
1016
|
+
var ENTITIES = {
|
|
1017
|
+
amp: "&",
|
|
1018
|
+
lt: "<",
|
|
1019
|
+
gt: ">",
|
|
1020
|
+
quot: '"',
|
|
1021
|
+
apos: "'"
|
|
1022
|
+
};
|
|
1023
|
+
function decodeXmlEntities(value) {
|
|
1024
|
+
if (!value.includes("&")) {
|
|
1025
|
+
return value;
|
|
1026
|
+
}
|
|
1027
|
+
return value.replace(/&(#(?:[xX][0-9a-fA-F]+|[0-9]+)|[a-zA-Z]+);/g, (match, code) => {
|
|
1028
|
+
if (code[0] === "#") {
|
|
1029
|
+
const hex = code[1] === "x" || code[1] === "X";
|
|
1030
|
+
const num2 = Number.parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10);
|
|
1031
|
+
return Number.isSafeInteger(num2) && num2 <= 1114111 ? String.fromCodePoint(num2) : match;
|
|
1032
|
+
}
|
|
1033
|
+
const named = ENTITIES[code];
|
|
1034
|
+
return named ?? match;
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
var ATTR_RE = /([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
|
1038
|
+
function parseTagBody(body) {
|
|
1039
|
+
const trimmed = body.trim();
|
|
1040
|
+
const nameMatch = /^([^\s/>]+)/.exec(trimmed);
|
|
1041
|
+
const tag = nameMatch ? nameMatch[1] : "";
|
|
1042
|
+
const attrs = {};
|
|
1043
|
+
const rest = trimmed.slice(tag.length);
|
|
1044
|
+
ATTR_RE.lastIndex = 0;
|
|
1045
|
+
let m;
|
|
1046
|
+
while ((m = ATTR_RE.exec(rest)) !== null) {
|
|
1047
|
+
const rawValue = m[3] !== void 0 ? m[3] : m[4] ?? "";
|
|
1048
|
+
attrs[m[1]] = decodeXmlEntities(rawValue);
|
|
1049
|
+
}
|
|
1050
|
+
return { tag, attrs };
|
|
1051
|
+
}
|
|
1052
|
+
function parseXml(input) {
|
|
1053
|
+
const n = input.length;
|
|
1054
|
+
const stack = [];
|
|
1055
|
+
let root = null;
|
|
1056
|
+
let i = 0;
|
|
1057
|
+
while (i < n) {
|
|
1058
|
+
const lt = input.indexOf("<", i);
|
|
1059
|
+
if (lt < 0) {
|
|
1060
|
+
break;
|
|
1061
|
+
}
|
|
1062
|
+
i = lt + 1;
|
|
1063
|
+
const ch = input[i];
|
|
1064
|
+
if (ch === "?") {
|
|
1065
|
+
const end = input.indexOf("?>", i);
|
|
1066
|
+
i = end < 0 ? n : end + 2;
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
if (ch === "!") {
|
|
1070
|
+
if (input.startsWith("!--", i)) {
|
|
1071
|
+
const end = input.indexOf("-->", i);
|
|
1072
|
+
i = end < 0 ? n : end + 3;
|
|
1073
|
+
} else {
|
|
1074
|
+
const end = input.indexOf(">", i);
|
|
1075
|
+
i = end < 0 ? n : end + 1;
|
|
1076
|
+
}
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
if (ch === "/") {
|
|
1080
|
+
const gt = input.indexOf(">", i);
|
|
1081
|
+
i = gt < 0 ? n : gt + 1;
|
|
1082
|
+
stack.pop();
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
let j = i;
|
|
1086
|
+
let quote2 = null;
|
|
1087
|
+
while (j < n) {
|
|
1088
|
+
const c = input[j];
|
|
1089
|
+
if (quote2 !== null) {
|
|
1090
|
+
if (c === quote2) {
|
|
1091
|
+
quote2 = null;
|
|
1092
|
+
}
|
|
1093
|
+
} else if (c === '"' || c === "'") {
|
|
1094
|
+
quote2 = c;
|
|
1095
|
+
} else if (c === ">") {
|
|
1096
|
+
break;
|
|
1097
|
+
}
|
|
1098
|
+
j += 1;
|
|
1099
|
+
}
|
|
1100
|
+
const inner = input.slice(i, j);
|
|
1101
|
+
i = j + 1;
|
|
1102
|
+
const selfClose = inner.endsWith("/");
|
|
1103
|
+
const body = selfClose ? inner.slice(0, -1) : inner;
|
|
1104
|
+
const { tag, attrs } = parseTagBody(body);
|
|
1105
|
+
if (tag.length === 0) {
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
const element = { tag, attrs, children: [] };
|
|
1109
|
+
const parent = stack[stack.length - 1];
|
|
1110
|
+
if (parent) {
|
|
1111
|
+
parent.children.push(element);
|
|
1112
|
+
}
|
|
1113
|
+
if (root === null) {
|
|
1114
|
+
root = element;
|
|
1115
|
+
}
|
|
1116
|
+
if (!selfClose) {
|
|
1117
|
+
stack.push(element);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
return root;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// src/selector/native.ts
|
|
1124
|
+
function unquoteSelectorValue(value) {
|
|
1125
|
+
const trimmed = value.trim();
|
|
1126
|
+
const first = trimmed[0];
|
|
1127
|
+
if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
|
|
1128
|
+
return trimmed.slice(1, -1);
|
|
1129
|
+
}
|
|
1130
|
+
return trimmed;
|
|
1131
|
+
}
|
|
1132
|
+
function quoteSelectorValue(value) {
|
|
1133
|
+
return `"${value}"`;
|
|
1134
|
+
}
|
|
1135
|
+
var ROLE_SELECTOR_RE = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s;
|
|
1136
|
+
var NATIVE_SELECTOR_PREFIX_RE = /^(id|label|text|role)=/;
|
|
1137
|
+
function invalidSelectorMessage(selector, reason) {
|
|
1138
|
+
return `Invalid native selector ${JSON.stringify(selector)}: ${reason}.`;
|
|
1139
|
+
}
|
|
1140
|
+
function parseNativeSelector(selector) {
|
|
1141
|
+
const trimmed = selector.trim();
|
|
1142
|
+
if (trimmed.length === 0) {
|
|
1143
|
+
throw new Error(
|
|
1144
|
+
invalidSelectorMessage(
|
|
1145
|
+
selector,
|
|
1146
|
+
"selector is empty; use id=, label=, text=, role=, :focus, or a bare text value"
|
|
1147
|
+
)
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
if (trimmed === ":focus") {
|
|
1151
|
+
return { kind: "focused" };
|
|
1152
|
+
}
|
|
1153
|
+
const idMatch = /^id=(.+)$/s.exec(trimmed);
|
|
1154
|
+
if (idMatch) {
|
|
1155
|
+
return { kind: "id", value: unquoteSelectorValue(idMatch[1]) };
|
|
1156
|
+
}
|
|
1157
|
+
const roleMatch = ROLE_SELECTOR_RE.exec(trimmed);
|
|
1158
|
+
if (roleMatch) {
|
|
1159
|
+
const name = roleMatch[2] !== void 0 ? unquoteSelectorValue(roleMatch[2]) : void 0;
|
|
1160
|
+
return name !== void 0 && name.length > 0 ? { kind: "role", role: roleMatch[1], name } : { kind: "role", role: roleMatch[1] };
|
|
1161
|
+
}
|
|
1162
|
+
const labelMatch = /^label=(.+)$/s.exec(trimmed);
|
|
1163
|
+
if (labelMatch) {
|
|
1164
|
+
return { kind: "label", value: unquoteSelectorValue(labelMatch[1]) };
|
|
1165
|
+
}
|
|
1166
|
+
const textMatch = /^text=(.+)$/s.exec(trimmed);
|
|
1167
|
+
if (textMatch) {
|
|
1168
|
+
return { kind: "text", value: unquoteSelectorValue(textMatch[1]) };
|
|
1169
|
+
}
|
|
1170
|
+
const prefix = NATIVE_SELECTOR_PREFIX_RE.exec(trimmed);
|
|
1171
|
+
if (prefix) {
|
|
1172
|
+
throw new Error(
|
|
1173
|
+
invalidSelectorMessage(
|
|
1174
|
+
selector,
|
|
1175
|
+
`malformed ${prefix[1]}= selector; expected id=<value>, label=<value>, text=<value>, or role=<Type>[name=<value>]`
|
|
1176
|
+
)
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
return { kind: "text", value: trimmed };
|
|
1180
|
+
}
|
|
1181
|
+
function unwrapNativeTextSelector(selector) {
|
|
1182
|
+
const trimmed = selector.trim();
|
|
1183
|
+
if (!trimmed.startsWith("text=")) {
|
|
1184
|
+
return null;
|
|
1185
|
+
}
|
|
1186
|
+
return unquoteSelectorValue(trimmed.slice("text=".length));
|
|
1187
|
+
}
|
|
1188
|
+
function qualifyResourceId(value, appPackage) {
|
|
1189
|
+
if (value.includes(":") || !appPackage) {
|
|
1190
|
+
return value;
|
|
1191
|
+
}
|
|
1192
|
+
return `${appPackage}:id/${value}`;
|
|
1193
|
+
}
|
|
1194
|
+
function normalizeXcuiClassName(role) {
|
|
1195
|
+
return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
|
|
1196
|
+
}
|
|
1197
|
+
function shortIosType(type) {
|
|
1198
|
+
return type.startsWith("XCUIElementType") ? type.slice("XCUIElementType".length) : type;
|
|
1199
|
+
}
|
|
1200
|
+
function rankNativeSelectors(fields) {
|
|
1201
|
+
const selectors = [];
|
|
1202
|
+
if (fields.id) {
|
|
1203
|
+
selectors.push(`id=${fields.id}`);
|
|
1204
|
+
}
|
|
1205
|
+
if (fields.label) {
|
|
1206
|
+
selectors.push(`label=${quoteSelectorValue(fields.label)}`);
|
|
1207
|
+
}
|
|
1208
|
+
if (fields.role && fields.name) {
|
|
1209
|
+
selectors.push(`role=${fields.role}[name=${quoteSelectorValue(fields.name)}]`);
|
|
1210
|
+
}
|
|
1211
|
+
if (fields.name) {
|
|
1212
|
+
selectors.push(`text=${quoteSelectorValue(fields.name)}`);
|
|
1213
|
+
}
|
|
1214
|
+
if (selectors.length === 0 && fields.role) {
|
|
1215
|
+
selectors.push(`role=${fields.role}`);
|
|
1216
|
+
}
|
|
1217
|
+
return selectors;
|
|
1218
|
+
}
|
|
1219
|
+
var NATIVE_ATTRIBUTE_MAP = {
|
|
1220
|
+
android: {
|
|
1221
|
+
id: { attribute: "resource-id", match: "exact (package-qualified)" },
|
|
1222
|
+
label: { attribute: "content-desc", match: "exact" },
|
|
1223
|
+
text: { attribute: "text", match: "substring" },
|
|
1224
|
+
role: { attribute: "class", match: "exact" }
|
|
1225
|
+
},
|
|
1226
|
+
ios: {
|
|
1227
|
+
id: { attribute: "accessibility id (name)", match: "exact" },
|
|
1228
|
+
label: { attribute: "label", match: "exact" },
|
|
1229
|
+
text: { attribute: "label | value", match: "substring" },
|
|
1230
|
+
role: { attribute: "type (XCUIElementType\u2026)", match: "exact" }
|
|
1231
|
+
},
|
|
1232
|
+
macos: {
|
|
1233
|
+
id: { attribute: "AXIdentifier", match: "exact" },
|
|
1234
|
+
label: { attribute: "title | description", match: "exact" },
|
|
1235
|
+
text: { attribute: "title | description | value", match: "substring" },
|
|
1236
|
+
role: { attribute: "AXRole", match: "exact" }
|
|
1237
|
+
}
|
|
1238
|
+
};
|
|
1239
|
+
var ANDROID_MATCH_DIALECT = {
|
|
1240
|
+
platform: "android",
|
|
1241
|
+
normalizeRole: (role) => role,
|
|
1242
|
+
normalizeId: (value, options) => qualifyResourceId(value, options.appPackage)
|
|
1243
|
+
};
|
|
1244
|
+
var IOS_MATCH_DIALECT = {
|
|
1245
|
+
platform: "ios",
|
|
1246
|
+
normalizeRole: (role) => normalizeXcuiClassName(role),
|
|
1247
|
+
normalizeId: (value) => value
|
|
1248
|
+
};
|
|
1249
|
+
var MACOS_MATCH_DIALECT = {
|
|
1250
|
+
platform: "macos",
|
|
1251
|
+
normalizeRole: (role) => role,
|
|
1252
|
+
normalizeId: (value) => value
|
|
1253
|
+
};
|
|
1254
|
+
function nodeMatchesSelector(dialect, selector, node, options = {}) {
|
|
1255
|
+
switch (selector.kind) {
|
|
1256
|
+
case "id":
|
|
1257
|
+
return node.id !== void 0 && node.id === dialect.normalizeId(selector.value, options);
|
|
1258
|
+
case "label":
|
|
1259
|
+
return node.label !== void 0 && node.label === selector.value;
|
|
1260
|
+
case "text":
|
|
1261
|
+
return node.textValues.some((t) => t.includes(selector.value));
|
|
1262
|
+
case "focused":
|
|
1263
|
+
return node.focused === true;
|
|
1264
|
+
case "role": {
|
|
1265
|
+
if (node.role === void 0) {
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1268
|
+
if (dialect.normalizeRole(node.role) !== dialect.normalizeRole(selector.role)) {
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
if (selector.name === void 0 || selector.name.length === 0) {
|
|
1272
|
+
return true;
|
|
1273
|
+
}
|
|
1274
|
+
const name = selector.name;
|
|
1275
|
+
return node.textValues.some((t) => t.includes(name));
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
function matchNativeTree(dialect, selector, root, project, children, options = {}) {
|
|
1280
|
+
const out = [];
|
|
1281
|
+
const visit = (node) => {
|
|
1282
|
+
if (nodeMatchesSelector(dialect, selector, project(node), options)) {
|
|
1283
|
+
out.push(node);
|
|
1284
|
+
}
|
|
1285
|
+
for (const child of children(node)) {
|
|
1286
|
+
visit(child);
|
|
1287
|
+
}
|
|
1288
|
+
};
|
|
1289
|
+
visit(root);
|
|
1290
|
+
return out;
|
|
1291
|
+
}
|
|
1292
|
+
function parseSnapshot(xml) {
|
|
1293
|
+
return parseXml(xml);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
961
1296
|
// src/browser/android-driver.ts
|
|
962
1297
|
import fs3 from "fs";
|
|
963
1298
|
var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
@@ -993,50 +1328,29 @@ var ANDROID_KEYCODES = {
|
|
|
993
1328
|
pageup: 92,
|
|
994
1329
|
pagedown: 93
|
|
995
1330
|
};
|
|
996
|
-
function unquote2(value) {
|
|
997
|
-
const trimmed = value.trim();
|
|
998
|
-
const first = trimmed[0];
|
|
999
|
-
if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
|
|
1000
|
-
return trimmed.slice(1, -1);
|
|
1001
|
-
}
|
|
1002
|
-
return trimmed;
|
|
1003
|
-
}
|
|
1004
1331
|
function escapeUiSelectorArg(value) {
|
|
1005
1332
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1006
1333
|
}
|
|
1007
|
-
function
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
|
|
1020
|
-
}
|
|
1021
|
-
const labelMatch = /^label=(.+)$/s.exec(trimmed);
|
|
1022
|
-
if (labelMatch) {
|
|
1023
|
-
return { by: "accessibilityId", value: unquote2(labelMatch[1]) };
|
|
1024
|
-
}
|
|
1025
|
-
const textMatch = /^text=(.+)$/s.exec(trimmed);
|
|
1026
|
-
if (textMatch) {
|
|
1027
|
-
return { by: "text", value: unquote2(textMatch[1]) };
|
|
1334
|
+
function toAndroidQuery(selector) {
|
|
1335
|
+
switch (selector.kind) {
|
|
1336
|
+
case "focused":
|
|
1337
|
+
return { by: "focused" };
|
|
1338
|
+
case "id":
|
|
1339
|
+
return { by: "id", value: selector.value };
|
|
1340
|
+
case "role":
|
|
1341
|
+
return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
|
|
1342
|
+
case "label":
|
|
1343
|
+
return { by: "accessibilityId", value: selector.value };
|
|
1344
|
+
case "text":
|
|
1345
|
+
return { by: "text", value: selector.value };
|
|
1028
1346
|
}
|
|
1029
|
-
|
|
1347
|
+
}
|
|
1348
|
+
function parseAndroidSelector(selector) {
|
|
1349
|
+
return toAndroidQuery(parseNativeSelector(selector));
|
|
1030
1350
|
}
|
|
1031
1351
|
function locator(strategy, selector) {
|
|
1032
1352
|
return { strategy, selector, context: "" };
|
|
1033
1353
|
}
|
|
1034
|
-
function qualifyResourceId(value, appPackage) {
|
|
1035
|
-
if (value.includes(":") || !appPackage) {
|
|
1036
|
-
return value;
|
|
1037
|
-
}
|
|
1038
|
-
return `${appPackage}:id/${value}`;
|
|
1039
|
-
}
|
|
1040
1354
|
function androidQueryToLocator(query, options = {}) {
|
|
1041
1355
|
switch (query.by) {
|
|
1042
1356
|
case "id":
|
|
@@ -1063,11 +1377,7 @@ function androidQueryToLocator(query, options = {}) {
|
|
|
1063
1377
|
}
|
|
1064
1378
|
}
|
|
1065
1379
|
function unwrapAndroidTextSelector(selector) {
|
|
1066
|
-
|
|
1067
|
-
if (!trimmed.startsWith("text=")) {
|
|
1068
|
-
return null;
|
|
1069
|
-
}
|
|
1070
|
-
return unquote2(trimmed.slice(5));
|
|
1380
|
+
return unwrapNativeTextSelector(selector);
|
|
1071
1381
|
}
|
|
1072
1382
|
function keyCodeFor(key) {
|
|
1073
1383
|
const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
|
|
@@ -1342,22 +1652,63 @@ async function installApk(runner, serial, apkPath) {
|
|
|
1342
1652
|
);
|
|
1343
1653
|
}
|
|
1344
1654
|
}
|
|
1655
|
+
function formatUnknownError(error) {
|
|
1656
|
+
return error instanceof Error ? error.message : String(error);
|
|
1657
|
+
}
|
|
1658
|
+
function runnerPhaseError(message, error) {
|
|
1659
|
+
return new Error(`${message}: ${formatUnknownError(error)}`, { cause: error });
|
|
1660
|
+
}
|
|
1661
|
+
function parseResolvedComponent(stdout, pkg) {
|
|
1662
|
+
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
1663
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1664
|
+
if (/^[\w.]+\/[\w.$]+$/.test(lines[i]) && lines[i].startsWith(`${pkg}/`)) {
|
|
1665
|
+
return lines[i];
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
return null;
|
|
1669
|
+
}
|
|
1345
1670
|
async function launchPackage(runner, serial, pkg) {
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1671
|
+
let resolved;
|
|
1672
|
+
try {
|
|
1673
|
+
resolved = await runner(
|
|
1674
|
+
withSerial(serial, [
|
|
1675
|
+
"shell",
|
|
1676
|
+
"cmd",
|
|
1677
|
+
"package",
|
|
1678
|
+
"resolve-activity",
|
|
1679
|
+
"--brief",
|
|
1680
|
+
"-c",
|
|
1681
|
+
"android.intent.category.LAUNCHER",
|
|
1682
|
+
pkg
|
|
1683
|
+
]),
|
|
1684
|
+
{ timeoutMs: 3e4 }
|
|
1685
|
+
);
|
|
1686
|
+
} catch (error) {
|
|
1687
|
+
throw runnerPhaseError(
|
|
1688
|
+
`Failed to resolve the launcher activity for Android package "${pkg}" via adb`,
|
|
1689
|
+
error
|
|
1690
|
+
);
|
|
1691
|
+
}
|
|
1692
|
+
const component = parseResolvedComponent(resolved.stdout, pkg);
|
|
1693
|
+
if (resolved.code !== 0 || !component) {
|
|
1694
|
+
throw new Error(
|
|
1695
|
+
`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?`
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
let start;
|
|
1699
|
+
try {
|
|
1700
|
+
start = await runner(withSerial(serial, ["shell", "am", "start", "-n", component]), {
|
|
1701
|
+
timeoutMs: 3e4
|
|
1702
|
+
});
|
|
1703
|
+
} catch (error) {
|
|
1704
|
+
throw runnerPhaseError(
|
|
1705
|
+
`Failed to launch Android package "${pkg}" with \`am start\` (${component}) via adb`,
|
|
1706
|
+
error
|
|
1707
|
+
);
|
|
1708
|
+
}
|
|
1709
|
+
if (start.code !== 0 || /error|does not exist|cannot start/i.test(start.stdout + start.stderr)) {
|
|
1359
1710
|
throw new Error(
|
|
1360
|
-
`Failed to launch Android package "${pkg}" (exit ${
|
|
1711
|
+
`Failed to launch Android package "${pkg}" (${component}, exit ${start.code}): ${(start.stdout + start.stderr).trim().slice(0, 400)}.`
|
|
1361
1712
|
);
|
|
1362
1713
|
}
|
|
1363
1714
|
}
|
|
@@ -1419,12 +1770,12 @@ var Uia2Transport = class {
|
|
|
1419
1770
|
* {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
|
|
1420
1771
|
* per-request deadline elapses.
|
|
1421
1772
|
*/
|
|
1422
|
-
async request(method,
|
|
1773
|
+
async request(method, path18, body, timeoutMs) {
|
|
1423
1774
|
const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
|
|
1424
1775
|
const controller = new AbortController();
|
|
1425
1776
|
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
1426
1777
|
timer.unref?.();
|
|
1427
|
-
const url = `${this.baseUrl}${
|
|
1778
|
+
const url = `${this.baseUrl}${path18}`;
|
|
1428
1779
|
let response;
|
|
1429
1780
|
try {
|
|
1430
1781
|
response = await this.fetchImpl(url, {
|
|
@@ -1436,7 +1787,7 @@ var Uia2Transport = class {
|
|
|
1436
1787
|
} catch (error) {
|
|
1437
1788
|
if (controller.signal.aborted) {
|
|
1438
1789
|
const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
|
|
1439
|
-
throw new Error(`uiautomator2 request ${method} ${
|
|
1790
|
+
throw new Error(`uiautomator2 request ${method} ${path18} timed out after ${shown}`);
|
|
1440
1791
|
}
|
|
1441
1792
|
throw error instanceof Error ? error : new Error(String(error));
|
|
1442
1793
|
} finally {
|
|
@@ -1447,7 +1798,7 @@ var Uia2Transport = class {
|
|
|
1447
1798
|
if (!response.ok) {
|
|
1448
1799
|
const wdError = extractWebdriverError(parsed);
|
|
1449
1800
|
throw new Uia2HttpError(
|
|
1450
|
-
`uiautomator2 ${method} ${
|
|
1801
|
+
`uiautomator2 ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
|
|
1451
1802
|
response.status,
|
|
1452
1803
|
wdError
|
|
1453
1804
|
);
|
|
@@ -1530,10 +1881,10 @@ function sleep(ms) {
|
|
|
1530
1881
|
}
|
|
1531
1882
|
function createUia2AgentClient(transport, sessionId, options = {}) {
|
|
1532
1883
|
const base = `/session/${sessionId}`;
|
|
1533
|
-
async function locate(query,
|
|
1884
|
+
async function locate(query, path18) {
|
|
1534
1885
|
return transport.request(
|
|
1535
1886
|
"POST",
|
|
1536
|
-
`${base}${
|
|
1887
|
+
`${base}${path18}`,
|
|
1537
1888
|
androidQueryToLocator(query, { appPackage: options.appPackage })
|
|
1538
1889
|
);
|
|
1539
1890
|
}
|
|
@@ -1575,6 +1926,13 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
|
|
|
1575
1926
|
}
|
|
1576
1927
|
return Buffer.from(value, "base64");
|
|
1577
1928
|
},
|
|
1929
|
+
async source() {
|
|
1930
|
+
const value = await transport.request("GET", `${base}/source`);
|
|
1931
|
+
if (typeof value !== "string") {
|
|
1932
|
+
throw new Error("uiautomator2 /source did not return XML text; cannot analyze Android UI hierarchy");
|
|
1933
|
+
}
|
|
1934
|
+
return value;
|
|
1935
|
+
},
|
|
1578
1936
|
async close() {
|
|
1579
1937
|
await transport.request("DELETE", base).catch(() => void 0);
|
|
1580
1938
|
}
|
|
@@ -1585,7 +1943,7 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
|
|
|
1585
1943
|
import { createRequire } from "module";
|
|
1586
1944
|
import { execFile as execFile2 } from "child_process";
|
|
1587
1945
|
import fs4 from "fs";
|
|
1588
|
-
import
|
|
1946
|
+
import path4 from "path";
|
|
1589
1947
|
var UIA2_REMOTE_PORT = 6790;
|
|
1590
1948
|
function resolveAgentApks(requireFn = createRequire(import.meta.url)) {
|
|
1591
1949
|
let pkgJsonPath;
|
|
@@ -1596,10 +1954,10 @@ function resolveAgentApks(requireFn = createRequire(import.meta.url)) {
|
|
|
1596
1954
|
"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"
|
|
1597
1955
|
);
|
|
1598
1956
|
}
|
|
1599
|
-
const pkgDir =
|
|
1957
|
+
const pkgDir = path4.dirname(pkgJsonPath);
|
|
1600
1958
|
const version = requireFn(pkgJsonPath).version;
|
|
1601
|
-
const serverApk =
|
|
1602
|
-
const testApk =
|
|
1959
|
+
const serverApk = path4.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
|
|
1960
|
+
const testApk = path4.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
|
|
1603
1961
|
for (const apk of [serverApk, testApk]) {
|
|
1604
1962
|
if (!fs4.existsSync(apk)) {
|
|
1605
1963
|
throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
|
|
@@ -1647,7 +2005,7 @@ async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
|
|
|
1647
2005
|
assertAndroidAppAllowed(allowedApps, app);
|
|
1648
2006
|
return app;
|
|
1649
2007
|
}
|
|
1650
|
-
const apkPath =
|
|
2008
|
+
const apkPath = path4.resolve(app);
|
|
1651
2009
|
if (!fs4.existsSync(apkPath)) {
|
|
1652
2010
|
throw new Error(`APK not found: ${apkPath}`);
|
|
1653
2011
|
}
|
|
@@ -1729,43 +2087,25 @@ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
|
1729
2087
|
var WAIT_POLL_INTERVAL_MS2 = 250;
|
|
1730
2088
|
var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
|
|
1731
2089
|
var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
|
|
1732
|
-
function unquote3(value) {
|
|
1733
|
-
const trimmed = value.trim();
|
|
1734
|
-
const first = trimmed[0];
|
|
1735
|
-
if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
|
|
1736
|
-
return trimmed.slice(1, -1);
|
|
1737
|
-
}
|
|
1738
|
-
return trimmed;
|
|
1739
|
-
}
|
|
1740
2090
|
function escapePredicateArg(value) {
|
|
1741
2091
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1742
2092
|
}
|
|
1743
|
-
function
|
|
1744
|
-
|
|
2093
|
+
function toIosQuery(selector) {
|
|
2094
|
+
switch (selector.kind) {
|
|
2095
|
+
case "focused":
|
|
2096
|
+
return { by: "focused" };
|
|
2097
|
+
case "id":
|
|
2098
|
+
return { by: "accessibilityId", value: selector.value };
|
|
2099
|
+
case "role":
|
|
2100
|
+
return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
|
|
2101
|
+
case "label":
|
|
2102
|
+
return { by: "label", value: selector.value };
|
|
2103
|
+
case "text":
|
|
2104
|
+
return { by: "text", value: selector.value };
|
|
2105
|
+
}
|
|
1745
2106
|
}
|
|
1746
2107
|
function parseIosSelector(selector) {
|
|
1747
|
-
|
|
1748
|
-
if (trimmed === ":focus") {
|
|
1749
|
-
return { by: "focused" };
|
|
1750
|
-
}
|
|
1751
|
-
const idMatch = /^id=(.+)$/s.exec(trimmed);
|
|
1752
|
-
if (idMatch) {
|
|
1753
|
-
return { by: "accessibilityId", value: unquote3(idMatch[1]) };
|
|
1754
|
-
}
|
|
1755
|
-
const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
|
|
1756
|
-
if (roleMatch) {
|
|
1757
|
-
const name = roleMatch[2] !== void 0 ? unquote3(roleMatch[2]) : void 0;
|
|
1758
|
-
return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
|
|
1759
|
-
}
|
|
1760
|
-
const labelMatch = /^label=(.+)$/s.exec(trimmed);
|
|
1761
|
-
if (labelMatch) {
|
|
1762
|
-
return { by: "label", value: unquote3(labelMatch[1]) };
|
|
1763
|
-
}
|
|
1764
|
-
const textMatch = /^text=(.+)$/s.exec(trimmed);
|
|
1765
|
-
if (textMatch) {
|
|
1766
|
-
return { by: "text", value: unquote3(textMatch[1]) };
|
|
1767
|
-
}
|
|
1768
|
-
return { by: "text", value: trimmed };
|
|
2108
|
+
return toIosQuery(parseNativeSelector(selector));
|
|
1769
2109
|
}
|
|
1770
2110
|
function iosQueryToLocator(query) {
|
|
1771
2111
|
switch (query.by) {
|
|
@@ -1797,11 +2137,7 @@ function iosQueryToLocator(query) {
|
|
|
1797
2137
|
}
|
|
1798
2138
|
}
|
|
1799
2139
|
function unwrapIosTextSelector(selector) {
|
|
1800
|
-
|
|
1801
|
-
if (!trimmed.startsWith("text=")) {
|
|
1802
|
-
return null;
|
|
1803
|
-
}
|
|
1804
|
-
return unquote3(trimmed.slice(5));
|
|
2140
|
+
return unwrapNativeTextSelector(selector);
|
|
1805
2141
|
}
|
|
1806
2142
|
function delay2(ms) {
|
|
1807
2143
|
return new Promise((resolve) => {
|
|
@@ -1960,12 +2296,25 @@ function createIosDriver(client, options) {
|
|
|
1960
2296
|
}
|
|
1961
2297
|
|
|
1962
2298
|
// src/browser/ios-simctl.ts
|
|
1963
|
-
import { execFile as execFile3 } from "child_process";
|
|
2299
|
+
import { execFile as execFile3, spawn as spawn3 } from "child_process";
|
|
1964
2300
|
import { mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
1965
2301
|
import net from "net";
|
|
1966
|
-
import
|
|
1967
|
-
import
|
|
1968
|
-
var
|
|
2302
|
+
import os3 from "os";
|
|
2303
|
+
import path5 from "path";
|
|
2304
|
+
var spawnXcrunProcess = (args, options) => {
|
|
2305
|
+
const child = spawn3("xcrun", args, {
|
|
2306
|
+
stdio: "ignore",
|
|
2307
|
+
env: options?.env ? { ...process.env, ...options.env } : process.env
|
|
2308
|
+
});
|
|
2309
|
+
child.on("error", () => {
|
|
2310
|
+
});
|
|
2311
|
+
return {
|
|
2312
|
+
kill: () => {
|
|
2313
|
+
child.kill();
|
|
2314
|
+
}
|
|
2315
|
+
};
|
|
2316
|
+
};
|
|
2317
|
+
var DEFAULT_SIMULATOR_LOCK_ROOT = path5.join(os3.tmpdir(), "prowl-ios-simulator-locks");
|
|
1969
2318
|
var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
|
|
1970
2319
|
var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
|
|
1971
2320
|
execFile3(
|
|
@@ -2005,7 +2354,7 @@ function isProcessAlive(pid) {
|
|
|
2005
2354
|
}
|
|
2006
2355
|
async function removeStaleSimulatorLock(lockPath) {
|
|
2007
2356
|
try {
|
|
2008
|
-
const ownerText = await readFile(
|
|
2357
|
+
const ownerText = await readFile(path5.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
|
|
2009
2358
|
const owner = JSON.parse(ownerText);
|
|
2010
2359
|
if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
|
|
2011
2360
|
await rm(lockPath, { recursive: true, force: true });
|
|
@@ -2023,7 +2372,7 @@ function simulatorReservedError(udid) {
|
|
|
2023
2372
|
}
|
|
2024
2373
|
async function reserveSimulatorUdid(udid, options = {}) {
|
|
2025
2374
|
const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
|
|
2026
|
-
const lockPath =
|
|
2375
|
+
const lockPath = path5.join(lockRoot, simulatorLockName(udid));
|
|
2027
2376
|
await mkdir(lockRoot, { recursive: true });
|
|
2028
2377
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
2029
2378
|
try {
|
|
@@ -2047,7 +2396,7 @@ async function reserveSimulatorUdid(udid, options = {}) {
|
|
|
2047
2396
|
};
|
|
2048
2397
|
try {
|
|
2049
2398
|
await writeFile(
|
|
2050
|
-
|
|
2399
|
+
path5.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
|
|
2051
2400
|
`${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
|
|
2052
2401
|
`,
|
|
2053
2402
|
{ flag: "wx" }
|
|
@@ -2239,15 +2588,15 @@ var WdaTransport = class {
|
|
|
2239
2588
|
* {@link WdaHttpError} on a non-2xx response, or a timeout error when the
|
|
2240
2589
|
* per-request deadline elapses.
|
|
2241
2590
|
*/
|
|
2242
|
-
async requestFull(method,
|
|
2591
|
+
async requestFull(method, path18, body, timeoutMs) {
|
|
2243
2592
|
const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
|
|
2244
2593
|
const controller = new AbortController();
|
|
2245
2594
|
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
2246
2595
|
timer.unref?.();
|
|
2247
|
-
const url = `${this.baseUrl}${
|
|
2596
|
+
const url = `${this.baseUrl}${path18}`;
|
|
2248
2597
|
const timeoutError = () => {
|
|
2249
2598
|
const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
|
|
2250
|
-
return new Error(`WebDriverAgent request ${method} ${
|
|
2599
|
+
return new Error(`WebDriverAgent request ${method} ${path18} timed out after ${shown}`);
|
|
2251
2600
|
};
|
|
2252
2601
|
let response;
|
|
2253
2602
|
try {
|
|
@@ -2279,7 +2628,7 @@ var WdaTransport = class {
|
|
|
2279
2628
|
if (!response.ok) {
|
|
2280
2629
|
const wdError = extractWebdriverError2(parsed);
|
|
2281
2630
|
throw new WdaHttpError(
|
|
2282
|
-
`WebDriverAgent ${method} ${
|
|
2631
|
+
`WebDriverAgent ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
|
|
2283
2632
|
response.status,
|
|
2284
2633
|
wdError
|
|
2285
2634
|
);
|
|
@@ -2287,8 +2636,8 @@ var WdaTransport = class {
|
|
|
2287
2636
|
return parsed;
|
|
2288
2637
|
}
|
|
2289
2638
|
/** Like {@link requestFull} but returns just the `value` field. */
|
|
2290
|
-
async request(method,
|
|
2291
|
-
const parsed = await this.requestFull(method,
|
|
2639
|
+
async request(method, path18, body, timeoutMs) {
|
|
2640
|
+
const parsed = await this.requestFull(method, path18, body, timeoutMs);
|
|
2292
2641
|
return parsed?.value;
|
|
2293
2642
|
}
|
|
2294
2643
|
};
|
|
@@ -2364,8 +2713,8 @@ function sleep2(ms) {
|
|
|
2364
2713
|
}
|
|
2365
2714
|
function createWdaAgentClient(transport, sessionId) {
|
|
2366
2715
|
const base = `/session/${sessionId}`;
|
|
2367
|
-
async function locate(query,
|
|
2368
|
-
return transport.request("POST", `${base}${
|
|
2716
|
+
async function locate(query, path18) {
|
|
2717
|
+
return transport.request("POST", `${base}${path18}`, iosQueryToLocator(query));
|
|
2369
2718
|
}
|
|
2370
2719
|
return {
|
|
2371
2720
|
async findElement(query) {
|
|
@@ -2401,6 +2750,13 @@ function createWdaAgentClient(transport, sessionId) {
|
|
|
2401
2750
|
async homescreen() {
|
|
2402
2751
|
await transport.request("POST", "/wda/homescreen", {});
|
|
2403
2752
|
},
|
|
2753
|
+
async source() {
|
|
2754
|
+
const value = await transport.request("GET", "/source");
|
|
2755
|
+
if (typeof value !== "string") {
|
|
2756
|
+
throw new Error("WebDriverAgent /source did not return XML text; cannot analyze iOS UI hierarchy");
|
|
2757
|
+
}
|
|
2758
|
+
return value;
|
|
2759
|
+
},
|
|
2404
2760
|
async close() {
|
|
2405
2761
|
await transport.request("DELETE", base).catch(() => void 0);
|
|
2406
2762
|
}
|
|
@@ -2409,12 +2765,13 @@ function createWdaAgentClient(transport, sessionId) {
|
|
|
2409
2765
|
|
|
2410
2766
|
// src/browser/ios-helper.ts
|
|
2411
2767
|
import { createRequire as createRequire2 } from "module";
|
|
2768
|
+
import { execFile as execFile4 } from "child_process";
|
|
2412
2769
|
import fs5 from "fs";
|
|
2413
|
-
import
|
|
2414
|
-
import
|
|
2770
|
+
import os4 from "os";
|
|
2771
|
+
import path6 from "path";
|
|
2415
2772
|
var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
|
|
2416
|
-
var WDA_RUNNER_APP_NAME = "WebDriverAgentRunner-Runner.app";
|
|
2417
2773
|
var WDA_USE_PORT_ENV = "USE_PORT";
|
|
2774
|
+
var PREPARED_XCTESTRUN_PREFIX = "prowl-wda-xctestrun-";
|
|
2418
2775
|
var WDA_STARTUP_ATTEMPTS = 2;
|
|
2419
2776
|
var defaultIosAgentConnector = async ({
|
|
2420
2777
|
host,
|
|
@@ -2437,32 +2794,63 @@ function resolveWdaProject(requireFn = createRequire2(import.meta.url)) {
|
|
|
2437
2794
|
"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"
|
|
2438
2795
|
);
|
|
2439
2796
|
}
|
|
2440
|
-
const pkgDir =
|
|
2797
|
+
const pkgDir = path6.dirname(pkgJsonPath);
|
|
2441
2798
|
const version = requireFn(pkgJsonPath).version;
|
|
2442
|
-
const projectPath =
|
|
2799
|
+
const projectPath = path6.join(pkgDir, "WebDriverAgent.xcodeproj");
|
|
2443
2800
|
if (!fs5.existsSync(projectPath)) {
|
|
2444
2801
|
throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
|
|
2445
2802
|
}
|
|
2446
2803
|
return { projectPath, version };
|
|
2447
2804
|
}
|
|
2448
|
-
function wdaCacheDir(wdaVersion, xcode, homeDir =
|
|
2449
|
-
return
|
|
2805
|
+
function wdaCacheDir(wdaVersion, xcode, homeDir = os4.homedir()) {
|
|
2806
|
+
return path6.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
|
|
2450
2807
|
}
|
|
2451
|
-
function
|
|
2452
|
-
return
|
|
2808
|
+
function productsDir(derivedDataPath) {
|
|
2809
|
+
return path6.join(derivedDataPath, "Build", "Products");
|
|
2453
2810
|
}
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2811
|
+
function findXctestrunIn(dir) {
|
|
2812
|
+
let entries;
|
|
2813
|
+
try {
|
|
2814
|
+
entries = fs5.readdirSync(dir);
|
|
2815
|
+
} catch {
|
|
2816
|
+
return null;
|
|
2817
|
+
}
|
|
2818
|
+
const match = entries.filter((name) => name.endsWith(".xctestrun") && !name.startsWith(PREPARED_XCTESTRUN_PREFIX)).sort()[0];
|
|
2819
|
+
return match ? path6.join(dir, match) : null;
|
|
2820
|
+
}
|
|
2821
|
+
function resolveOverrideXctestrun(override) {
|
|
2822
|
+
if (!fs5.existsSync(override)) {
|
|
2823
|
+
throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
|
|
2824
|
+
}
|
|
2825
|
+
if (override.endsWith(".xctestrun")) {
|
|
2826
|
+
return override;
|
|
2827
|
+
}
|
|
2828
|
+
const candidates = [];
|
|
2829
|
+
const stat = fs5.statSync(override);
|
|
2830
|
+
if (override.endsWith(".app")) {
|
|
2831
|
+
candidates.push(path6.dirname(path6.dirname(override)));
|
|
2832
|
+
} else if (stat.isDirectory()) {
|
|
2833
|
+
candidates.push(override, productsDir(override));
|
|
2834
|
+
}
|
|
2835
|
+
for (const dir of candidates) {
|
|
2836
|
+
const found = findXctestrunIn(dir);
|
|
2837
|
+
if (found) {
|
|
2838
|
+
return found;
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
throw new Error(
|
|
2842
|
+
`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\`.`
|
|
2843
|
+
);
|
|
2844
|
+
}
|
|
2845
|
+
async function resolveWdaTestRun(options = {}) {
|
|
2846
|
+
const runner = options.runner ?? execFileXcrunRunner;
|
|
2847
|
+
const env = options.env ?? process.env;
|
|
2848
|
+
const homeDir = options.homeDir ?? os4.homedir();
|
|
2849
|
+
const log = options.logger ?? ((message) => process.stderr.write(`${message}
|
|
2850
|
+
`));
|
|
2460
2851
|
const override = env.PROWL_WDA_RUNNER;
|
|
2461
2852
|
if (override) {
|
|
2462
|
-
|
|
2463
|
-
throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
|
|
2464
|
-
}
|
|
2465
|
-
return override;
|
|
2853
|
+
return resolveOverrideXctestrun(override);
|
|
2466
2854
|
}
|
|
2467
2855
|
const { projectPath, version } = resolveWdaProject(options.requireFn);
|
|
2468
2856
|
const xcode = await xcodeVersion(runner);
|
|
@@ -2472,9 +2860,9 @@ async function resolveWdaRunner(options = {}) {
|
|
|
2472
2860
|
);
|
|
2473
2861
|
}
|
|
2474
2862
|
const cacheDir = wdaCacheDir(version, xcode, homeDir);
|
|
2475
|
-
const
|
|
2476
|
-
if (
|
|
2477
|
-
return
|
|
2863
|
+
const cached = findXctestrunIn(productsDir(cacheDir));
|
|
2864
|
+
if (cached) {
|
|
2865
|
+
return cached;
|
|
2478
2866
|
}
|
|
2479
2867
|
log(
|
|
2480
2868
|
`Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
|
|
@@ -2501,12 +2889,106 @@ async function resolveWdaRunner(options = {}) {
|
|
|
2501
2889
|
`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)}`
|
|
2502
2890
|
);
|
|
2503
2891
|
}
|
|
2504
|
-
|
|
2892
|
+
const built = findXctestrunIn(productsDir(cacheDir));
|
|
2893
|
+
if (!built) {
|
|
2894
|
+
throw new Error(
|
|
2895
|
+
`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.`
|
|
2896
|
+
);
|
|
2897
|
+
}
|
|
2898
|
+
return built;
|
|
2899
|
+
}
|
|
2900
|
+
function injectUsePortIntoXctestrun(plist, port) {
|
|
2901
|
+
const clone = structuredClone(plist);
|
|
2902
|
+
let injected = 0;
|
|
2903
|
+
const visit = (node) => {
|
|
2904
|
+
if (Array.isArray(node)) {
|
|
2905
|
+
for (const entry of node) {
|
|
2906
|
+
visit(entry);
|
|
2907
|
+
}
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
if (!node || typeof node !== "object") {
|
|
2911
|
+
return;
|
|
2912
|
+
}
|
|
2913
|
+
const obj = node;
|
|
2914
|
+
const isTarget = typeof obj.TestBundlePath === "string" || typeof obj.TestHostPath === "string";
|
|
2915
|
+
const existingEnv = obj.EnvironmentVariables && typeof obj.EnvironmentVariables === "object" && !Array.isArray(obj.EnvironmentVariables) ? obj.EnvironmentVariables : void 0;
|
|
2916
|
+
if (isTarget || existingEnv) {
|
|
2917
|
+
const env = existingEnv ?? {};
|
|
2918
|
+
env[WDA_USE_PORT_ENV] = String(port);
|
|
2919
|
+
obj.EnvironmentVariables = env;
|
|
2920
|
+
injected += 1;
|
|
2921
|
+
}
|
|
2922
|
+
for (const value of Object.values(obj)) {
|
|
2923
|
+
visit(value);
|
|
2924
|
+
}
|
|
2925
|
+
};
|
|
2926
|
+
visit(clone);
|
|
2927
|
+
if (injected === 0) {
|
|
2505
2928
|
throw new Error(
|
|
2506
|
-
|
|
2929
|
+
"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."
|
|
2507
2930
|
);
|
|
2508
2931
|
}
|
|
2509
|
-
return
|
|
2932
|
+
return clone;
|
|
2933
|
+
}
|
|
2934
|
+
function runPlutil(args) {
|
|
2935
|
+
return new Promise((resolve, reject) => {
|
|
2936
|
+
execFile4(
|
|
2937
|
+
"plutil",
|
|
2938
|
+
args,
|
|
2939
|
+
{ encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 },
|
|
2940
|
+
(error, stdout, stderr) => {
|
|
2941
|
+
if (error) {
|
|
2942
|
+
reject(
|
|
2943
|
+
new Error(
|
|
2944
|
+
`\`plutil ${args.join(" ")}\` failed: ${(stderr || error.message).slice(0, 400)}`
|
|
2945
|
+
)
|
|
2946
|
+
);
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
resolve(stdout ?? "");
|
|
2950
|
+
}
|
|
2951
|
+
);
|
|
2952
|
+
});
|
|
2953
|
+
}
|
|
2954
|
+
var defaultWdaTestRunPreparer = async ({ xctestrunPath, port }) => {
|
|
2955
|
+
const json = await runPlutil(["-convert", "json", "-o", "-", xctestrunPath]);
|
|
2956
|
+
let parsed;
|
|
2957
|
+
try {
|
|
2958
|
+
parsed = JSON.parse(json);
|
|
2959
|
+
} catch {
|
|
2960
|
+
throw new Error(`Could not parse the WebDriverAgent .xctestrun as JSON: ${xctestrunPath}`);
|
|
2961
|
+
}
|
|
2962
|
+
const injected = injectUsePortIntoXctestrun(parsed, port);
|
|
2963
|
+
const stem = `${PREPARED_XCTESTRUN_PREFIX}${port}-${process.pid}`;
|
|
2964
|
+
const outPath = path6.join(path6.dirname(xctestrunPath), `${stem}.xctestrun`);
|
|
2965
|
+
const jsonPath = path6.join(os4.tmpdir(), `${stem}.json`);
|
|
2966
|
+
fs5.writeFileSync(jsonPath, JSON.stringify(injected));
|
|
2967
|
+
try {
|
|
2968
|
+
await runPlutil(["-convert", "xml1", jsonPath, "-o", outPath]);
|
|
2969
|
+
} finally {
|
|
2970
|
+
fs5.rmSync(jsonPath, { force: true });
|
|
2971
|
+
}
|
|
2972
|
+
return outPath;
|
|
2973
|
+
};
|
|
2974
|
+
function cleanupPreparedTestRun(preparedPath) {
|
|
2975
|
+
if (!preparedPath) {
|
|
2976
|
+
return;
|
|
2977
|
+
}
|
|
2978
|
+
if (!path6.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2981
|
+
fs5.rmSync(preparedPath, { force: true });
|
|
2982
|
+
}
|
|
2983
|
+
function wdaTestRunArgs(xctestrunPath, udid) {
|
|
2984
|
+
return [
|
|
2985
|
+
"xcodebuild",
|
|
2986
|
+
"test-without-building",
|
|
2987
|
+
"-xctestrun",
|
|
2988
|
+
xctestrunPath,
|
|
2989
|
+
"-destination",
|
|
2990
|
+
`id=${udid}`
|
|
2991
|
+
];
|
|
2510
2992
|
}
|
|
2511
2993
|
async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
|
|
2512
2994
|
if (!looksLikeIosAppPath(app)) {
|
|
@@ -2518,7 +3000,7 @@ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
|
|
|
2518
3000
|
}
|
|
2519
3001
|
return app;
|
|
2520
3002
|
}
|
|
2521
|
-
const appPath =
|
|
3003
|
+
const appPath = path6.resolve(app);
|
|
2522
3004
|
if (!fs5.existsSync(appPath)) {
|
|
2523
3005
|
throw new Error(`.app bundle not found: ${appPath}`);
|
|
2524
3006
|
}
|
|
@@ -2537,6 +3019,8 @@ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
|
|
|
2537
3019
|
}
|
|
2538
3020
|
async function launchIosSession(options) {
|
|
2539
3021
|
const runner = options.runner ?? execFileXcrunRunner;
|
|
3022
|
+
const spawner = options.spawner ?? spawnXcrunProcess;
|
|
3023
|
+
const preparer = options.testRunPreparer ?? defaultWdaTestRunPreparer;
|
|
2540
3024
|
const portAllocator = options.portAllocator ?? findFreePort;
|
|
2541
3025
|
const connector = options.agentConnector ?? defaultIosAgentConnector;
|
|
2542
3026
|
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
|
|
@@ -2560,14 +3044,14 @@ async function launchIosSession(options) {
|
|
|
2560
3044
|
options.coldStart ?? false,
|
|
2561
3045
|
options.allowedApps ?? []
|
|
2562
3046
|
);
|
|
2563
|
-
const
|
|
2564
|
-
await installApp(runner, udid, wdaRunnerApp);
|
|
3047
|
+
const baseTestRun = options.wdaTestRun ?? await resolveWdaTestRun({ runner, logger: options.logger });
|
|
2565
3048
|
for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
|
|
2566
3049
|
let client;
|
|
3050
|
+
let wdaProcess;
|
|
3051
|
+
let preparedTestRun;
|
|
2567
3052
|
let tornDown = false;
|
|
2568
|
-
let terminateWda = false;
|
|
2569
3053
|
let terminateTarget = false;
|
|
2570
|
-
let stage = "
|
|
3054
|
+
let stage = "prepare";
|
|
2571
3055
|
const teardownAttempt = async () => {
|
|
2572
3056
|
if (tornDown) {
|
|
2573
3057
|
return;
|
|
@@ -2576,18 +3060,18 @@ async function launchIosSession(options) {
|
|
|
2576
3060
|
if (client) {
|
|
2577
3061
|
await client.close().catch(() => void 0);
|
|
2578
3062
|
}
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
}
|
|
3063
|
+
wdaProcess?.kill();
|
|
3064
|
+
await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
|
|
2582
3065
|
if (terminateTarget) {
|
|
2583
3066
|
await terminateApp(runner, udid, bundleId);
|
|
2584
3067
|
}
|
|
3068
|
+
cleanupPreparedTestRun(preparedTestRun);
|
|
2585
3069
|
};
|
|
2586
3070
|
try {
|
|
2587
3071
|
const port = await portAllocator();
|
|
3072
|
+
preparedTestRun = await preparer({ xctestrunPath: baseTestRun, port });
|
|
2588
3073
|
stage = "wda-launch";
|
|
2589
|
-
|
|
2590
|
-
await launchApp(runner, udid, WDA_RUNNER_BUNDLE_ID, { [WDA_USE_PORT_ENV]: String(port) });
|
|
3074
|
+
wdaProcess = spawner(wdaTestRunArgs(preparedTestRun, udid));
|
|
2591
3075
|
stage = "target-launch";
|
|
2592
3076
|
terminateTarget = true;
|
|
2593
3077
|
await launchApp(runner, udid, bundleId);
|
|
@@ -2675,14 +3159,14 @@ async function healSelector(probe, selector, options) {
|
|
|
2675
3159
|
|
|
2676
3160
|
// src/runner/history.ts
|
|
2677
3161
|
import fs6 from "fs";
|
|
2678
|
-
import
|
|
3162
|
+
import path7 from "path";
|
|
2679
3163
|
var HISTORY_FILE = "history.json";
|
|
2680
3164
|
var LOCK_FILE_SUFFIX = ".lock";
|
|
2681
3165
|
var LOCK_RETRY_MS = 10;
|
|
2682
3166
|
var LOCK_TIMEOUT_MS = 5e3;
|
|
2683
3167
|
var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
2684
3168
|
function historyPath(configDir) {
|
|
2685
|
-
return
|
|
3169
|
+
return path7.join(configDir, HISTORY_FILE);
|
|
2686
3170
|
}
|
|
2687
3171
|
function isHistoryEntry(value) {
|
|
2688
3172
|
if (!value || typeof value !== "object") {
|
|
@@ -2736,7 +3220,7 @@ function sleepSync(ms) {
|
|
|
2736
3220
|
function withHistoryLock(configDir, fn) {
|
|
2737
3221
|
const filePath = historyPath(configDir);
|
|
2738
3222
|
const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
|
|
2739
|
-
fs6.mkdirSync(
|
|
3223
|
+
fs6.mkdirSync(path7.dirname(filePath), { recursive: true });
|
|
2740
3224
|
const startedAt = Date.now();
|
|
2741
3225
|
while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
|
|
2742
3226
|
let fd;
|
|
@@ -2777,11 +3261,11 @@ function appendEntry(configDir, entry, maxRuns) {
|
|
|
2777
3261
|
|
|
2778
3262
|
// src/runner/index.ts
|
|
2779
3263
|
import fs12 from "fs";
|
|
2780
|
-
import
|
|
3264
|
+
import path13 from "path";
|
|
2781
3265
|
|
|
2782
3266
|
// src/browser/playwright-driver.ts
|
|
2783
3267
|
import fs7 from "fs";
|
|
2784
|
-
import
|
|
3268
|
+
import path8 from "path";
|
|
2785
3269
|
import {
|
|
2786
3270
|
chromium,
|
|
2787
3271
|
firefox,
|
|
@@ -2814,7 +3298,7 @@ async function launchBrowser(options) {
|
|
|
2814
3298
|
}
|
|
2815
3299
|
}
|
|
2816
3300
|
if (options.recordHar) {
|
|
2817
|
-
contextOptions.recordHar = { path:
|
|
3301
|
+
contextOptions.recordHar = { path: path8.join(options.runDir, "network.har") };
|
|
2818
3302
|
}
|
|
2819
3303
|
const context = await browser.newContext(contextOptions);
|
|
2820
3304
|
const page = await context.newPage();
|
|
@@ -2822,7 +3306,7 @@ async function launchBrowser(options) {
|
|
|
2822
3306
|
page.setDefaultNavigationTimeout(options.timeout);
|
|
2823
3307
|
let tracePath;
|
|
2824
3308
|
if (options.trace) {
|
|
2825
|
-
tracePath =
|
|
3309
|
+
tracePath = path8.join(options.runDir, "trace.zip");
|
|
2826
3310
|
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
2827
3311
|
}
|
|
2828
3312
|
return { browser, context, page, tracePath };
|
|
@@ -3001,7 +3485,7 @@ function createPlaywrightDriver(page) {
|
|
|
3001
3485
|
|
|
3002
3486
|
// src/runner/steps.ts
|
|
3003
3487
|
import fs8 from "fs";
|
|
3004
|
-
import
|
|
3488
|
+
import path9 from "path";
|
|
3005
3489
|
|
|
3006
3490
|
// src/runner/policy.ts
|
|
3007
3491
|
var ALWAYS_ALLOWED_PROTOCOLS = ["about:", "data:"];
|
|
@@ -3100,6 +3584,246 @@ function createRunPolicy(driver, options) {
|
|
|
3100
3584
|
};
|
|
3101
3585
|
}
|
|
3102
3586
|
|
|
3587
|
+
// src/generator/ai.ts
|
|
3588
|
+
var DEFAULT_BASE_URL = {
|
|
3589
|
+
anthropic: "https://api.anthropic.com",
|
|
3590
|
+
openai: "https://api.openai.com"
|
|
3591
|
+
};
|
|
3592
|
+
var AI_REQUEST_TIMEOUT_MS = 3e4;
|
|
3593
|
+
function apiRoot(config) {
|
|
3594
|
+
return config.baseUrl ?? DEFAULT_BASE_URL[config.provider];
|
|
3595
|
+
}
|
|
3596
|
+
function defaultModelFor(provider) {
|
|
3597
|
+
return provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
|
|
3598
|
+
}
|
|
3599
|
+
function resolveAiEnv() {
|
|
3600
|
+
const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
|
|
3601
|
+
if (provider !== "anthropic" && provider !== "openai") {
|
|
3602
|
+
throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
|
|
3603
|
+
}
|
|
3604
|
+
const model = process.env.PROWL_AI_MODEL ?? defaultModelFor(provider);
|
|
3605
|
+
const baseUrl = normalizeBaseUrl(process.env.PROWL_AI_BASE_URL) ?? DEFAULT_BASE_URL[provider];
|
|
3606
|
+
return { provider, model, baseUrl, apiKey: process.env.PROWL_AI_KEY };
|
|
3607
|
+
}
|
|
3608
|
+
function normalizeBaseUrl(value) {
|
|
3609
|
+
if (value === void 0) return void 0;
|
|
3610
|
+
const trimmed = value.trim().replace(/\/+$/, "");
|
|
3611
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
3612
|
+
}
|
|
3613
|
+
function resolveAiConfig() {
|
|
3614
|
+
const env = resolveAiEnv();
|
|
3615
|
+
if (!env.apiKey) {
|
|
3616
|
+
throw new Error(
|
|
3617
|
+
"PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
|
|
3618
|
+
);
|
|
3619
|
+
}
|
|
3620
|
+
return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
|
|
3621
|
+
}
|
|
3622
|
+
function tryResolveAiConfig() {
|
|
3623
|
+
const env = resolveAiEnv();
|
|
3624
|
+
if (!env.apiKey) {
|
|
3625
|
+
return null;
|
|
3626
|
+
}
|
|
3627
|
+
return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
|
|
3628
|
+
}
|
|
3629
|
+
async function generateWithAi(prompt, config) {
|
|
3630
|
+
if (config.provider === "anthropic") {
|
|
3631
|
+
return generateWithAnthropic(prompt, config);
|
|
3632
|
+
}
|
|
3633
|
+
return generateWithOpenAi(prompt, config);
|
|
3634
|
+
}
|
|
3635
|
+
async function generateWithAnthropic(prompt, config) {
|
|
3636
|
+
const data = await postJson(
|
|
3637
|
+
"Anthropic",
|
|
3638
|
+
`${apiRoot(config)}/v1/messages`,
|
|
3639
|
+
anthropicHeaders(config),
|
|
3640
|
+
{
|
|
3641
|
+
model: config.model,
|
|
3642
|
+
max_tokens: 4096,
|
|
3643
|
+
messages: [
|
|
3644
|
+
{ role: "user", content: prompt }
|
|
3645
|
+
]
|
|
3646
|
+
}
|
|
3647
|
+
);
|
|
3648
|
+
return extractAnthropicText(data);
|
|
3649
|
+
}
|
|
3650
|
+
async function generateWithOpenAi(prompt, config) {
|
|
3651
|
+
const data = await postJson(
|
|
3652
|
+
"OpenAI",
|
|
3653
|
+
`${apiRoot(config)}/v1/chat/completions`,
|
|
3654
|
+
openAiHeaders(config),
|
|
3655
|
+
{
|
|
3656
|
+
model: config.model,
|
|
3657
|
+
messages: [
|
|
3658
|
+
{ role: "user", content: prompt }
|
|
3659
|
+
],
|
|
3660
|
+
max_tokens: 4096
|
|
3661
|
+
}
|
|
3662
|
+
);
|
|
3663
|
+
return extractOpenAiText(data);
|
|
3664
|
+
}
|
|
3665
|
+
function buildVisionPrompt(assertion) {
|
|
3666
|
+
return [
|
|
3667
|
+
"You are a meticulous QA reviewer. You are given a screenshot of an application",
|
|
3668
|
+
"and a single assertion describing what should be true about it.",
|
|
3669
|
+
"",
|
|
3670
|
+
"Assertion:",
|
|
3671
|
+
assertion,
|
|
3672
|
+
"",
|
|
3673
|
+
"Decide whether the assertion holds for the screenshot. Judge ONLY what is",
|
|
3674
|
+
"visible; do not assume behavior you cannot see. Be strict: if the assertion",
|
|
3675
|
+
"is not clearly satisfied, it fails.",
|
|
3676
|
+
"",
|
|
3677
|
+
"Reply with ONLY a single JSON object on one line, no markdown, no code fences:",
|
|
3678
|
+
'{"pass": <true|false>, "reason": "<one concise sentence explaining the verdict>"}'
|
|
3679
|
+
].join("\n");
|
|
3680
|
+
}
|
|
3681
|
+
function parseVisionVerdict(raw) {
|
|
3682
|
+
const cleaned = stripCodeFences(raw).trim();
|
|
3683
|
+
const candidate = extractJsonObject(cleaned);
|
|
3684
|
+
if (candidate === null) {
|
|
3685
|
+
throw new Error(
|
|
3686
|
+
`Could not parse a JSON verdict from the AI response: ${truncateForError(raw)}`
|
|
3687
|
+
);
|
|
3688
|
+
}
|
|
3689
|
+
let parsed;
|
|
3690
|
+
try {
|
|
3691
|
+
parsed = JSON.parse(candidate);
|
|
3692
|
+
} catch {
|
|
3693
|
+
throw new Error(
|
|
3694
|
+
`AI verdict was not valid JSON: ${truncateForError(raw)}`
|
|
3695
|
+
);
|
|
3696
|
+
}
|
|
3697
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
3698
|
+
throw new Error(`AI verdict was not a JSON object: ${truncateForError(raw)}`);
|
|
3699
|
+
}
|
|
3700
|
+
const record = parsed;
|
|
3701
|
+
if (typeof record.pass !== "boolean") {
|
|
3702
|
+
throw new Error(
|
|
3703
|
+
`AI verdict is missing a boolean "pass" field: ${truncateForError(raw)}`
|
|
3704
|
+
);
|
|
3705
|
+
}
|
|
3706
|
+
const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : record.pass ? "Assertion satisfied." : "Assertion not satisfied.";
|
|
3707
|
+
return { pass: record.pass, reason };
|
|
3708
|
+
}
|
|
3709
|
+
function stripCodeFences(text) {
|
|
3710
|
+
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
3711
|
+
return fence ? fence[1] : text;
|
|
3712
|
+
}
|
|
3713
|
+
function extractJsonObject(text) {
|
|
3714
|
+
const trimmed = text.trim();
|
|
3715
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
3716
|
+
return trimmed;
|
|
3717
|
+
}
|
|
3718
|
+
const first = trimmed.indexOf("{");
|
|
3719
|
+
const last = trimmed.lastIndexOf("}");
|
|
3720
|
+
if (first === -1 || last === -1 || last <= first) {
|
|
3721
|
+
return null;
|
|
3722
|
+
}
|
|
3723
|
+
return trimmed.slice(first, last + 1);
|
|
3724
|
+
}
|
|
3725
|
+
function truncateForError(text) {
|
|
3726
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
3727
|
+
return collapsed.length > 200 ? `${collapsed.slice(0, 199)}\u2026` : collapsed;
|
|
3728
|
+
}
|
|
3729
|
+
async function assertWithAiVision(input, config) {
|
|
3730
|
+
const raw = config.provider === "anthropic" ? await visionWithAnthropic(input, config) : await visionWithOpenAi(input, config);
|
|
3731
|
+
return parseVisionVerdict(raw);
|
|
3732
|
+
}
|
|
3733
|
+
async function visionWithAnthropic(input, config) {
|
|
3734
|
+
const data = await postJson(
|
|
3735
|
+
"Anthropic",
|
|
3736
|
+
`${apiRoot(config)}/v1/messages`,
|
|
3737
|
+
anthropicHeaders(config),
|
|
3738
|
+
{
|
|
3739
|
+
model: config.model,
|
|
3740
|
+
max_tokens: 1024,
|
|
3741
|
+
temperature: 0,
|
|
3742
|
+
messages: [
|
|
3743
|
+
{
|
|
3744
|
+
role: "user",
|
|
3745
|
+
content: [
|
|
3746
|
+
{
|
|
3747
|
+
type: "image",
|
|
3748
|
+
source: {
|
|
3749
|
+
type: "base64",
|
|
3750
|
+
media_type: input.mediaType,
|
|
3751
|
+
data: input.imageBase64
|
|
3752
|
+
}
|
|
3753
|
+
},
|
|
3754
|
+
{ type: "text", text: buildVisionPrompt(input.assertion) }
|
|
3755
|
+
]
|
|
3756
|
+
}
|
|
3757
|
+
]
|
|
3758
|
+
}
|
|
3759
|
+
);
|
|
3760
|
+
return extractAnthropicText(data);
|
|
3761
|
+
}
|
|
3762
|
+
async function visionWithOpenAi(input, config) {
|
|
3763
|
+
const data = await postJson(
|
|
3764
|
+
"OpenAI",
|
|
3765
|
+
`${apiRoot(config)}/v1/chat/completions`,
|
|
3766
|
+
openAiHeaders(config),
|
|
3767
|
+
{
|
|
3768
|
+
model: config.model,
|
|
3769
|
+
max_tokens: 1024,
|
|
3770
|
+
temperature: 0,
|
|
3771
|
+
messages: [
|
|
3772
|
+
{
|
|
3773
|
+
role: "user",
|
|
3774
|
+
content: [
|
|
3775
|
+
{ type: "text", text: buildVisionPrompt(input.assertion) },
|
|
3776
|
+
{
|
|
3777
|
+
type: "image_url",
|
|
3778
|
+
image_url: { url: `data:${input.mediaType};base64,${input.imageBase64}` }
|
|
3779
|
+
}
|
|
3780
|
+
]
|
|
3781
|
+
}
|
|
3782
|
+
]
|
|
3783
|
+
}
|
|
3784
|
+
);
|
|
3785
|
+
return extractOpenAiText(data);
|
|
3786
|
+
}
|
|
3787
|
+
async function postJson(provider, url, headers, body) {
|
|
3788
|
+
const response = await fetch(url, {
|
|
3789
|
+
method: "POST",
|
|
3790
|
+
headers,
|
|
3791
|
+
body: JSON.stringify(body),
|
|
3792
|
+
signal: AbortSignal.timeout(AI_REQUEST_TIMEOUT_MS)
|
|
3793
|
+
});
|
|
3794
|
+
if (!response.ok) {
|
|
3795
|
+
const responseBody = await response.text();
|
|
3796
|
+
throw new Error(`${provider} API error (${response.status}): ${responseBody}`);
|
|
3797
|
+
}
|
|
3798
|
+
return await response.json();
|
|
3799
|
+
}
|
|
3800
|
+
function anthropicHeaders(config) {
|
|
3801
|
+
return {
|
|
3802
|
+
"Content-Type": "application/json",
|
|
3803
|
+
"x-api-key": config.apiKey,
|
|
3804
|
+
"anthropic-version": "2023-06-01"
|
|
3805
|
+
};
|
|
3806
|
+
}
|
|
3807
|
+
function openAiHeaders(config) {
|
|
3808
|
+
return {
|
|
3809
|
+
"Content-Type": "application/json",
|
|
3810
|
+
"Authorization": `Bearer ${config.apiKey}`
|
|
3811
|
+
};
|
|
3812
|
+
}
|
|
3813
|
+
function extractAnthropicText(data) {
|
|
3814
|
+
const textBlock = data.content?.find((c) => c.type === "text");
|
|
3815
|
+
if (!textBlock?.text) {
|
|
3816
|
+
throw new Error("Anthropic API returned no text content");
|
|
3817
|
+
}
|
|
3818
|
+
return textBlock.text;
|
|
3819
|
+
}
|
|
3820
|
+
function extractOpenAiText(data) {
|
|
3821
|
+
if (!data.choices?.[0]?.message?.content) {
|
|
3822
|
+
throw new Error("OpenAI API returned no content");
|
|
3823
|
+
}
|
|
3824
|
+
return data.choices[0].message.content;
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3103
3827
|
// src/runner/steps.ts
|
|
3104
3828
|
function getStepType(step) {
|
|
3105
3829
|
if ("navigate" in step) return "navigate";
|
|
@@ -3128,6 +3852,7 @@ function getStepType(step) {
|
|
|
3128
3852
|
if ("evalScript" in step) return "evalScript";
|
|
3129
3853
|
if ("runScript" in step) return "runScript";
|
|
3130
3854
|
if ("assertScreenshot" in step) return "assertScreenshot";
|
|
3855
|
+
if ("assertWithAI" in step) return "assertWithAI";
|
|
3131
3856
|
if ("copyText" in step) return "copyText";
|
|
3132
3857
|
if ("waitForDownload" in step) return "waitForDownload";
|
|
3133
3858
|
return "step";
|
|
@@ -3187,6 +3912,9 @@ function applyRuntimeVars(step, vars) {
|
|
|
3187
3912
|
}
|
|
3188
3913
|
};
|
|
3189
3914
|
}
|
|
3915
|
+
if ("assertWithAI" in step) {
|
|
3916
|
+
return { assertWithAI: sub(step.assertWithAI) };
|
|
3917
|
+
}
|
|
3190
3918
|
if ("copyText" in step) {
|
|
3191
3919
|
return { copyText: { selector: sub(step.copyText.selector), as: step.copyText.as } };
|
|
3192
3920
|
}
|
|
@@ -3437,7 +4165,7 @@ async function runInlineAssert(driver, policy, assertion) {
|
|
|
3437
4165
|
throw new Error("assert step is missing an assertion type");
|
|
3438
4166
|
}
|
|
3439
4167
|
function screenshotPath(screenshotsDir, fileName) {
|
|
3440
|
-
return
|
|
4168
|
+
return path9.join(screenshotsDir, fileName);
|
|
3441
4169
|
}
|
|
3442
4170
|
function stepPath(prefix, index) {
|
|
3443
4171
|
return prefix ? `${prefix}.${index}` : `${index}`;
|
|
@@ -3454,7 +4182,7 @@ function validateDownloadFilename(suggestedFilename) {
|
|
|
3454
4182
|
const safeFilename = suggestedFilename.trim();
|
|
3455
4183
|
const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
|
|
3456
4184
|
const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
|
|
3457
|
-
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !==
|
|
4185
|
+
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== path9.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
|
|
3458
4186
|
throw new Error(`Invalid download filename: "${suggestedFilename}"`);
|
|
3459
4187
|
}
|
|
3460
4188
|
return safeFilename;
|
|
@@ -3634,7 +4362,7 @@ var STEP_HANDLERS = {
|
|
|
3634
4362
|
if (!("setInputFiles" in h.step)) unknownStep();
|
|
3635
4363
|
const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
|
|
3636
4364
|
const rawFiles = h.step.setInputFiles.files;
|
|
3637
|
-
const resolveFile = (f) =>
|
|
4365
|
+
const resolveFile = (f) => path9.isAbsolute(f) ? f : path9.join(h.context.configDir, f);
|
|
3638
4366
|
const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
|
|
3639
4367
|
await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
|
|
3640
4368
|
h.policy.ensureLocationAllowed(h.driver);
|
|
@@ -3996,11 +4724,11 @@ var STEP_HANDLERS = {
|
|
|
3996
4724
|
if (!responseFile) {
|
|
3997
4725
|
throw new Error("mock.response must include either body or file");
|
|
3998
4726
|
}
|
|
3999
|
-
const candidateFilePath =
|
|
4000
|
-
const resolvedConfigDir =
|
|
4001
|
-
const resolvedFilePath =
|
|
4002
|
-
const relativePath =
|
|
4003
|
-
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${
|
|
4727
|
+
const candidateFilePath = path9.isAbsolute(responseFile) ? responseFile : path9.join(h.context.configDir, responseFile);
|
|
4728
|
+
const resolvedConfigDir = path9.resolve(h.context.configDir);
|
|
4729
|
+
const resolvedFilePath = path9.resolve(candidateFilePath);
|
|
4730
|
+
const relativePath = path9.relative(resolvedConfigDir, resolvedFilePath);
|
|
4731
|
+
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${path9.sep}`) && !path9.isAbsolute(relativePath);
|
|
4004
4732
|
if (!isWithinConfigDir) {
|
|
4005
4733
|
throw new Error("mock.response.file must resolve within config directory");
|
|
4006
4734
|
}
|
|
@@ -4067,7 +4795,7 @@ var STEP_HANDLERS = {
|
|
|
4067
4795
|
capabilities: ["evaluate"],
|
|
4068
4796
|
run: async (h) => {
|
|
4069
4797
|
if (!("runScript" in h.step)) unknownStep();
|
|
4070
|
-
const filePath =
|
|
4798
|
+
const filePath = path9.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : path9.join(h.context.configDir, h.step.runScript.file);
|
|
4071
4799
|
const fileContents = fs8.readFileSync(filePath, "utf-8");
|
|
4072
4800
|
await h.driver.evaluate(fileContents);
|
|
4073
4801
|
return {
|
|
@@ -4089,11 +4817,11 @@ var STEP_HANDLERS = {
|
|
|
4089
4817
|
const name = h.step.assertScreenshot.name;
|
|
4090
4818
|
const threshold = h.step.assertScreenshot.threshold ?? 0.1;
|
|
4091
4819
|
const baselineDir = ensureBaselineDir(h.context.configDir);
|
|
4092
|
-
const baselinePath =
|
|
4093
|
-
const currentScreenshotPath =
|
|
4094
|
-
fs8.mkdirSync(
|
|
4820
|
+
const baselinePath = path9.join(baselineDir, `${name}.png`);
|
|
4821
|
+
const currentScreenshotPath = path9.join(h.context.runDir, "screenshots", `${name}-current.png`);
|
|
4822
|
+
fs8.mkdirSync(path9.dirname(currentScreenshotPath), { recursive: true });
|
|
4095
4823
|
await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
|
|
4096
|
-
h.screenshots.push(
|
|
4824
|
+
h.screenshots.push(path9.join("screenshots", `${name}-current.png`));
|
|
4097
4825
|
if (!fs8.existsSync(baselinePath)) {
|
|
4098
4826
|
fs8.copyFileSync(currentScreenshotPath, baselinePath);
|
|
4099
4827
|
return {
|
|
@@ -4101,7 +4829,7 @@ var STEP_HANDLERS = {
|
|
|
4101
4829
|
result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
|
|
4102
4830
|
};
|
|
4103
4831
|
}
|
|
4104
|
-
const diffPath =
|
|
4832
|
+
const diffPath = path9.join(h.context.runDir, "screenshots", `${name}-diff.png`);
|
|
4105
4833
|
const comparison = await compareScreenshots(baselinePath, currentScreenshotPath, diffPath, threshold);
|
|
4106
4834
|
if (comparison.match) {
|
|
4107
4835
|
return {
|
|
@@ -4114,12 +4842,54 @@ var STEP_HANDLERS = {
|
|
|
4114
4842
|
}
|
|
4115
4843
|
};
|
|
4116
4844
|
}
|
|
4117
|
-
h.screenshots.push(
|
|
4845
|
+
h.screenshots.push(path9.join("screenshots", `${name}-diff.png`));
|
|
4118
4846
|
throw new Error(
|
|
4119
4847
|
`Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
|
|
4120
4848
|
);
|
|
4121
4849
|
}
|
|
4122
4850
|
},
|
|
4851
|
+
assertWithAI: {
|
|
4852
|
+
capabilities: ["screenshot"],
|
|
4853
|
+
run: async (h) => {
|
|
4854
|
+
if (!("assertWithAI" in h.step)) unknownStep();
|
|
4855
|
+
const assertion = h.step.assertWithAI;
|
|
4856
|
+
const resolve = h.context.resolveAiConfig ?? tryResolveAiConfig;
|
|
4857
|
+
const aiConfig = resolve();
|
|
4858
|
+
if (!aiConfig) {
|
|
4859
|
+
return {
|
|
4860
|
+
kind: "result",
|
|
4861
|
+
result: {
|
|
4862
|
+
type: "assertWithAI",
|
|
4863
|
+
status: "warn",
|
|
4864
|
+
durationMs: Date.now() - h.stepStart,
|
|
4865
|
+
value: `skipped: no AI provider configured (set PROWL_AI_KEY to enable) \u2014 "${assertion}"`
|
|
4866
|
+
}
|
|
4867
|
+
};
|
|
4868
|
+
}
|
|
4869
|
+
const fileName = `assertWithAI_step_${h.index + 1}.png`;
|
|
4870
|
+
const relative = await h.addScreenshot(fileName);
|
|
4871
|
+
const screenshotFullPath = path9.join(h.context.runDir, relative);
|
|
4872
|
+
const imageBase64 = fs8.readFileSync(screenshotFullPath).toString("base64");
|
|
4873
|
+
const assertVision = h.context.assertVision ?? assertWithAiVision;
|
|
4874
|
+
const verdict = await assertVision(
|
|
4875
|
+
{ imageBase64, mediaType: "image/png", assertion },
|
|
4876
|
+
aiConfig
|
|
4877
|
+
);
|
|
4878
|
+
if (verdict.pass) {
|
|
4879
|
+
return {
|
|
4880
|
+
kind: "result",
|
|
4881
|
+
result: {
|
|
4882
|
+
type: "assertWithAI",
|
|
4883
|
+
status: "pass",
|
|
4884
|
+
durationMs: Date.now() - h.stepStart,
|
|
4885
|
+
value: verdict.reason,
|
|
4886
|
+
screenshot: relative
|
|
4887
|
+
}
|
|
4888
|
+
};
|
|
4889
|
+
}
|
|
4890
|
+
throw new Error(`AI assertion failed: ${verdict.reason}`);
|
|
4891
|
+
}
|
|
4892
|
+
},
|
|
4123
4893
|
copyText: {
|
|
4124
4894
|
capabilities: ["query"],
|
|
4125
4895
|
run: async (h) => {
|
|
@@ -4156,7 +4926,7 @@ var STEP_HANDLERS = {
|
|
|
4156
4926
|
`Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
|
|
4157
4927
|
);
|
|
4158
4928
|
}
|
|
4159
|
-
const savePath =
|
|
4929
|
+
const savePath = path9.join(h.context.runDir, suggestedFilename);
|
|
4160
4930
|
await download.saveAs(savePath);
|
|
4161
4931
|
return {
|
|
4162
4932
|
kind: "result",
|
|
@@ -4185,7 +4955,7 @@ async function executeSteps(context) {
|
|
|
4185
4955
|
maxSteps: context.maxSteps,
|
|
4186
4956
|
selfHealing: context.selfHealing
|
|
4187
4957
|
});
|
|
4188
|
-
const screenshotsDir =
|
|
4958
|
+
const screenshotsDir = path9.join(context.runDir, "screenshots");
|
|
4189
4959
|
fs8.mkdirSync(screenshotsDir, { recursive: true });
|
|
4190
4960
|
const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
|
|
4191
4961
|
policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
|
|
@@ -4196,7 +4966,7 @@ async function executeSteps(context) {
|
|
|
4196
4966
|
const addScreenshot = async (fileName) => {
|
|
4197
4967
|
const fullPath = screenshotPath(screenshotsDir, fileName);
|
|
4198
4968
|
await captureScreenshot2(driver, fullPath);
|
|
4199
|
-
const relative =
|
|
4969
|
+
const relative = path9.join("screenshots", fileName);
|
|
4200
4970
|
screenshots.push(relative);
|
|
4201
4971
|
return relative;
|
|
4202
4972
|
};
|
|
@@ -4284,12 +5054,12 @@ async function executeSteps(context) {
|
|
|
4284
5054
|
return { results, screenshots, failed: false };
|
|
4285
5055
|
}
|
|
4286
5056
|
async function captureFinalScreenshot(page, runDir) {
|
|
4287
|
-
const screenshotsDir =
|
|
5057
|
+
const screenshotsDir = path9.join(runDir, "screenshots");
|
|
4288
5058
|
fs8.mkdirSync(screenshotsDir, { recursive: true });
|
|
4289
5059
|
const fileName = "final.png";
|
|
4290
5060
|
const filePath = screenshotPath(screenshotsDir, fileName);
|
|
4291
5061
|
await captureScreenshot2(page, filePath);
|
|
4292
|
-
return
|
|
5062
|
+
return path9.join("screenshots", fileName);
|
|
4293
5063
|
}
|
|
4294
5064
|
|
|
4295
5065
|
// src/runner/assertions.ts
|
|
@@ -4411,6 +5181,59 @@ async function evaluateAssertions(options) {
|
|
|
4411
5181
|
}
|
|
4412
5182
|
return results;
|
|
4413
5183
|
}
|
|
5184
|
+
async function evaluateNativeAssertions(options) {
|
|
5185
|
+
const assertions = mergeAssertions(options.config, options.huntAssertions);
|
|
5186
|
+
const authoredTypes = new Set(
|
|
5187
|
+
(options.huntAssertions ?? []).map((assertion) => Object.keys(assertion)[0])
|
|
5188
|
+
);
|
|
5189
|
+
const results = [];
|
|
5190
|
+
const warnings = [];
|
|
5191
|
+
for (const assertion of assertions) {
|
|
5192
|
+
const type = Object.keys(assertion)[0] ?? "assertion";
|
|
5193
|
+
try {
|
|
5194
|
+
if ("selectorExists" in assertion) {
|
|
5195
|
+
options.assertAllowedSelector?.(assertion.selectorExists);
|
|
5196
|
+
const count = await options.driver.count(assertion.selectorExists);
|
|
5197
|
+
results.push({
|
|
5198
|
+
type: "selectorExists",
|
|
5199
|
+
value: assertion.selectorExists,
|
|
5200
|
+
status: count > 0 ? "pass" : "fail",
|
|
5201
|
+
error: count > 0 ? void 0 : "Selector not found"
|
|
5202
|
+
});
|
|
5203
|
+
continue;
|
|
5204
|
+
}
|
|
5205
|
+
if ("selectorNotExists" in assertion) {
|
|
5206
|
+
options.assertAllowedSelector?.(assertion.selectorNotExists);
|
|
5207
|
+
const count = await options.driver.count(assertion.selectorNotExists);
|
|
5208
|
+
results.push({
|
|
5209
|
+
type: "selectorNotExists",
|
|
5210
|
+
value: assertion.selectorNotExists,
|
|
5211
|
+
status: count === 0 ? "pass" : "fail",
|
|
5212
|
+
error: count === 0 ? void 0 : "Selector exists"
|
|
5213
|
+
});
|
|
5214
|
+
continue;
|
|
5215
|
+
}
|
|
5216
|
+
const rawValue = assertion[type];
|
|
5217
|
+
results.push({
|
|
5218
|
+
type,
|
|
5219
|
+
value: rawValue,
|
|
5220
|
+
status: "skipped",
|
|
5221
|
+
error: `skipped (web-only): not supported on the ${options.targetLabel} target`
|
|
5222
|
+
});
|
|
5223
|
+
if (authoredTypes.has(type)) {
|
|
5224
|
+
warnings.push(`${type} is web-only; skipped on ${options.targetLabel} target`);
|
|
5225
|
+
}
|
|
5226
|
+
} catch (error) {
|
|
5227
|
+
const detail = error instanceof Error ? error.message : "no error details";
|
|
5228
|
+
results.push({
|
|
5229
|
+
type,
|
|
5230
|
+
status: "fail",
|
|
5231
|
+
error: `Native assertion "${type}" failed: ${detail}`
|
|
5232
|
+
});
|
|
5233
|
+
}
|
|
5234
|
+
}
|
|
5235
|
+
return { results, warnings };
|
|
5236
|
+
}
|
|
4414
5237
|
|
|
4415
5238
|
// src/runner/tracing.ts
|
|
4416
5239
|
var DEFAULT_TRACE_HEADER = "traceparent";
|
|
@@ -4450,17 +5273,17 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
|
|
|
4450
5273
|
|
|
4451
5274
|
// src/reporter/result.ts
|
|
4452
5275
|
import fs9 from "fs";
|
|
4453
|
-
import
|
|
5276
|
+
import path10 from "path";
|
|
4454
5277
|
function writeResult(runDir, result) {
|
|
4455
5278
|
const fileName = "result.json";
|
|
4456
|
-
const fullPath =
|
|
5279
|
+
const fullPath = path10.join(runDir, fileName);
|
|
4457
5280
|
fs9.writeFileSync(fullPath, JSON.stringify(result, null, 2));
|
|
4458
5281
|
return fileName;
|
|
4459
5282
|
}
|
|
4460
5283
|
|
|
4461
5284
|
// src/reporter/summary.ts
|
|
4462
5285
|
import fs10 from "fs";
|
|
4463
|
-
import
|
|
5286
|
+
import path11 from "path";
|
|
4464
5287
|
function escapeMd(text) {
|
|
4465
5288
|
return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
|
|
4466
5289
|
}
|
|
@@ -4538,7 +5361,7 @@ function writeSummary(runDir, result) {
|
|
|
4538
5361
|
}
|
|
4539
5362
|
}
|
|
4540
5363
|
const fileName = "summary.md";
|
|
4541
|
-
const fullPath =
|
|
5364
|
+
const fullPath = path11.join(runDir, fileName);
|
|
4542
5365
|
fs10.writeFileSync(fullPath, `${lines.join("\n")}
|
|
4543
5366
|
`);
|
|
4544
5367
|
return fileName;
|
|
@@ -4546,20 +5369,21 @@ function writeSummary(runDir, result) {
|
|
|
4546
5369
|
|
|
4547
5370
|
// src/reporter/junit.ts
|
|
4548
5371
|
import fs11 from "fs";
|
|
4549
|
-
import
|
|
5372
|
+
import path12 from "path";
|
|
4550
5373
|
function escapeXml(text) {
|
|
4551
5374
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
4552
5375
|
}
|
|
4553
5376
|
function writeJunit(runDir, result) {
|
|
4554
5377
|
const totalTests = result.steps.length + result.assertions.length;
|
|
4555
5378
|
const failures = result.steps.filter((s) => s.status === "fail").length + result.assertions.filter((a) => a.status === "fail").length;
|
|
5379
|
+
const skipped = result.assertions.filter((a) => a.status === "skipped").length;
|
|
4556
5380
|
const timeSeconds = (result.durationMs / 1e3).toFixed(3);
|
|
4557
5381
|
const huntName = escapeXml(result.hunt);
|
|
4558
5382
|
const lines = [];
|
|
4559
5383
|
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
4560
5384
|
lines.push("<testsuites>");
|
|
4561
5385
|
lines.push(
|
|
4562
|
-
` <testsuite name="${huntName}" tests="${totalTests}" failures="${failures}" errors="0" time="${timeSeconds}" timestamp="${escapeXml(result.startedAt)}">`
|
|
5386
|
+
` <testsuite name="${huntName}" tests="${totalTests}" failures="${failures}" errors="0" skipped="${skipped}" time="${timeSeconds}" timestamp="${escapeXml(result.startedAt)}">`
|
|
4563
5387
|
);
|
|
4564
5388
|
for (let i = 0; i < result.steps.length; i++) {
|
|
4565
5389
|
const step = result.steps[i];
|
|
@@ -4583,6 +5407,11 @@ function writeJunit(runDir, result) {
|
|
|
4583
5407
|
lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0">`);
|
|
4584
5408
|
lines.push(` <failure message="${escapedFailureText}" type="assertion">${escapedFailureText}</failure>`);
|
|
4585
5409
|
lines.push(" </testcase>");
|
|
5410
|
+
} else if (assertion.status === "skipped") {
|
|
5411
|
+
const skipText = escapeXml(assertion.error ?? "skipped");
|
|
5412
|
+
lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0">`);
|
|
5413
|
+
lines.push(` <skipped message="${skipText}"/>`);
|
|
5414
|
+
lines.push(" </testcase>");
|
|
4586
5415
|
} else {
|
|
4587
5416
|
lines.push(` <testcase name="${caseName}" classname="${huntName}" time="0"/>`);
|
|
4588
5417
|
}
|
|
@@ -4590,7 +5419,7 @@ function writeJunit(runDir, result) {
|
|
|
4590
5419
|
lines.push(" </testsuite>");
|
|
4591
5420
|
lines.push("</testsuites>");
|
|
4592
5421
|
const fileName = "junit.xml";
|
|
4593
|
-
const fullPath =
|
|
5422
|
+
const fullPath = path12.join(runDir, fileName);
|
|
4594
5423
|
fs11.writeFileSync(fullPath, `${lines.join("\n")}
|
|
4595
5424
|
`);
|
|
4596
5425
|
return fileName;
|
|
@@ -4633,11 +5462,11 @@ function parseViewportFlag(value) {
|
|
|
4633
5462
|
return value;
|
|
4634
5463
|
}
|
|
4635
5464
|
function resolvePath(configDir, inputPath) {
|
|
4636
|
-
if (
|
|
5465
|
+
if (path13.isAbsolute(inputPath)) {
|
|
4637
5466
|
return inputPath;
|
|
4638
5467
|
}
|
|
4639
|
-
const projectRoot =
|
|
4640
|
-
return
|
|
5468
|
+
const projectRoot = path13.dirname(configDir);
|
|
5469
|
+
return path13.join(projectRoot, inputPath);
|
|
4641
5470
|
}
|
|
4642
5471
|
function buildRunResult(options) {
|
|
4643
5472
|
return {
|
|
@@ -4656,7 +5485,7 @@ function buildRunResult(options) {
|
|
|
4656
5485
|
}
|
|
4657
5486
|
function writeConsoleLog(runDir, entries) {
|
|
4658
5487
|
const fileName = "console.log";
|
|
4659
|
-
const filePath =
|
|
5488
|
+
const filePath = path13.join(runDir, fileName);
|
|
4660
5489
|
const lines = entries.map((entry) => {
|
|
4661
5490
|
const location = entry.location ? ` (${entry.location})` : "";
|
|
4662
5491
|
return `[${entry.type}] ${entry.text}${location}`;
|
|
@@ -4669,7 +5498,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
4669
5498
|
const headless = options.headed ? false : config.browser.headless;
|
|
4670
5499
|
const slowMo = options.slowMo ?? config.browser.slowMo;
|
|
4671
5500
|
const maxSteps = config.guardrails.maxSteps;
|
|
4672
|
-
const runDir =
|
|
5501
|
+
const runDir = path13.join(configDir, "runs", timestamp());
|
|
4673
5502
|
fs12.mkdirSync(runDir, { recursive: true });
|
|
4674
5503
|
const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
|
|
4675
5504
|
const engine = options.browser ?? config.browser.engine;
|
|
@@ -4853,7 +5682,7 @@ async function runHunt(options) {
|
|
|
4853
5682
|
}
|
|
4854
5683
|
async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
|
|
4855
5684
|
const maxSteps = config.guardrails.maxSteps;
|
|
4856
|
-
const runDir =
|
|
5685
|
+
const runDir = path13.join(configDir, "runs", timestamp());
|
|
4857
5686
|
fs12.mkdirSync(runDir, { recursive: true });
|
|
4858
5687
|
const session = await native.launchSession();
|
|
4859
5688
|
let result;
|
|
@@ -4862,6 +5691,13 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
|
|
|
4862
5691
|
const appIdentity = native.sessionAppIdentity(session);
|
|
4863
5692
|
const targetLabel = `${native.targetType}:${appIdentity}`;
|
|
4864
5693
|
const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
|
|
5694
|
+
const assertionPolicy = createRunPolicy(driver, {
|
|
5695
|
+
forbiddenSelectors: config.guardrails.forbiddenSelectors,
|
|
5696
|
+
allowedDomains: [],
|
|
5697
|
+
allowedApps: effectiveAllowedApps,
|
|
5698
|
+
maxSteps,
|
|
5699
|
+
selfHealing: config.guardrails.selfHealing
|
|
5700
|
+
});
|
|
4865
5701
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4866
5702
|
const startTime = Date.now();
|
|
4867
5703
|
let stepResults = [];
|
|
@@ -4901,8 +5737,19 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
|
|
|
4901
5737
|
} catch {
|
|
4902
5738
|
finalScreenshot = void 0;
|
|
4903
5739
|
}
|
|
5740
|
+
const { results: assertionResults, warnings: assertionWarnings } = await evaluateNativeAssertions({
|
|
5741
|
+
driver,
|
|
5742
|
+
config,
|
|
5743
|
+
huntAssertions: interpolatedHunt.assertions,
|
|
5744
|
+
assertAllowedSelector: assertionPolicy.assertAllowedSelector,
|
|
5745
|
+
targetLabel: nativeTargetLabel(native.targetType)
|
|
5746
|
+
});
|
|
5747
|
+
for (const warning of assertionWarnings) {
|
|
5748
|
+
console.warn(warning);
|
|
5749
|
+
}
|
|
4904
5750
|
const durationMs = Date.now() - startTime;
|
|
4905
|
-
const
|
|
5751
|
+
const assertionsFailed = assertionResults.some((assertion) => assertion.status === "fail");
|
|
5752
|
+
const status = stepFailed || assertionsFailed ? "fail" : "pass";
|
|
4906
5753
|
const artifacts = {
|
|
4907
5754
|
screenshots: finalScreenshot ? [...stepScreenshots, finalScreenshot] : stepScreenshots
|
|
4908
5755
|
};
|
|
@@ -4913,7 +5760,7 @@ async function executeNativeHuntAttempt(options, config, configDir, interpolated
|
|
|
4913
5760
|
hunt: options.huntName,
|
|
4914
5761
|
targetUrl: targetLabel,
|
|
4915
5762
|
steps: stepResults,
|
|
4916
|
-
assertions:
|
|
5763
|
+
assertions: assertionResults,
|
|
4917
5764
|
artifacts
|
|
4918
5765
|
});
|
|
4919
5766
|
result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
|
|
@@ -5026,7 +5873,6 @@ async function runNativeHunt(options, config, configDir, target, native) {
|
|
|
5026
5873
|
const hunt = loadHunt(options.huntName, configDir);
|
|
5027
5874
|
const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
|
|
5028
5875
|
assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
|
|
5029
|
-
assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
|
|
5030
5876
|
native.assertAppAllowed(config.guardrails.allowedApps, target);
|
|
5031
5877
|
const maxSteps = config.guardrails.maxSteps;
|
|
5032
5878
|
if (interpolatedHunt.steps.length > maxSteps) {
|
|
@@ -5067,7 +5913,7 @@ async function runNativeHunt(options, config, configDir, target, native) {
|
|
|
5067
5913
|
}
|
|
5068
5914
|
function recordHistory(configDir, outcome, maxRuns) {
|
|
5069
5915
|
try {
|
|
5070
|
-
const relativeRunDir =
|
|
5916
|
+
const relativeRunDir = path13.relative(configDir, outcome.runDir);
|
|
5071
5917
|
appendEntry(
|
|
5072
5918
|
configDir,
|
|
5073
5919
|
{
|
|
@@ -5189,7 +6035,7 @@ function clusterFailures(failures) {
|
|
|
5189
6035
|
|
|
5190
6036
|
// src/backlog/index.ts
|
|
5191
6037
|
import fs13 from "fs";
|
|
5192
|
-
import
|
|
6038
|
+
import path14 from "path";
|
|
5193
6039
|
|
|
5194
6040
|
// src/backlog/parse.ts
|
|
5195
6041
|
var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
|
|
@@ -5295,7 +6141,7 @@ function buildFailure(hunt) {
|
|
|
5295
6141
|
if (!hunt.runDir) return failure;
|
|
5296
6142
|
let run;
|
|
5297
6143
|
try {
|
|
5298
|
-
const resultJson = readFileOrEmpty(
|
|
6144
|
+
const resultJson = readFileOrEmpty(path14.join(hunt.runDir, "result.json"));
|
|
5299
6145
|
if (!resultJson) return failure;
|
|
5300
6146
|
run = JSON.parse(resultJson);
|
|
5301
6147
|
} catch (error) {
|
|
@@ -5324,8 +6170,8 @@ function extractFailures(suiteResult) {
|
|
|
5324
6170
|
}
|
|
5325
6171
|
function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
5326
6172
|
const projectRoot = options.projectRoot ?? process.cwd();
|
|
5327
|
-
const backlogPath = options.backlogPath ??
|
|
5328
|
-
const resolvedPath = options.resolvedPath ??
|
|
6173
|
+
const backlogPath = options.backlogPath ?? path14.join(projectRoot, "docs", "backlog.md");
|
|
6174
|
+
const resolvedPath = options.resolvedPath ?? path14.join(projectRoot, "docs", "resolved.md");
|
|
5329
6175
|
const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
5330
6176
|
const summary = { created: [], regressions: [], skipped: [], backlogPath };
|
|
5331
6177
|
const failures = extractFailures(suiteResult);
|
|
@@ -5357,18 +6203,18 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
|
5357
6203
|
}
|
|
5358
6204
|
}
|
|
5359
6205
|
if (ticketsToAdd.length > 0) {
|
|
5360
|
-
fs13.mkdirSync(
|
|
6206
|
+
fs13.mkdirSync(path14.dirname(backlogPath), { recursive: true });
|
|
5361
6207
|
fs13.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
|
|
5362
6208
|
}
|
|
5363
6209
|
return summary;
|
|
5364
6210
|
}
|
|
5365
6211
|
|
|
5366
6212
|
// src/runner/suite.ts
|
|
5367
|
-
import
|
|
6213
|
+
import path16 from "path";
|
|
5368
6214
|
|
|
5369
6215
|
// src/reporter/ci-summary.ts
|
|
5370
6216
|
import fs14 from "fs";
|
|
5371
|
-
import
|
|
6217
|
+
import path15 from "path";
|
|
5372
6218
|
import chalk from "chalk";
|
|
5373
6219
|
function countCiResults(results) {
|
|
5374
6220
|
return {
|
|
@@ -5433,7 +6279,7 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
|
|
|
5433
6279
|
...clusters.length > 0 ? { clusters } : {}
|
|
5434
6280
|
};
|
|
5435
6281
|
fs14.mkdirSync(ciRunDir, { recursive: true });
|
|
5436
|
-
const filePath =
|
|
6282
|
+
const filePath = path15.join(ciRunDir, "ci-result.json");
|
|
5437
6283
|
fs14.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
|
|
5438
6284
|
return filePath;
|
|
5439
6285
|
}
|
|
@@ -5619,7 +6465,7 @@ async function runSuite(options = {}) {
|
|
|
5619
6465
|
const clusters = clusterFailures(
|
|
5620
6466
|
extractFailures({ result: { hunts: results }, resultPath: null })
|
|
5621
6467
|
).filter((cluster) => cluster.count > 1);
|
|
5622
|
-
const ciRunDir =
|
|
6468
|
+
const ciRunDir = path16.join(configDir, "runs", timestamp("ci"));
|
|
5623
6469
|
const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
|
|
5624
6470
|
const { passed, failed, skipped } = countCiResults(results);
|
|
5625
6471
|
return {
|
|
@@ -5882,6 +6728,273 @@ async function readStatusMenu(client, menuTimeoutSeconds) {
|
|
|
5882
6728
|
}
|
|
5883
6729
|
}
|
|
5884
6730
|
|
|
6731
|
+
// src/analyzer/android.ts
|
|
6732
|
+
var ANDROID_INTERACTIVE_CLASSES = /* @__PURE__ */ new Set([
|
|
6733
|
+
"android.widget.Button",
|
|
6734
|
+
"android.widget.ImageButton",
|
|
6735
|
+
"android.widget.EditText",
|
|
6736
|
+
"android.widget.CheckBox",
|
|
6737
|
+
"android.widget.RadioButton",
|
|
6738
|
+
"android.widget.Switch",
|
|
6739
|
+
"android.widget.ToggleButton",
|
|
6740
|
+
"android.widget.Spinner",
|
|
6741
|
+
"android.widget.SeekBar",
|
|
6742
|
+
"android.widget.RatingBar",
|
|
6743
|
+
"android.widget.CompoundButton",
|
|
6744
|
+
"android.widget.AutoCompleteTextView",
|
|
6745
|
+
"android.widget.MultiAutoCompleteTextView",
|
|
6746
|
+
"android.widget.CheckedTextView",
|
|
6747
|
+
"androidx.appcompat.widget.SwitchCompat",
|
|
6748
|
+
"androidx.appcompat.widget.AppCompatButton",
|
|
6749
|
+
"androidx.appcompat.widget.AppCompatEditText"
|
|
6750
|
+
]);
|
|
6751
|
+
function str2(value) {
|
|
6752
|
+
if (value === void 0) {
|
|
6753
|
+
return void 0;
|
|
6754
|
+
}
|
|
6755
|
+
const trimmed = value.trim();
|
|
6756
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
6757
|
+
}
|
|
6758
|
+
function bool(value) {
|
|
6759
|
+
if (value === void 0) {
|
|
6760
|
+
return void 0;
|
|
6761
|
+
}
|
|
6762
|
+
return value === "true";
|
|
6763
|
+
}
|
|
6764
|
+
function toAndroidNode(element) {
|
|
6765
|
+
const a = element.attrs;
|
|
6766
|
+
return {
|
|
6767
|
+
className: str2(a["class"]),
|
|
6768
|
+
resourceId: str2(a["resource-id"]),
|
|
6769
|
+
contentDesc: str2(a["content-desc"]),
|
|
6770
|
+
text: str2(a.text),
|
|
6771
|
+
package: str2(a.package),
|
|
6772
|
+
clickable: bool(a.clickable),
|
|
6773
|
+
longClickable: bool(a["long-clickable"]),
|
|
6774
|
+
checkable: bool(a.checkable),
|
|
6775
|
+
checked: bool(a.checked),
|
|
6776
|
+
scrollable: bool(a.scrollable),
|
|
6777
|
+
focusable: bool(a.focusable),
|
|
6778
|
+
focused: bool(a.focused),
|
|
6779
|
+
enabled: bool(a.enabled),
|
|
6780
|
+
children: element.children.map(toAndroidNode)
|
|
6781
|
+
};
|
|
6782
|
+
}
|
|
6783
|
+
function parseAndroidHierarchy(xml) {
|
|
6784
|
+
const root = parseXml(xml);
|
|
6785
|
+
return root ? toAndroidNode(root) : null;
|
|
6786
|
+
}
|
|
6787
|
+
function isAndroidInteractive(node) {
|
|
6788
|
+
if (node.clickable || node.longClickable || node.checkable || node.scrollable) {
|
|
6789
|
+
return true;
|
|
6790
|
+
}
|
|
6791
|
+
return node.className !== void 0 && ANDROID_INTERACTIVE_CLASSES.has(node.className);
|
|
6792
|
+
}
|
|
6793
|
+
function rankAndroidSelectors(node) {
|
|
6794
|
+
return rankNativeSelectors({
|
|
6795
|
+
id: node.resourceId,
|
|
6796
|
+
label: node.contentDesc,
|
|
6797
|
+
role: node.className,
|
|
6798
|
+
name: node.text
|
|
6799
|
+
});
|
|
6800
|
+
}
|
|
6801
|
+
function androidNodeToNative(node) {
|
|
6802
|
+
return {
|
|
6803
|
+
...node.resourceId !== void 0 ? { id: node.resourceId } : {},
|
|
6804
|
+
...node.contentDesc !== void 0 ? { label: node.contentDesc } : {},
|
|
6805
|
+
...node.className !== void 0 ? { role: node.className } : {},
|
|
6806
|
+
textValues: node.text !== void 0 ? [node.text] : [],
|
|
6807
|
+
...node.focused !== void 0 ? { focused: node.focused } : {}
|
|
6808
|
+
};
|
|
6809
|
+
}
|
|
6810
|
+
function matchAndroidSelector(xml, selector, options = {}) {
|
|
6811
|
+
const parsedSelector = parseNativeSelector(selector);
|
|
6812
|
+
const root = parseAndroidHierarchy(xml);
|
|
6813
|
+
if (!root) {
|
|
6814
|
+
return [];
|
|
6815
|
+
}
|
|
6816
|
+
return matchNativeTree(
|
|
6817
|
+
ANDROID_MATCH_DIALECT,
|
|
6818
|
+
parsedSelector,
|
|
6819
|
+
root,
|
|
6820
|
+
androidNodeToNative,
|
|
6821
|
+
(node) => node.children,
|
|
6822
|
+
options
|
|
6823
|
+
);
|
|
6824
|
+
}
|
|
6825
|
+
function toElement2(node) {
|
|
6826
|
+
return {
|
|
6827
|
+
className: node.className ?? "?",
|
|
6828
|
+
...node.resourceId ? { resourceId: node.resourceId } : {},
|
|
6829
|
+
...node.contentDesc ? { contentDesc: node.contentDesc } : {},
|
|
6830
|
+
...node.text ? { text: node.text } : {},
|
|
6831
|
+
...node.clickable !== void 0 ? { clickable: node.clickable } : {},
|
|
6832
|
+
...node.checkable !== void 0 ? { checkable: node.checkable } : {},
|
|
6833
|
+
...node.scrollable !== void 0 ? { scrollable: node.scrollable } : {},
|
|
6834
|
+
...node.enabled !== void 0 ? { enabled: node.enabled } : {},
|
|
6835
|
+
selectors: rankAndroidSelectors(node)
|
|
6836
|
+
};
|
|
6837
|
+
}
|
|
6838
|
+
function collectInteractive2(root) {
|
|
6839
|
+
const out = [];
|
|
6840
|
+
const visit = (node) => {
|
|
6841
|
+
if (isAndroidInteractive(node)) {
|
|
6842
|
+
out.push(toElement2(node));
|
|
6843
|
+
}
|
|
6844
|
+
for (const child of node.children) {
|
|
6845
|
+
visit(child);
|
|
6846
|
+
}
|
|
6847
|
+
};
|
|
6848
|
+
visit(root);
|
|
6849
|
+
return out;
|
|
6850
|
+
}
|
|
6851
|
+
async function analyzeAndroidApp(client, options) {
|
|
6852
|
+
const xml = await client.source();
|
|
6853
|
+
const root = parseAndroidHierarchy(xml);
|
|
6854
|
+
const elements = root ? collectInteractive2(root) : [];
|
|
6855
|
+
return { app: options.app, elements };
|
|
6856
|
+
}
|
|
6857
|
+
|
|
6858
|
+
// src/analyzer/ios.ts
|
|
6859
|
+
var IOS_INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
|
|
6860
|
+
"XCUIElementTypeButton",
|
|
6861
|
+
"XCUIElementTypeCell",
|
|
6862
|
+
"XCUIElementTypeTextField",
|
|
6863
|
+
"XCUIElementTypeSecureTextField",
|
|
6864
|
+
"XCUIElementTypeSearchField",
|
|
6865
|
+
"XCUIElementTypeSwitch",
|
|
6866
|
+
"XCUIElementTypeToggle",
|
|
6867
|
+
"XCUIElementTypeLink",
|
|
6868
|
+
"XCUIElementTypeMenuItem",
|
|
6869
|
+
"XCUIElementTypeSlider",
|
|
6870
|
+
"XCUIElementTypeStepper",
|
|
6871
|
+
"XCUIElementTypeTextView",
|
|
6872
|
+
"XCUIElementTypePickerWheel",
|
|
6873
|
+
"XCUIElementTypeTab",
|
|
6874
|
+
"XCUIElementTypeSegmentedControl",
|
|
6875
|
+
"XCUIElementTypeCheckBox",
|
|
6876
|
+
"XCUIElementTypeRadioButton",
|
|
6877
|
+
"XCUIElementTypeKey"
|
|
6878
|
+
]);
|
|
6879
|
+
var IOS_WINDOW_TYPE = "XCUIElementTypeWindow";
|
|
6880
|
+
function str3(value) {
|
|
6881
|
+
if (value === void 0) {
|
|
6882
|
+
return void 0;
|
|
6883
|
+
}
|
|
6884
|
+
const trimmed = value.trim();
|
|
6885
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
6886
|
+
}
|
|
6887
|
+
function bool2(value) {
|
|
6888
|
+
if (value === void 0) {
|
|
6889
|
+
return void 0;
|
|
6890
|
+
}
|
|
6891
|
+
return value === "true" || value === "1";
|
|
6892
|
+
}
|
|
6893
|
+
function toIosNode(element) {
|
|
6894
|
+
const a = element.attrs;
|
|
6895
|
+
return {
|
|
6896
|
+
type: str3(a.type) ?? str3(element.tag),
|
|
6897
|
+
name: str3(a.name),
|
|
6898
|
+
label: str3(a.label),
|
|
6899
|
+
value: str3(a.value),
|
|
6900
|
+
enabled: bool2(a.enabled),
|
|
6901
|
+
visible: bool2(a.visible),
|
|
6902
|
+
children: element.children.map(toIosNode)
|
|
6903
|
+
};
|
|
6904
|
+
}
|
|
6905
|
+
function parseIosHierarchy(xml) {
|
|
6906
|
+
const root = parseXml(xml);
|
|
6907
|
+
return root ? toIosNode(root) : null;
|
|
6908
|
+
}
|
|
6909
|
+
function hasAccessibilityId(node) {
|
|
6910
|
+
return node.name !== void 0 && node.name !== node.label;
|
|
6911
|
+
}
|
|
6912
|
+
function rankIosSelectors(node) {
|
|
6913
|
+
return rankNativeSelectors({
|
|
6914
|
+
id: hasAccessibilityId(node) ? node.name : void 0,
|
|
6915
|
+
label: node.label,
|
|
6916
|
+
role: node.type ? shortIosType(node.type) : void 0,
|
|
6917
|
+
name: node.label ?? node.value
|
|
6918
|
+
});
|
|
6919
|
+
}
|
|
6920
|
+
function iosNodeToNative(node) {
|
|
6921
|
+
const textValues = [];
|
|
6922
|
+
if (node.label !== void 0) {
|
|
6923
|
+
textValues.push(node.label);
|
|
6924
|
+
}
|
|
6925
|
+
if (node.value !== void 0) {
|
|
6926
|
+
textValues.push(node.value);
|
|
6927
|
+
}
|
|
6928
|
+
return {
|
|
6929
|
+
...node.name !== void 0 ? { id: node.name } : {},
|
|
6930
|
+
...node.label !== void 0 ? { label: node.label } : {},
|
|
6931
|
+
...node.type !== void 0 ? { role: node.type } : {},
|
|
6932
|
+
textValues
|
|
6933
|
+
};
|
|
6934
|
+
}
|
|
6935
|
+
function matchIosSelector(xml, selector) {
|
|
6936
|
+
const parsedSelector = parseNativeSelector(selector);
|
|
6937
|
+
const root = parseIosHierarchy(xml);
|
|
6938
|
+
if (!root) {
|
|
6939
|
+
return [];
|
|
6940
|
+
}
|
|
6941
|
+
return matchNativeTree(
|
|
6942
|
+
IOS_MATCH_DIALECT,
|
|
6943
|
+
parsedSelector,
|
|
6944
|
+
root,
|
|
6945
|
+
iosNodeToNative,
|
|
6946
|
+
(node) => node.children
|
|
6947
|
+
);
|
|
6948
|
+
}
|
|
6949
|
+
function toElement3(node) {
|
|
6950
|
+
return {
|
|
6951
|
+
type: node.type ?? "?",
|
|
6952
|
+
...node.name ? { name: node.name } : {},
|
|
6953
|
+
...node.label ? { label: node.label } : {},
|
|
6954
|
+
...node.value ? { value: node.value } : {},
|
|
6955
|
+
...node.enabled !== void 0 ? { enabled: node.enabled } : {},
|
|
6956
|
+
...node.visible !== void 0 ? { visible: node.visible } : {},
|
|
6957
|
+
selectors: rankIosSelectors(node)
|
|
6958
|
+
};
|
|
6959
|
+
}
|
|
6960
|
+
function toWindow2(node) {
|
|
6961
|
+
const [best] = rankIosSelectors(node);
|
|
6962
|
+
return {
|
|
6963
|
+
...node.name ? { name: node.name } : {},
|
|
6964
|
+
...node.label ? { label: node.label } : {},
|
|
6965
|
+
selector: best ?? `role=${shortIosType(IOS_WINDOW_TYPE)}`
|
|
6966
|
+
};
|
|
6967
|
+
}
|
|
6968
|
+
function isIosInteractive(node) {
|
|
6969
|
+
return node.type !== void 0 && IOS_INTERACTIVE_TYPES.has(node.type);
|
|
6970
|
+
}
|
|
6971
|
+
function collect(root) {
|
|
6972
|
+
const elements = [];
|
|
6973
|
+
const windows = [];
|
|
6974
|
+
const visit = (node) => {
|
|
6975
|
+
if (node.type === IOS_WINDOW_TYPE) {
|
|
6976
|
+
windows.push(toWindow2(node));
|
|
6977
|
+
}
|
|
6978
|
+
if (isIosInteractive(node)) {
|
|
6979
|
+
elements.push(toElement3(node));
|
|
6980
|
+
}
|
|
6981
|
+
for (const child of node.children) {
|
|
6982
|
+
visit(child);
|
|
6983
|
+
}
|
|
6984
|
+
};
|
|
6985
|
+
visit(root);
|
|
6986
|
+
return { elements, windows };
|
|
6987
|
+
}
|
|
6988
|
+
async function analyzeIosApp(client, options) {
|
|
6989
|
+
const xml = await client.source();
|
|
6990
|
+
const root = parseIosHierarchy(xml);
|
|
6991
|
+
if (!root) {
|
|
6992
|
+
return { app: options.app, elements: [], windows: [] };
|
|
6993
|
+
}
|
|
6994
|
+
const { elements, windows } = collect(root);
|
|
6995
|
+
return { app: options.app, elements, windows };
|
|
6996
|
+
}
|
|
6997
|
+
|
|
5885
6998
|
// src/generator/index.ts
|
|
5886
6999
|
import yaml from "yaml";
|
|
5887
7000
|
|
|
@@ -5942,6 +7055,7 @@ var STEP_REFERENCE = `
|
|
|
5942
7055
|
|
|
5943
7056
|
### Visual Regression
|
|
5944
7057
|
- assertScreenshot: { name: "baseline-name", threshold: 0.1 }
|
|
7058
|
+
- assertWithAI: "The login form should show email and password fields" \u2014 AI checks the screenshot against the claim
|
|
5945
7059
|
|
|
5946
7060
|
### Control Flow
|
|
5947
7061
|
- if: { visible: ".banner", then: [steps...] }
|
|
@@ -5983,81 +7097,6 @@ function extractYamlFromResponse(response) {
|
|
|
5983
7097
|
return response.trim();
|
|
5984
7098
|
}
|
|
5985
7099
|
|
|
5986
|
-
// src/generator/ai.ts
|
|
5987
|
-
function resolveAiConfig() {
|
|
5988
|
-
const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
|
|
5989
|
-
if (provider !== "anthropic" && provider !== "openai") {
|
|
5990
|
-
throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
|
|
5991
|
-
}
|
|
5992
|
-
const apiKey = process.env.PROWL_AI_KEY;
|
|
5993
|
-
if (!apiKey) {
|
|
5994
|
-
throw new Error(
|
|
5995
|
-
"PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
|
|
5996
|
-
);
|
|
5997
|
-
}
|
|
5998
|
-
const defaultModel = provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
|
|
5999
|
-
const model = process.env.PROWL_AI_MODEL ?? defaultModel;
|
|
6000
|
-
return { provider, model, apiKey };
|
|
6001
|
-
}
|
|
6002
|
-
async function generateWithAi(prompt, config) {
|
|
6003
|
-
if (config.provider === "anthropic") {
|
|
6004
|
-
return generateWithAnthropic(prompt, config);
|
|
6005
|
-
}
|
|
6006
|
-
return generateWithOpenAi(prompt, config);
|
|
6007
|
-
}
|
|
6008
|
-
async function generateWithAnthropic(prompt, config) {
|
|
6009
|
-
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
6010
|
-
method: "POST",
|
|
6011
|
-
headers: {
|
|
6012
|
-
"Content-Type": "application/json",
|
|
6013
|
-
"x-api-key": config.apiKey,
|
|
6014
|
-
"anthropic-version": "2023-06-01"
|
|
6015
|
-
},
|
|
6016
|
-
body: JSON.stringify({
|
|
6017
|
-
model: config.model,
|
|
6018
|
-
max_tokens: 4096,
|
|
6019
|
-
messages: [
|
|
6020
|
-
{ role: "user", content: prompt }
|
|
6021
|
-
]
|
|
6022
|
-
})
|
|
6023
|
-
});
|
|
6024
|
-
if (!response.ok) {
|
|
6025
|
-
const body = await response.text();
|
|
6026
|
-
throw new Error(`Anthropic API error (${response.status}): ${body}`);
|
|
6027
|
-
}
|
|
6028
|
-
const data = await response.json();
|
|
6029
|
-
const textBlock = data.content.find((c) => c.type === "text");
|
|
6030
|
-
if (!textBlock?.text) {
|
|
6031
|
-
throw new Error("Anthropic API returned no text content");
|
|
6032
|
-
}
|
|
6033
|
-
return textBlock.text;
|
|
6034
|
-
}
|
|
6035
|
-
async function generateWithOpenAi(prompt, config) {
|
|
6036
|
-
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
6037
|
-
method: "POST",
|
|
6038
|
-
headers: {
|
|
6039
|
-
"Content-Type": "application/json",
|
|
6040
|
-
"Authorization": `Bearer ${config.apiKey}`
|
|
6041
|
-
},
|
|
6042
|
-
body: JSON.stringify({
|
|
6043
|
-
model: config.model,
|
|
6044
|
-
messages: [
|
|
6045
|
-
{ role: "user", content: prompt }
|
|
6046
|
-
],
|
|
6047
|
-
max_tokens: 4096
|
|
6048
|
-
})
|
|
6049
|
-
});
|
|
6050
|
-
if (!response.ok) {
|
|
6051
|
-
const body = await response.text();
|
|
6052
|
-
throw new Error(`OpenAI API error (${response.status}): ${body}`);
|
|
6053
|
-
}
|
|
6054
|
-
const data = await response.json();
|
|
6055
|
-
if (!data.choices?.[0]?.message?.content) {
|
|
6056
|
-
throw new Error("OpenAI API returned no content");
|
|
6057
|
-
}
|
|
6058
|
-
return data.choices[0].message.content;
|
|
6059
|
-
}
|
|
6060
|
-
|
|
6061
7100
|
// src/generator/index.ts
|
|
6062
7101
|
function parseViewportFlag2(value) {
|
|
6063
7102
|
const match = /^(\d+)x(\d+)$/i.exec(value);
|
|
@@ -6101,6 +7140,298 @@ async function generateHunt(options) {
|
|
|
6101
7140
|
return yamlStr;
|
|
6102
7141
|
}
|
|
6103
7142
|
|
|
7143
|
+
// src/browser/macdriver-install.ts
|
|
7144
|
+
import { execFile as execFile5 } from "child_process";
|
|
7145
|
+
import { createHash as createHash2 } from "crypto";
|
|
7146
|
+
import fs15 from "fs";
|
|
7147
|
+
import os5 from "os";
|
|
7148
|
+
import path17 from "path";
|
|
7149
|
+
import { promisify } from "util";
|
|
7150
|
+
var execFileAsync = promisify(execFile5);
|
|
7151
|
+
function parseChecksumFile(text) {
|
|
7152
|
+
const token = text.trim().split(/\s+/, 1)[0] ?? "";
|
|
7153
|
+
const digest = token.toLowerCase();
|
|
7154
|
+
if (!/^[0-9a-f]{64}$/.test(digest)) {
|
|
7155
|
+
throw new Error(`Malformed .sha256 checksum file (expected a 64-char hex digest, got: ${text.trim().slice(0, 80)})`);
|
|
7156
|
+
}
|
|
7157
|
+
return digest;
|
|
7158
|
+
}
|
|
7159
|
+
function sha256Hex(bytes) {
|
|
7160
|
+
return createHash2("sha256").update(bytes).digest("hex");
|
|
7161
|
+
}
|
|
7162
|
+
var commandRunner = async (file, args) => execFileAsync(file, args);
|
|
7163
|
+
function commandOutput(result) {
|
|
7164
|
+
return [result.stdout, result.stderr].filter((value) => value !== void 0).map((value) => value.toString()).join("\n").trim();
|
|
7165
|
+
}
|
|
7166
|
+
function commandErrorDetail(error) {
|
|
7167
|
+
const err = error;
|
|
7168
|
+
return [err.stderr, err.stdout, err.message].filter((value) => value !== void 0 && value !== "").map((value) => value.toString()).join("\n").trim();
|
|
7169
|
+
}
|
|
7170
|
+
async function runRequiredCommand(run, binaryPath, command, args, label) {
|
|
7171
|
+
try {
|
|
7172
|
+
return await run(command, args);
|
|
7173
|
+
} catch (error) {
|
|
7174
|
+
const detail = commandErrorDetail(error);
|
|
7175
|
+
throw new Error(`${label} failed for ${binaryPath}${detail ? `: ${detail}` : ""}`);
|
|
7176
|
+
}
|
|
7177
|
+
}
|
|
7178
|
+
var zipinfoArchiveLister = async (zipPath) => {
|
|
7179
|
+
try {
|
|
7180
|
+
const { stdout } = await execFileAsync("zipinfo", ["-1", zipPath]);
|
|
7181
|
+
return stdout.toString().split(/\r?\n/).filter((entry) => entry.length > 0);
|
|
7182
|
+
} catch (error) {
|
|
7183
|
+
const detail = commandErrorDetail(error);
|
|
7184
|
+
throw new Error(`Failed to inspect release archive ${zipPath} with zipinfo${detail ? `: ${detail}` : ""}`);
|
|
7185
|
+
}
|
|
7186
|
+
};
|
|
7187
|
+
function normalizeArchiveEntryName(entry) {
|
|
7188
|
+
if (entry.length === 0 || entry !== entry.trim() || entry.includes("\0") || entry.includes("\\")) {
|
|
7189
|
+
throw new Error(`Unsafe path in prowl-macdriver release archive: ${JSON.stringify(entry)}`);
|
|
7190
|
+
}
|
|
7191
|
+
if (entry.endsWith("/")) {
|
|
7192
|
+
throw new Error(`Unexpected directory in prowl-macdriver release archive: ${entry}`);
|
|
7193
|
+
}
|
|
7194
|
+
if (path17.posix.isAbsolute(entry)) {
|
|
7195
|
+
throw new Error(`Unsafe absolute path in prowl-macdriver release archive: ${entry}`);
|
|
7196
|
+
}
|
|
7197
|
+
const parts = entry.split("/");
|
|
7198
|
+
if (parts.some((part) => part === "" || part === "." || part === "..")) {
|
|
7199
|
+
throw new Error(`Unsafe path in prowl-macdriver release archive: ${entry}`);
|
|
7200
|
+
}
|
|
7201
|
+
return entry;
|
|
7202
|
+
}
|
|
7203
|
+
function validateMacdriverArchiveEntries(entries) {
|
|
7204
|
+
const normalized = entries.map(normalizeArchiveEntryName);
|
|
7205
|
+
if (normalized.length !== 1 || normalized[0] !== HELPER_BINARY) {
|
|
7206
|
+
const shown = normalized.length > 0 ? normalized.join(", ") : "(empty archive)";
|
|
7207
|
+
throw new Error(
|
|
7208
|
+
`Unexpected prowl-macdriver release archive contents: ${shown}. Expected exactly "${HELPER_BINARY}" at the archive root.`
|
|
7209
|
+
);
|
|
7210
|
+
}
|
|
7211
|
+
}
|
|
7212
|
+
var dittoExtractor = async (zipPath, destDir) => {
|
|
7213
|
+
await execFileAsync("ditto", ["-x", "-k", zipPath, destDir]);
|
|
7214
|
+
};
|
|
7215
|
+
function parseCodesignDetails(text) {
|
|
7216
|
+
const details = { identifier: null, authorities: [], teamIdentifier: null };
|
|
7217
|
+
for (const line of text.split(/\r?\n/)) {
|
|
7218
|
+
const trimmed = line.trim();
|
|
7219
|
+
if (trimmed.startsWith("Identifier=")) {
|
|
7220
|
+
details.identifier = trimmed.slice("Identifier=".length);
|
|
7221
|
+
} else if (trimmed.startsWith("Authority=")) {
|
|
7222
|
+
details.authorities.push(trimmed.slice("Authority=".length));
|
|
7223
|
+
} else if (trimmed.startsWith("TeamIdentifier=")) {
|
|
7224
|
+
details.teamIdentifier = trimmed.slice("TeamIdentifier=".length);
|
|
7225
|
+
}
|
|
7226
|
+
}
|
|
7227
|
+
return details;
|
|
7228
|
+
}
|
|
7229
|
+
function validateCodesignDetails(details, binaryPath) {
|
|
7230
|
+
if (details.identifier !== MACDRIVER_SIGNING_IDENTIFIER) {
|
|
7231
|
+
throw new Error(
|
|
7232
|
+
`codesign verification failed for ${binaryPath}: expected identifier "${MACDRIVER_SIGNING_IDENTIFIER}", got "${details.identifier ?? "missing"}"`
|
|
7233
|
+
);
|
|
7234
|
+
}
|
|
7235
|
+
const developerIdAuthority = details.authorities.find(
|
|
7236
|
+
(authority) => authority.startsWith(`${MACDRIVER_SIGNING_AUTHORITY_PREFIX} (`)
|
|
7237
|
+
);
|
|
7238
|
+
const authorityTeamId = developerIdAuthority?.match(/\(([A-Z0-9]{10})\)$/)?.[1] ?? null;
|
|
7239
|
+
if (!developerIdAuthority || !authorityTeamId) {
|
|
7240
|
+
const shown = details.authorities.length > 0 ? details.authorities.join(" / ") : "missing";
|
|
7241
|
+
throw new Error(
|
|
7242
|
+
`codesign verification failed for ${binaryPath}: expected ${MACDRIVER_SIGNING_AUTHORITY_PREFIX} signer, got ${shown}`
|
|
7243
|
+
);
|
|
7244
|
+
}
|
|
7245
|
+
if (!details.teamIdentifier) {
|
|
7246
|
+
throw new Error(`codesign verification failed for ${binaryPath}: missing TeamIdentifier`);
|
|
7247
|
+
}
|
|
7248
|
+
if (details.teamIdentifier !== authorityTeamId) {
|
|
7249
|
+
throw new Error(
|
|
7250
|
+
`codesign verification failed for ${binaryPath}: TeamIdentifier ${details.teamIdentifier} does not match Developer ID authority team ${authorityTeamId}`
|
|
7251
|
+
);
|
|
7252
|
+
}
|
|
7253
|
+
}
|
|
7254
|
+
async function verifyMacdriverSignature(binaryPath, run = commandRunner) {
|
|
7255
|
+
await runRequiredCommand(run, binaryPath, "codesign", ["--verify", "--strict", binaryPath], "codesign verification");
|
|
7256
|
+
const display = await runRequiredCommand(
|
|
7257
|
+
run,
|
|
7258
|
+
binaryPath,
|
|
7259
|
+
"codesign",
|
|
7260
|
+
["--display", "--verbose=4", binaryPath],
|
|
7261
|
+
"codesign detail inspection"
|
|
7262
|
+
);
|
|
7263
|
+
validateCodesignDetails(parseCodesignDetails(commandOutput(display)), binaryPath);
|
|
7264
|
+
await runRequiredCommand(
|
|
7265
|
+
run,
|
|
7266
|
+
binaryPath,
|
|
7267
|
+
"spctl",
|
|
7268
|
+
["--assess", "--type", "execute", "--verbose=4", binaryPath],
|
|
7269
|
+
"spctl assessment"
|
|
7270
|
+
);
|
|
7271
|
+
}
|
|
7272
|
+
var codesignVerifier = async (binaryPath) => {
|
|
7273
|
+
await verifyMacdriverSignature(binaryPath);
|
|
7274
|
+
};
|
|
7275
|
+
async function downloadAndVerify(options = {}) {
|
|
7276
|
+
const version = options.version ?? MACDRIVER_VERSION;
|
|
7277
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
7278
|
+
const assetName = macdriverAssetName(version);
|
|
7279
|
+
const zipUrl = macdriverAssetUrl(assetName, version);
|
|
7280
|
+
const sumUrl = macdriverAssetUrl(macdriverChecksumName(version), version);
|
|
7281
|
+
const [zipRes, sumRes] = await Promise.all([
|
|
7282
|
+
fetchImpl(zipUrl, { redirect: "follow" }),
|
|
7283
|
+
fetchImpl(sumUrl, { redirect: "follow" })
|
|
7284
|
+
]);
|
|
7285
|
+
if (zipRes.status === 404 || sumRes.status === 404) {
|
|
7286
|
+
throw new Error(
|
|
7287
|
+
`No published prowl-macdriver release for ${macdriverReleaseTag(version)} yet.
|
|
7288
|
+
The signed binary is cut by the maintainer; until then build from source:
|
|
7289
|
+
cd macdriver && swift build -c release`
|
|
7290
|
+
);
|
|
7291
|
+
}
|
|
7292
|
+
if (!zipRes.ok) {
|
|
7293
|
+
throw new Error(`Failed to download ${assetName} (HTTP ${zipRes.status}) from ${zipUrl}`);
|
|
7294
|
+
}
|
|
7295
|
+
if (!sumRes.ok) {
|
|
7296
|
+
throw new Error(`Failed to download the checksum (HTTP ${sumRes.status}) from ${sumUrl}`);
|
|
7297
|
+
}
|
|
7298
|
+
const zipBytes = Buffer.from(await zipRes.arrayBuffer());
|
|
7299
|
+
const expected = parseChecksumFile(await sumRes.text());
|
|
7300
|
+
const actual = sha256Hex(zipBytes);
|
|
7301
|
+
if (actual !== expected) {
|
|
7302
|
+
throw new Error(
|
|
7303
|
+
`Checksum mismatch for ${assetName}.
|
|
7304
|
+
expected: ${expected}
|
|
7305
|
+
actual: ${actual}
|
|
7306
|
+
The download was rejected and discarded; re-run the install, and report it if it repeats.`
|
|
7307
|
+
);
|
|
7308
|
+
}
|
|
7309
|
+
return zipBytes;
|
|
7310
|
+
}
|
|
7311
|
+
async function installMacdriver(options = {}) {
|
|
7312
|
+
const version = validateMacdriverVersion(options.version ?? MACDRIVER_VERSION);
|
|
7313
|
+
const homedir = options.homedir ?? os5.homedir();
|
|
7314
|
+
const listArchiveEntries = options.listArchiveEntries ?? zipinfoArchiveLister;
|
|
7315
|
+
const extract = options.extract ?? dittoExtractor;
|
|
7316
|
+
const verifySignature = options.verifySignature ?? codesignVerifier;
|
|
7317
|
+
const installRoot = macdriverInstallRoot(homedir);
|
|
7318
|
+
const versionDir = macdriverVersionDir(version, homedir);
|
|
7319
|
+
const binaryPath = macdriverInstalledBinary(version, homedir);
|
|
7320
|
+
if (!options.force && fs15.existsSync(binaryPath)) {
|
|
7321
|
+
return { version, binaryPath, alreadyInstalled: true };
|
|
7322
|
+
}
|
|
7323
|
+
const zipBytes = await downloadAndVerify({ version, fetchImpl: options.fetchImpl });
|
|
7324
|
+
fs15.mkdirSync(installRoot, { recursive: true });
|
|
7325
|
+
const stagingDir = fs15.mkdtempSync(path17.join(installRoot, `.tmp-${version}-`));
|
|
7326
|
+
const extractDir = path17.join(stagingDir, "extract");
|
|
7327
|
+
const zipPath = path17.join(stagingDir, macdriverAssetName(version));
|
|
7328
|
+
try {
|
|
7329
|
+
fs15.mkdirSync(extractDir);
|
|
7330
|
+
fs15.writeFileSync(zipPath, zipBytes);
|
|
7331
|
+
validateMacdriverArchiveEntries(await listArchiveEntries(zipPath));
|
|
7332
|
+
await extract(zipPath, extractDir);
|
|
7333
|
+
const stagedBinaryPath = path17.join(extractDir, HELPER_BINARY);
|
|
7334
|
+
assertExtractedHelper(extractDir, stagedBinaryPath);
|
|
7335
|
+
fs15.chmodSync(stagedBinaryPath, 493);
|
|
7336
|
+
await verifySignature(stagedBinaryPath);
|
|
7337
|
+
replaceVersionDir(versionDir, extractDir, installRoot);
|
|
7338
|
+
} finally {
|
|
7339
|
+
fs15.rmSync(stagingDir, { recursive: true, force: true });
|
|
7340
|
+
}
|
|
7341
|
+
return { version, binaryPath, alreadyInstalled: false };
|
|
7342
|
+
}
|
|
7343
|
+
function assertExtractedHelper(extractDir, binaryPath) {
|
|
7344
|
+
const entries = fs15.readdirSync(extractDir);
|
|
7345
|
+
if (!entries.includes(HELPER_BINARY)) {
|
|
7346
|
+
throw new Error(`The release archive did not contain a "${HELPER_BINARY}" binary.`);
|
|
7347
|
+
}
|
|
7348
|
+
if (entries.length !== 1 || entries[0] !== HELPER_BINARY) {
|
|
7349
|
+
const shown = entries.length > 0 ? entries.join(", ") : "(empty directory)";
|
|
7350
|
+
throw new Error(
|
|
7351
|
+
`Unexpected extracted prowl-macdriver archive contents: ${shown}. Expected exactly "${HELPER_BINARY}".`
|
|
7352
|
+
);
|
|
7353
|
+
}
|
|
7354
|
+
let stat = null;
|
|
7355
|
+
try {
|
|
7356
|
+
stat = fs15.lstatSync(binaryPath);
|
|
7357
|
+
} catch {
|
|
7358
|
+
}
|
|
7359
|
+
if (!stat?.isFile()) {
|
|
7360
|
+
throw new Error(`The release archive "${HELPER_BINARY}" entry is not a regular file.`);
|
|
7361
|
+
}
|
|
7362
|
+
}
|
|
7363
|
+
function replaceVersionDir(versionDir, stagedVersionDir, installRoot) {
|
|
7364
|
+
const backupDir = path17.join(installRoot, `.previous-${path17.basename(versionDir)}-${process.pid}-${Date.now()}`);
|
|
7365
|
+
let backedUp = false;
|
|
7366
|
+
try {
|
|
7367
|
+
if (fs15.existsSync(versionDir)) {
|
|
7368
|
+
fs15.renameSync(versionDir, backupDir);
|
|
7369
|
+
backedUp = true;
|
|
7370
|
+
}
|
|
7371
|
+
fs15.renameSync(stagedVersionDir, versionDir);
|
|
7372
|
+
if (backedUp) {
|
|
7373
|
+
fs15.rmSync(backupDir, { recursive: true, force: true });
|
|
7374
|
+
}
|
|
7375
|
+
} catch (error) {
|
|
7376
|
+
if (backedUp && !fs15.existsSync(versionDir) && fs15.existsSync(backupDir)) {
|
|
7377
|
+
fs15.renameSync(backupDir, versionDir);
|
|
7378
|
+
}
|
|
7379
|
+
throw error;
|
|
7380
|
+
}
|
|
7381
|
+
}
|
|
7382
|
+
var runVersionProbe = async (binaryPath) => {
|
|
7383
|
+
try {
|
|
7384
|
+
const { stdout } = await execFileAsync(binaryPath, ["version"], { timeout: 5e3 });
|
|
7385
|
+
const match = stdout.match(/prowl-macdriver\s+(\S+)/);
|
|
7386
|
+
return match ? match[1] : stdout.trim() || null;
|
|
7387
|
+
} catch {
|
|
7388
|
+
return null;
|
|
7389
|
+
}
|
|
7390
|
+
};
|
|
7391
|
+
async function collectMacdriverStatus(options = {}) {
|
|
7392
|
+
const env = options.env ?? process.env;
|
|
7393
|
+
const homedir = options.homedir ?? os5.homedir();
|
|
7394
|
+
const probe = options.probe ?? runVersionProbe;
|
|
7395
|
+
let resolved = null;
|
|
7396
|
+
try {
|
|
7397
|
+
const resolvedPath = resolveHelperBinary(env, { homedir });
|
|
7398
|
+
resolved = { path: resolvedPath, source: classifyResolvedSource(resolvedPath, env, homedir) };
|
|
7399
|
+
} catch {
|
|
7400
|
+
resolved = null;
|
|
7401
|
+
}
|
|
7402
|
+
const installed = [];
|
|
7403
|
+
const root = macdriverInstallRoot(homedir);
|
|
7404
|
+
if (fs15.existsSync(root)) {
|
|
7405
|
+
for (const entry of fs15.readdirSync(root, { withFileTypes: true })) {
|
|
7406
|
+
if (!entry.isDirectory()) continue;
|
|
7407
|
+
let binaryPath;
|
|
7408
|
+
try {
|
|
7409
|
+
binaryPath = macdriverInstalledBinary(entry.name, homedir);
|
|
7410
|
+
} catch {
|
|
7411
|
+
continue;
|
|
7412
|
+
}
|
|
7413
|
+
if (fs15.existsSync(binaryPath)) {
|
|
7414
|
+
installed.push({ version: entry.name, binaryPath });
|
|
7415
|
+
}
|
|
7416
|
+
}
|
|
7417
|
+
installed.sort((a, b) => a.version.localeCompare(b.version));
|
|
7418
|
+
}
|
|
7419
|
+
const probedVersion = resolved ? await probe(resolved.path) : null;
|
|
7420
|
+
return { resolved, pinnedVersion: MACDRIVER_VERSION, installed, probedVersion };
|
|
7421
|
+
}
|
|
7422
|
+
function classifyResolvedSource(resolvedPath, env, homedir) {
|
|
7423
|
+
if (env.PROWL_MACDRIVER_BIN && resolvedPath === env.PROWL_MACDRIVER_BIN) {
|
|
7424
|
+
return "env";
|
|
7425
|
+
}
|
|
7426
|
+
if (resolvedPath.startsWith(macdriverInstallRoot(homedir) + path17.sep)) {
|
|
7427
|
+
return "user-install";
|
|
7428
|
+
}
|
|
7429
|
+
return "source-build";
|
|
7430
|
+
}
|
|
7431
|
+
function tccGuidance() {
|
|
7432
|
+
return "macOS permissions: the app that hosts Prowl (your terminal \u2014 Terminal, iTerm, VS Code, \u2026)\nmust be granted, in System Settings \u2192 Privacy & Security:\n \u2022 Accessibility \u2014 required to drive the target app\n \u2022 Screen Recording \u2014 required for screenshots / visual baselines\nGrant both to the terminal app, not to prowl-macdriver itself, then re-run your hunt.";
|
|
7433
|
+
}
|
|
7434
|
+
|
|
6104
7435
|
export {
|
|
6105
7436
|
interpolateHunt,
|
|
6106
7437
|
WEB_ONLY_STEP_TYPES,
|
|
@@ -6118,12 +7449,43 @@ export {
|
|
|
6118
7449
|
createPlaywrightDriver,
|
|
6119
7450
|
parseMacSelector,
|
|
6120
7451
|
createMacDriver,
|
|
7452
|
+
HELPER_BINARY,
|
|
7453
|
+
MACDRIVER_VERSION,
|
|
7454
|
+
MACDRIVER_REPO,
|
|
7455
|
+
MACDRIVER_SIGNING_IDENTIFIER,
|
|
7456
|
+
MACDRIVER_SIGNING_AUTHORITY_PREFIX,
|
|
7457
|
+
MACDRIVER_VERSION_PATTERN,
|
|
7458
|
+
validateMacdriverVersion,
|
|
7459
|
+
macdriverReleaseTag,
|
|
7460
|
+
macdriverAssetName,
|
|
7461
|
+
macdriverChecksumName,
|
|
7462
|
+
macdriverAssetUrl,
|
|
7463
|
+
macdriverInstallRoot,
|
|
7464
|
+
macdriverVersionDir,
|
|
7465
|
+
macdriverInstalledBinary,
|
|
6121
7466
|
macdriverBuildInstructions,
|
|
6122
7467
|
resolveHelperBinary,
|
|
6123
7468
|
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
6124
7469
|
SpawnMacHelperClient,
|
|
6125
7470
|
launchMacSession,
|
|
6126
7471
|
closeMacSession,
|
|
7472
|
+
decodeXmlEntities,
|
|
7473
|
+
parseXml,
|
|
7474
|
+
unquoteSelectorValue,
|
|
7475
|
+
quoteSelectorValue,
|
|
7476
|
+
parseNativeSelector,
|
|
7477
|
+
unwrapNativeTextSelector,
|
|
7478
|
+
qualifyResourceId,
|
|
7479
|
+
normalizeXcuiClassName,
|
|
7480
|
+
shortIosType,
|
|
7481
|
+
rankNativeSelectors,
|
|
7482
|
+
NATIVE_ATTRIBUTE_MAP,
|
|
7483
|
+
ANDROID_MATCH_DIALECT,
|
|
7484
|
+
IOS_MATCH_DIALECT,
|
|
7485
|
+
MACOS_MATCH_DIALECT,
|
|
7486
|
+
nodeMatchesSelector,
|
|
7487
|
+
matchNativeTree,
|
|
7488
|
+
parseSnapshot,
|
|
6127
7489
|
ANDROID_KEYCODES,
|
|
6128
7490
|
escapeUiSelectorArg,
|
|
6129
7491
|
parseAndroidSelector,
|
|
@@ -6150,7 +7512,6 @@ export {
|
|
|
6150
7512
|
closeAndroidSession,
|
|
6151
7513
|
IOS_PRESS_KEYS,
|
|
6152
7514
|
escapePredicateArg,
|
|
6153
|
-
normalizeXcuiClassName,
|
|
6154
7515
|
parseIosSelector,
|
|
6155
7516
|
iosQueryToLocator,
|
|
6156
7517
|
unwrapIosTextSelector,
|
|
@@ -6170,11 +7531,14 @@ export {
|
|
|
6170
7531
|
waitForWdaReady,
|
|
6171
7532
|
createWdaAgentClient,
|
|
6172
7533
|
WDA_RUNNER_BUNDLE_ID,
|
|
6173
|
-
|
|
7534
|
+
WDA_USE_PORT_ENV,
|
|
6174
7535
|
defaultIosAgentConnector,
|
|
6175
7536
|
resolveWdaProject,
|
|
6176
7537
|
wdaCacheDir,
|
|
6177
|
-
|
|
7538
|
+
resolveWdaTestRun,
|
|
7539
|
+
injectUsePortIntoXctestrun,
|
|
7540
|
+
defaultWdaTestRunPreparer,
|
|
7541
|
+
wdaTestRunArgs,
|
|
6178
7542
|
launchIosSession,
|
|
6179
7543
|
closeIosSession,
|
|
6180
7544
|
extractSelectorIntent,
|
|
@@ -6197,6 +7561,34 @@ export {
|
|
|
6197
7561
|
DEFAULT_ANALYZE_TREE_DEPTH,
|
|
6198
7562
|
rankMacSelectors,
|
|
6199
7563
|
analyzeMacApp,
|
|
6200
|
-
|
|
7564
|
+
ANDROID_INTERACTIVE_CLASSES,
|
|
7565
|
+
parseAndroidHierarchy,
|
|
7566
|
+
isAndroidInteractive,
|
|
7567
|
+
rankAndroidSelectors,
|
|
7568
|
+
androidNodeToNative,
|
|
7569
|
+
matchAndroidSelector,
|
|
7570
|
+
analyzeAndroidApp,
|
|
7571
|
+
IOS_INTERACTIVE_TYPES,
|
|
7572
|
+
IOS_WINDOW_TYPE,
|
|
7573
|
+
parseIosHierarchy,
|
|
7574
|
+
rankIosSelectors,
|
|
7575
|
+
iosNodeToNative,
|
|
7576
|
+
matchIosSelector,
|
|
7577
|
+
isIosInteractive,
|
|
7578
|
+
analyzeIosApp,
|
|
7579
|
+
generateHunt,
|
|
7580
|
+
parseChecksumFile,
|
|
7581
|
+
sha256Hex,
|
|
7582
|
+
zipinfoArchiveLister,
|
|
7583
|
+
validateMacdriverArchiveEntries,
|
|
7584
|
+
dittoExtractor,
|
|
7585
|
+
parseCodesignDetails,
|
|
7586
|
+
verifyMacdriverSignature,
|
|
7587
|
+
codesignVerifier,
|
|
7588
|
+
downloadAndVerify,
|
|
7589
|
+
installMacdriver,
|
|
7590
|
+
runVersionProbe,
|
|
7591
|
+
collectMacdriverStatus,
|
|
7592
|
+
tccGuidance
|
|
6201
7593
|
};
|
|
6202
|
-
//# sourceMappingURL=chunk-
|
|
7594
|
+
//# sourceMappingURL=chunk-R7NUH44M.js.map
|