prowl-tools 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +251 -9
- package/dist/{chunk-2KD2XCTH.js → chunk-5KQR3IR3.js} +1182 -234
- package/dist/chunk-5KQR3IR3.js.map +1 -0
- package/dist/{chunk-ITOSUJCN.js → chunk-WHAMB4TY.js} +5 -1
- package/dist/chunk-WHAMB4TY.js.map +1 -0
- package/dist/index.cjs +1152 -204
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +173 -10
- package/dist/index.js.map +1 -1
- package/dist/lib.cjs +1220 -232
- package/dist/lib.cjs.map +1 -1
- package/dist/lib.d.cts +540 -53
- package/dist/lib.d.ts +540 -53
- package/dist/lib.js +74 -6
- package/dist/{loader-FCXPARP7.js → loader-PBBYV3U7.js} +2 -2
- package/package.json +1 -1
- package/dist/chunk-2KD2XCTH.js.map +0 -1
- package/dist/chunk-ITOSUJCN.js.map +0 -1
- /package/dist/{loader-FCXPARP7.js.map → loader-PBBYV3U7.js.map} +0 -0
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
loadHunt,
|
|
8
8
|
loadHuntTags,
|
|
9
9
|
resolveViewport
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-WHAMB4TY.js";
|
|
11
11
|
|
|
12
12
|
// src/config/interpolate.ts
|
|
13
13
|
import crypto from "crypto";
|
|
@@ -303,6 +303,11 @@ function interpolateStep(step, vars, stepPath2, redacted) {
|
|
|
303
303
|
}
|
|
304
304
|
};
|
|
305
305
|
}
|
|
306
|
+
if ("assertWithAI" in step) {
|
|
307
|
+
return {
|
|
308
|
+
assertWithAI: interpolateString(step.assertWithAI, vars).value
|
|
309
|
+
};
|
|
310
|
+
}
|
|
306
311
|
if ("copyText" in step) {
|
|
307
312
|
return {
|
|
308
313
|
copyText: {
|
|
@@ -958,6 +963,287 @@ async function closeMacSession(session) {
|
|
|
958
963
|
}
|
|
959
964
|
}
|
|
960
965
|
|
|
966
|
+
// src/analyzer/xml.ts
|
|
967
|
+
var ENTITIES = {
|
|
968
|
+
amp: "&",
|
|
969
|
+
lt: "<",
|
|
970
|
+
gt: ">",
|
|
971
|
+
quot: '"',
|
|
972
|
+
apos: "'"
|
|
973
|
+
};
|
|
974
|
+
function decodeXmlEntities(value) {
|
|
975
|
+
if (!value.includes("&")) {
|
|
976
|
+
return value;
|
|
977
|
+
}
|
|
978
|
+
return value.replace(/&(#(?:[xX][0-9a-fA-F]+|[0-9]+)|[a-zA-Z]+);/g, (match, code) => {
|
|
979
|
+
if (code[0] === "#") {
|
|
980
|
+
const hex = code[1] === "x" || code[1] === "X";
|
|
981
|
+
const num2 = Number.parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10);
|
|
982
|
+
return Number.isSafeInteger(num2) && num2 <= 1114111 ? String.fromCodePoint(num2) : match;
|
|
983
|
+
}
|
|
984
|
+
const named = ENTITIES[code];
|
|
985
|
+
return named ?? match;
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
var ATTR_RE = /([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
|
989
|
+
function parseTagBody(body) {
|
|
990
|
+
const trimmed = body.trim();
|
|
991
|
+
const nameMatch = /^([^\s/>]+)/.exec(trimmed);
|
|
992
|
+
const tag = nameMatch ? nameMatch[1] : "";
|
|
993
|
+
const attrs = {};
|
|
994
|
+
const rest = trimmed.slice(tag.length);
|
|
995
|
+
ATTR_RE.lastIndex = 0;
|
|
996
|
+
let m;
|
|
997
|
+
while ((m = ATTR_RE.exec(rest)) !== null) {
|
|
998
|
+
const rawValue = m[3] !== void 0 ? m[3] : m[4] ?? "";
|
|
999
|
+
attrs[m[1]] = decodeXmlEntities(rawValue);
|
|
1000
|
+
}
|
|
1001
|
+
return { tag, attrs };
|
|
1002
|
+
}
|
|
1003
|
+
function parseXml(input) {
|
|
1004
|
+
const n = input.length;
|
|
1005
|
+
const stack = [];
|
|
1006
|
+
let root = null;
|
|
1007
|
+
let i = 0;
|
|
1008
|
+
while (i < n) {
|
|
1009
|
+
const lt = input.indexOf("<", i);
|
|
1010
|
+
if (lt < 0) {
|
|
1011
|
+
break;
|
|
1012
|
+
}
|
|
1013
|
+
i = lt + 1;
|
|
1014
|
+
const ch = input[i];
|
|
1015
|
+
if (ch === "?") {
|
|
1016
|
+
const end = input.indexOf("?>", i);
|
|
1017
|
+
i = end < 0 ? n : end + 2;
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
if (ch === "!") {
|
|
1021
|
+
if (input.startsWith("!--", i)) {
|
|
1022
|
+
const end = input.indexOf("-->", i);
|
|
1023
|
+
i = end < 0 ? n : end + 3;
|
|
1024
|
+
} else {
|
|
1025
|
+
const end = input.indexOf(">", i);
|
|
1026
|
+
i = end < 0 ? n : end + 1;
|
|
1027
|
+
}
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
if (ch === "/") {
|
|
1031
|
+
const gt = input.indexOf(">", i);
|
|
1032
|
+
i = gt < 0 ? n : gt + 1;
|
|
1033
|
+
stack.pop();
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
let j = i;
|
|
1037
|
+
let quote2 = null;
|
|
1038
|
+
while (j < n) {
|
|
1039
|
+
const c = input[j];
|
|
1040
|
+
if (quote2 !== null) {
|
|
1041
|
+
if (c === quote2) {
|
|
1042
|
+
quote2 = null;
|
|
1043
|
+
}
|
|
1044
|
+
} else if (c === '"' || c === "'") {
|
|
1045
|
+
quote2 = c;
|
|
1046
|
+
} else if (c === ">") {
|
|
1047
|
+
break;
|
|
1048
|
+
}
|
|
1049
|
+
j += 1;
|
|
1050
|
+
}
|
|
1051
|
+
const inner = input.slice(i, j);
|
|
1052
|
+
i = j + 1;
|
|
1053
|
+
const selfClose = inner.endsWith("/");
|
|
1054
|
+
const body = selfClose ? inner.slice(0, -1) : inner;
|
|
1055
|
+
const { tag, attrs } = parseTagBody(body);
|
|
1056
|
+
if (tag.length === 0) {
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
const element = { tag, attrs, children: [] };
|
|
1060
|
+
const parent = stack[stack.length - 1];
|
|
1061
|
+
if (parent) {
|
|
1062
|
+
parent.children.push(element);
|
|
1063
|
+
}
|
|
1064
|
+
if (root === null) {
|
|
1065
|
+
root = element;
|
|
1066
|
+
}
|
|
1067
|
+
if (!selfClose) {
|
|
1068
|
+
stack.push(element);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return root;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// src/selector/native.ts
|
|
1075
|
+
function unquoteSelectorValue(value) {
|
|
1076
|
+
const trimmed = value.trim();
|
|
1077
|
+
const first = trimmed[0];
|
|
1078
|
+
if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
|
|
1079
|
+
return trimmed.slice(1, -1);
|
|
1080
|
+
}
|
|
1081
|
+
return trimmed;
|
|
1082
|
+
}
|
|
1083
|
+
function quoteSelectorValue(value) {
|
|
1084
|
+
return `"${value}"`;
|
|
1085
|
+
}
|
|
1086
|
+
var ROLE_SELECTOR_RE = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s;
|
|
1087
|
+
var NATIVE_SELECTOR_PREFIX_RE = /^(id|label|text|role)=/;
|
|
1088
|
+
function invalidSelectorMessage(selector, reason) {
|
|
1089
|
+
return `Invalid native selector ${JSON.stringify(selector)}: ${reason}.`;
|
|
1090
|
+
}
|
|
1091
|
+
function parseNativeSelector(selector) {
|
|
1092
|
+
const trimmed = selector.trim();
|
|
1093
|
+
if (trimmed.length === 0) {
|
|
1094
|
+
throw new Error(
|
|
1095
|
+
invalidSelectorMessage(
|
|
1096
|
+
selector,
|
|
1097
|
+
"selector is empty; use id=, label=, text=, role=, :focus, or a bare text value"
|
|
1098
|
+
)
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
if (trimmed === ":focus") {
|
|
1102
|
+
return { kind: "focused" };
|
|
1103
|
+
}
|
|
1104
|
+
const idMatch = /^id=(.+)$/s.exec(trimmed);
|
|
1105
|
+
if (idMatch) {
|
|
1106
|
+
return { kind: "id", value: unquoteSelectorValue(idMatch[1]) };
|
|
1107
|
+
}
|
|
1108
|
+
const roleMatch = ROLE_SELECTOR_RE.exec(trimmed);
|
|
1109
|
+
if (roleMatch) {
|
|
1110
|
+
const name = roleMatch[2] !== void 0 ? unquoteSelectorValue(roleMatch[2]) : void 0;
|
|
1111
|
+
return name !== void 0 && name.length > 0 ? { kind: "role", role: roleMatch[1], name } : { kind: "role", role: roleMatch[1] };
|
|
1112
|
+
}
|
|
1113
|
+
const labelMatch = /^label=(.+)$/s.exec(trimmed);
|
|
1114
|
+
if (labelMatch) {
|
|
1115
|
+
return { kind: "label", value: unquoteSelectorValue(labelMatch[1]) };
|
|
1116
|
+
}
|
|
1117
|
+
const textMatch = /^text=(.+)$/s.exec(trimmed);
|
|
1118
|
+
if (textMatch) {
|
|
1119
|
+
return { kind: "text", value: unquoteSelectorValue(textMatch[1]) };
|
|
1120
|
+
}
|
|
1121
|
+
const prefix = NATIVE_SELECTOR_PREFIX_RE.exec(trimmed);
|
|
1122
|
+
if (prefix) {
|
|
1123
|
+
throw new Error(
|
|
1124
|
+
invalidSelectorMessage(
|
|
1125
|
+
selector,
|
|
1126
|
+
`malformed ${prefix[1]}= selector; expected id=<value>, label=<value>, text=<value>, or role=<Type>[name=<value>]`
|
|
1127
|
+
)
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
return { kind: "text", value: trimmed };
|
|
1131
|
+
}
|
|
1132
|
+
function unwrapNativeTextSelector(selector) {
|
|
1133
|
+
const trimmed = selector.trim();
|
|
1134
|
+
if (!trimmed.startsWith("text=")) {
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
return unquoteSelectorValue(trimmed.slice("text=".length));
|
|
1138
|
+
}
|
|
1139
|
+
function qualifyResourceId(value, appPackage) {
|
|
1140
|
+
if (value.includes(":") || !appPackage) {
|
|
1141
|
+
return value;
|
|
1142
|
+
}
|
|
1143
|
+
return `${appPackage}:id/${value}`;
|
|
1144
|
+
}
|
|
1145
|
+
function normalizeXcuiClassName(role) {
|
|
1146
|
+
return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
|
|
1147
|
+
}
|
|
1148
|
+
function shortIosType(type) {
|
|
1149
|
+
return type.startsWith("XCUIElementType") ? type.slice("XCUIElementType".length) : type;
|
|
1150
|
+
}
|
|
1151
|
+
function rankNativeSelectors(fields) {
|
|
1152
|
+
const selectors = [];
|
|
1153
|
+
if (fields.id) {
|
|
1154
|
+
selectors.push(`id=${fields.id}`);
|
|
1155
|
+
}
|
|
1156
|
+
if (fields.label) {
|
|
1157
|
+
selectors.push(`label=${quoteSelectorValue(fields.label)}`);
|
|
1158
|
+
}
|
|
1159
|
+
if (fields.role && fields.name) {
|
|
1160
|
+
selectors.push(`role=${fields.role}[name=${quoteSelectorValue(fields.name)}]`);
|
|
1161
|
+
}
|
|
1162
|
+
if (fields.name) {
|
|
1163
|
+
selectors.push(`text=${quoteSelectorValue(fields.name)}`);
|
|
1164
|
+
}
|
|
1165
|
+
if (selectors.length === 0 && fields.role) {
|
|
1166
|
+
selectors.push(`role=${fields.role}`);
|
|
1167
|
+
}
|
|
1168
|
+
return selectors;
|
|
1169
|
+
}
|
|
1170
|
+
var NATIVE_ATTRIBUTE_MAP = {
|
|
1171
|
+
android: {
|
|
1172
|
+
id: { attribute: "resource-id", match: "exact (package-qualified)" },
|
|
1173
|
+
label: { attribute: "content-desc", match: "exact" },
|
|
1174
|
+
text: { attribute: "text", match: "substring" },
|
|
1175
|
+
role: { attribute: "class", match: "exact" }
|
|
1176
|
+
},
|
|
1177
|
+
ios: {
|
|
1178
|
+
id: { attribute: "accessibility id (name)", match: "exact" },
|
|
1179
|
+
label: { attribute: "label", match: "exact" },
|
|
1180
|
+
text: { attribute: "label | value", match: "substring" },
|
|
1181
|
+
role: { attribute: "type (XCUIElementType\u2026)", match: "exact" }
|
|
1182
|
+
},
|
|
1183
|
+
macos: {
|
|
1184
|
+
id: { attribute: "AXIdentifier", match: "exact" },
|
|
1185
|
+
label: { attribute: "title | description", match: "exact" },
|
|
1186
|
+
text: { attribute: "title | description | value", match: "substring" },
|
|
1187
|
+
role: { attribute: "AXRole", match: "exact" }
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
var ANDROID_MATCH_DIALECT = {
|
|
1191
|
+
platform: "android",
|
|
1192
|
+
normalizeRole: (role) => role,
|
|
1193
|
+
normalizeId: (value, options) => qualifyResourceId(value, options.appPackage)
|
|
1194
|
+
};
|
|
1195
|
+
var IOS_MATCH_DIALECT = {
|
|
1196
|
+
platform: "ios",
|
|
1197
|
+
normalizeRole: (role) => normalizeXcuiClassName(role),
|
|
1198
|
+
normalizeId: (value) => value
|
|
1199
|
+
};
|
|
1200
|
+
var MACOS_MATCH_DIALECT = {
|
|
1201
|
+
platform: "macos",
|
|
1202
|
+
normalizeRole: (role) => role,
|
|
1203
|
+
normalizeId: (value) => value
|
|
1204
|
+
};
|
|
1205
|
+
function nodeMatchesSelector(dialect, selector, node, options = {}) {
|
|
1206
|
+
switch (selector.kind) {
|
|
1207
|
+
case "id":
|
|
1208
|
+
return node.id !== void 0 && node.id === dialect.normalizeId(selector.value, options);
|
|
1209
|
+
case "label":
|
|
1210
|
+
return node.label !== void 0 && node.label === selector.value;
|
|
1211
|
+
case "text":
|
|
1212
|
+
return node.textValues.some((t) => t.includes(selector.value));
|
|
1213
|
+
case "focused":
|
|
1214
|
+
return node.focused === true;
|
|
1215
|
+
case "role": {
|
|
1216
|
+
if (node.role === void 0) {
|
|
1217
|
+
return false;
|
|
1218
|
+
}
|
|
1219
|
+
if (dialect.normalizeRole(node.role) !== dialect.normalizeRole(selector.role)) {
|
|
1220
|
+
return false;
|
|
1221
|
+
}
|
|
1222
|
+
if (selector.name === void 0 || selector.name.length === 0) {
|
|
1223
|
+
return true;
|
|
1224
|
+
}
|
|
1225
|
+
const name = selector.name;
|
|
1226
|
+
return node.textValues.some((t) => t.includes(name));
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
function matchNativeTree(dialect, selector, root, project, children, options = {}) {
|
|
1231
|
+
const out = [];
|
|
1232
|
+
const visit = (node) => {
|
|
1233
|
+
if (nodeMatchesSelector(dialect, selector, project(node), options)) {
|
|
1234
|
+
out.push(node);
|
|
1235
|
+
}
|
|
1236
|
+
for (const child of children(node)) {
|
|
1237
|
+
visit(child);
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
visit(root);
|
|
1241
|
+
return out;
|
|
1242
|
+
}
|
|
1243
|
+
function parseSnapshot(xml) {
|
|
1244
|
+
return parseXml(xml);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
961
1247
|
// src/browser/android-driver.ts
|
|
962
1248
|
import fs3 from "fs";
|
|
963
1249
|
var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
@@ -993,50 +1279,29 @@ var ANDROID_KEYCODES = {
|
|
|
993
1279
|
pageup: 92,
|
|
994
1280
|
pagedown: 93
|
|
995
1281
|
};
|
|
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
1282
|
function escapeUiSelectorArg(value) {
|
|
1005
1283
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1006
1284
|
}
|
|
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]) };
|
|
1285
|
+
function toAndroidQuery(selector) {
|
|
1286
|
+
switch (selector.kind) {
|
|
1287
|
+
case "focused":
|
|
1288
|
+
return { by: "focused" };
|
|
1289
|
+
case "id":
|
|
1290
|
+
return { by: "id", value: selector.value };
|
|
1291
|
+
case "role":
|
|
1292
|
+
return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
|
|
1293
|
+
case "label":
|
|
1294
|
+
return { by: "accessibilityId", value: selector.value };
|
|
1295
|
+
case "text":
|
|
1296
|
+
return { by: "text", value: selector.value };
|
|
1028
1297
|
}
|
|
1029
|
-
|
|
1298
|
+
}
|
|
1299
|
+
function parseAndroidSelector(selector) {
|
|
1300
|
+
return toAndroidQuery(parseNativeSelector(selector));
|
|
1030
1301
|
}
|
|
1031
1302
|
function locator(strategy, selector) {
|
|
1032
1303
|
return { strategy, selector, context: "" };
|
|
1033
1304
|
}
|
|
1034
|
-
function qualifyResourceId(value, appPackage) {
|
|
1035
|
-
if (value.includes(":") || !appPackage) {
|
|
1036
|
-
return value;
|
|
1037
|
-
}
|
|
1038
|
-
return `${appPackage}:id/${value}`;
|
|
1039
|
-
}
|
|
1040
1305
|
function androidQueryToLocator(query, options = {}) {
|
|
1041
1306
|
switch (query.by) {
|
|
1042
1307
|
case "id":
|
|
@@ -1063,11 +1328,7 @@ function androidQueryToLocator(query, options = {}) {
|
|
|
1063
1328
|
}
|
|
1064
1329
|
}
|
|
1065
1330
|
function unwrapAndroidTextSelector(selector) {
|
|
1066
|
-
|
|
1067
|
-
if (!trimmed.startsWith("text=")) {
|
|
1068
|
-
return null;
|
|
1069
|
-
}
|
|
1070
|
-
return unquote2(trimmed.slice(5));
|
|
1331
|
+
return unwrapNativeTextSelector(selector);
|
|
1071
1332
|
}
|
|
1072
1333
|
function keyCodeFor(key) {
|
|
1073
1334
|
const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
|
|
@@ -1342,22 +1603,63 @@ async function installApk(runner, serial, apkPath) {
|
|
|
1342
1603
|
);
|
|
1343
1604
|
}
|
|
1344
1605
|
}
|
|
1606
|
+
function formatUnknownError(error) {
|
|
1607
|
+
return error instanceof Error ? error.message : String(error);
|
|
1608
|
+
}
|
|
1609
|
+
function runnerPhaseError(message, error) {
|
|
1610
|
+
return new Error(`${message}: ${formatUnknownError(error)}`, { cause: error });
|
|
1611
|
+
}
|
|
1612
|
+
function parseResolvedComponent(stdout, pkg) {
|
|
1613
|
+
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
1614
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1615
|
+
if (/^[\w.]+\/[\w.$]+$/.test(lines[i]) && lines[i].startsWith(`${pkg}/`)) {
|
|
1616
|
+
return lines[i];
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
return null;
|
|
1620
|
+
}
|
|
1345
1621
|
async function launchPackage(runner, serial, pkg) {
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1622
|
+
let resolved;
|
|
1623
|
+
try {
|
|
1624
|
+
resolved = await runner(
|
|
1625
|
+
withSerial(serial, [
|
|
1626
|
+
"shell",
|
|
1627
|
+
"cmd",
|
|
1628
|
+
"package",
|
|
1629
|
+
"resolve-activity",
|
|
1630
|
+
"--brief",
|
|
1631
|
+
"-c",
|
|
1632
|
+
"android.intent.category.LAUNCHER",
|
|
1633
|
+
pkg
|
|
1634
|
+
]),
|
|
1635
|
+
{ timeoutMs: 3e4 }
|
|
1636
|
+
);
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
throw runnerPhaseError(
|
|
1639
|
+
`Failed to resolve the launcher activity for Android package "${pkg}" via adb`,
|
|
1640
|
+
error
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
const component = parseResolvedComponent(resolved.stdout, pkg);
|
|
1644
|
+
if (resolved.code !== 0 || !component) {
|
|
1645
|
+
throw new Error(
|
|
1646
|
+
`Could not resolve a launcher activity for Android package "${pkg}" (exit ${resolved.code})${(resolved.stdout + resolved.stderr).trim() ? `: ${(resolved.stdout + resolved.stderr).trim().slice(0, 300)}` : ""}. Is it installed?`
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
let start;
|
|
1650
|
+
try {
|
|
1651
|
+
start = await runner(withSerial(serial, ["shell", "am", "start", "-n", component]), {
|
|
1652
|
+
timeoutMs: 3e4
|
|
1653
|
+
});
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
throw runnerPhaseError(
|
|
1656
|
+
`Failed to launch Android package "${pkg}" with \`am start\` (${component}) via adb`,
|
|
1657
|
+
error
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
if (start.code !== 0 || /error|does not exist|cannot start/i.test(start.stdout + start.stderr)) {
|
|
1359
1661
|
throw new Error(
|
|
1360
|
-
`Failed to launch Android package "${pkg}" (exit ${
|
|
1662
|
+
`Failed to launch Android package "${pkg}" (${component}, exit ${start.code}): ${(start.stdout + start.stderr).trim().slice(0, 400)}.`
|
|
1361
1663
|
);
|
|
1362
1664
|
}
|
|
1363
1665
|
}
|
|
@@ -1575,6 +1877,13 @@ function createUia2AgentClient(transport, sessionId, options = {}) {
|
|
|
1575
1877
|
}
|
|
1576
1878
|
return Buffer.from(value, "base64");
|
|
1577
1879
|
},
|
|
1880
|
+
async source() {
|
|
1881
|
+
const value = await transport.request("GET", `${base}/source`);
|
|
1882
|
+
if (typeof value !== "string") {
|
|
1883
|
+
throw new Error("uiautomator2 /source did not return XML text; cannot analyze Android UI hierarchy");
|
|
1884
|
+
}
|
|
1885
|
+
return value;
|
|
1886
|
+
},
|
|
1578
1887
|
async close() {
|
|
1579
1888
|
await transport.request("DELETE", base).catch(() => void 0);
|
|
1580
1889
|
}
|
|
@@ -1729,43 +2038,25 @@ var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
|
1729
2038
|
var WAIT_POLL_INTERVAL_MS2 = 250;
|
|
1730
2039
|
var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
|
|
1731
2040
|
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
2041
|
function escapePredicateArg(value) {
|
|
1741
2042
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1742
2043
|
}
|
|
1743
|
-
function
|
|
1744
|
-
|
|
2044
|
+
function toIosQuery(selector) {
|
|
2045
|
+
switch (selector.kind) {
|
|
2046
|
+
case "focused":
|
|
2047
|
+
return { by: "focused" };
|
|
2048
|
+
case "id":
|
|
2049
|
+
return { by: "accessibilityId", value: selector.value };
|
|
2050
|
+
case "role":
|
|
2051
|
+
return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
|
|
2052
|
+
case "label":
|
|
2053
|
+
return { by: "label", value: selector.value };
|
|
2054
|
+
case "text":
|
|
2055
|
+
return { by: "text", value: selector.value };
|
|
2056
|
+
}
|
|
1745
2057
|
}
|
|
1746
2058
|
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 };
|
|
2059
|
+
return toIosQuery(parseNativeSelector(selector));
|
|
1769
2060
|
}
|
|
1770
2061
|
function iosQueryToLocator(query) {
|
|
1771
2062
|
switch (query.by) {
|
|
@@ -1797,11 +2088,7 @@ function iosQueryToLocator(query) {
|
|
|
1797
2088
|
}
|
|
1798
2089
|
}
|
|
1799
2090
|
function unwrapIosTextSelector(selector) {
|
|
1800
|
-
|
|
1801
|
-
if (!trimmed.startsWith("text=")) {
|
|
1802
|
-
return null;
|
|
1803
|
-
}
|
|
1804
|
-
return unquote3(trimmed.slice(5));
|
|
2091
|
+
return unwrapNativeTextSelector(selector);
|
|
1805
2092
|
}
|
|
1806
2093
|
function delay2(ms) {
|
|
1807
2094
|
return new Promise((resolve) => {
|
|
@@ -1960,11 +2247,24 @@ function createIosDriver(client, options) {
|
|
|
1960
2247
|
}
|
|
1961
2248
|
|
|
1962
2249
|
// src/browser/ios-simctl.ts
|
|
1963
|
-
import { execFile as execFile3 } from "child_process";
|
|
2250
|
+
import { execFile as execFile3, spawn as spawn3 } from "child_process";
|
|
1964
2251
|
import { mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
1965
2252
|
import net from "net";
|
|
1966
2253
|
import os from "os";
|
|
1967
2254
|
import path4 from "path";
|
|
2255
|
+
var spawnXcrunProcess = (args, options) => {
|
|
2256
|
+
const child = spawn3("xcrun", args, {
|
|
2257
|
+
stdio: "ignore",
|
|
2258
|
+
env: options?.env ? { ...process.env, ...options.env } : process.env
|
|
2259
|
+
});
|
|
2260
|
+
child.on("error", () => {
|
|
2261
|
+
});
|
|
2262
|
+
return {
|
|
2263
|
+
kill: () => {
|
|
2264
|
+
child.kill();
|
|
2265
|
+
}
|
|
2266
|
+
};
|
|
2267
|
+
};
|
|
1968
2268
|
var DEFAULT_SIMULATOR_LOCK_ROOT = path4.join(os.tmpdir(), "prowl-ios-simulator-locks");
|
|
1969
2269
|
var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
|
|
1970
2270
|
var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
|
|
@@ -2401,6 +2701,13 @@ function createWdaAgentClient(transport, sessionId) {
|
|
|
2401
2701
|
async homescreen() {
|
|
2402
2702
|
await transport.request("POST", "/wda/homescreen", {});
|
|
2403
2703
|
},
|
|
2704
|
+
async source() {
|
|
2705
|
+
const value = await transport.request("GET", "/source");
|
|
2706
|
+
if (typeof value !== "string") {
|
|
2707
|
+
throw new Error("WebDriverAgent /source did not return XML text; cannot analyze iOS UI hierarchy");
|
|
2708
|
+
}
|
|
2709
|
+
return value;
|
|
2710
|
+
},
|
|
2404
2711
|
async close() {
|
|
2405
2712
|
await transport.request("DELETE", base).catch(() => void 0);
|
|
2406
2713
|
}
|
|
@@ -2409,12 +2716,13 @@ function createWdaAgentClient(transport, sessionId) {
|
|
|
2409
2716
|
|
|
2410
2717
|
// src/browser/ios-helper.ts
|
|
2411
2718
|
import { createRequire as createRequire2 } from "module";
|
|
2719
|
+
import { execFile as execFile4 } from "child_process";
|
|
2412
2720
|
import fs5 from "fs";
|
|
2413
2721
|
import os2 from "os";
|
|
2414
2722
|
import path5 from "path";
|
|
2415
2723
|
var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
|
|
2416
|
-
var WDA_RUNNER_APP_NAME = "WebDriverAgentRunner-Runner.app";
|
|
2417
2724
|
var WDA_USE_PORT_ENV = "USE_PORT";
|
|
2725
|
+
var PREPARED_XCTESTRUN_PREFIX = "prowl-wda-xctestrun-";
|
|
2418
2726
|
var WDA_STARTUP_ATTEMPTS = 2;
|
|
2419
2727
|
var defaultIosAgentConnector = async ({
|
|
2420
2728
|
host,
|
|
@@ -2448,10 +2756,44 @@ function resolveWdaProject(requireFn = createRequire2(import.meta.url)) {
|
|
|
2448
2756
|
function wdaCacheDir(wdaVersion, xcode, homeDir = os2.homedir()) {
|
|
2449
2757
|
return path5.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
|
|
2450
2758
|
}
|
|
2451
|
-
function
|
|
2452
|
-
return path5.join(derivedDataPath, "Build", "Products"
|
|
2759
|
+
function productsDir(derivedDataPath) {
|
|
2760
|
+
return path5.join(derivedDataPath, "Build", "Products");
|
|
2761
|
+
}
|
|
2762
|
+
function findXctestrunIn(dir) {
|
|
2763
|
+
let entries;
|
|
2764
|
+
try {
|
|
2765
|
+
entries = fs5.readdirSync(dir);
|
|
2766
|
+
} catch {
|
|
2767
|
+
return null;
|
|
2768
|
+
}
|
|
2769
|
+
const match = entries.filter((name) => name.endsWith(".xctestrun") && !name.startsWith(PREPARED_XCTESTRUN_PREFIX)).sort()[0];
|
|
2770
|
+
return match ? path5.join(dir, match) : null;
|
|
2453
2771
|
}
|
|
2454
|
-
|
|
2772
|
+
function resolveOverrideXctestrun(override) {
|
|
2773
|
+
if (!fs5.existsSync(override)) {
|
|
2774
|
+
throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
|
|
2775
|
+
}
|
|
2776
|
+
if (override.endsWith(".xctestrun")) {
|
|
2777
|
+
return override;
|
|
2778
|
+
}
|
|
2779
|
+
const candidates = [];
|
|
2780
|
+
const stat = fs5.statSync(override);
|
|
2781
|
+
if (override.endsWith(".app")) {
|
|
2782
|
+
candidates.push(path5.dirname(path5.dirname(override)));
|
|
2783
|
+
} else if (stat.isDirectory()) {
|
|
2784
|
+
candidates.push(override, productsDir(override));
|
|
2785
|
+
}
|
|
2786
|
+
for (const dir of candidates) {
|
|
2787
|
+
const found = findXctestrunIn(dir);
|
|
2788
|
+
if (found) {
|
|
2789
|
+
return found;
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
throw new Error(
|
|
2793
|
+
`PROWL_WDA_RUNNER (${override}) does not resolve to a WebDriverAgent .xctestrun. Point it at the generated \`*.xctestrun\`, at the \`-derivedDataPath\` directory from \`xcodebuild build-for-testing\`, or at that build's runner \`.app\`.`
|
|
2794
|
+
);
|
|
2795
|
+
}
|
|
2796
|
+
async function resolveWdaTestRun(options = {}) {
|
|
2455
2797
|
const runner = options.runner ?? execFileXcrunRunner;
|
|
2456
2798
|
const env = options.env ?? process.env;
|
|
2457
2799
|
const homeDir = options.homeDir ?? os2.homedir();
|
|
@@ -2459,10 +2801,7 @@ async function resolveWdaRunner(options = {}) {
|
|
|
2459
2801
|
`));
|
|
2460
2802
|
const override = env.PROWL_WDA_RUNNER;
|
|
2461
2803
|
if (override) {
|
|
2462
|
-
|
|
2463
|
-
throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
|
|
2464
|
-
}
|
|
2465
|
-
return override;
|
|
2804
|
+
return resolveOverrideXctestrun(override);
|
|
2466
2805
|
}
|
|
2467
2806
|
const { projectPath, version } = resolveWdaProject(options.requireFn);
|
|
2468
2807
|
const xcode = await xcodeVersion(runner);
|
|
@@ -2472,9 +2811,9 @@ async function resolveWdaRunner(options = {}) {
|
|
|
2472
2811
|
);
|
|
2473
2812
|
}
|
|
2474
2813
|
const cacheDir = wdaCacheDir(version, xcode, homeDir);
|
|
2475
|
-
const
|
|
2476
|
-
if (
|
|
2477
|
-
return
|
|
2814
|
+
const cached = findXctestrunIn(productsDir(cacheDir));
|
|
2815
|
+
if (cached) {
|
|
2816
|
+
return cached;
|
|
2478
2817
|
}
|
|
2479
2818
|
log(
|
|
2480
2819
|
`Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
|
|
@@ -2501,12 +2840,106 @@ async function resolveWdaRunner(options = {}) {
|
|
|
2501
2840
|
`Failed to build WebDriverAgent with \`xcodebuild build-for-testing\`. Ensure a full Xcode (not just the command-line tools) is installed and selected (\`xcode-select -p\`). Details: ${(result.stderr.trim() || result.stdout.trim()).slice(-800)}`
|
|
2502
2841
|
);
|
|
2503
2842
|
}
|
|
2504
|
-
|
|
2843
|
+
const built = findXctestrunIn(productsDir(cacheDir));
|
|
2844
|
+
if (!built) {
|
|
2505
2845
|
throw new Error(
|
|
2506
|
-
`WebDriverAgent build succeeded but
|
|
2846
|
+
`WebDriverAgent build succeeded but no .xctestrun was found under ${productsDir(cacheDir)}. This may indicate an Xcode layout change; set PROWL_WDA_RUNNER to a prebuilt runner/xctestrun.`
|
|
2507
2847
|
);
|
|
2508
2848
|
}
|
|
2509
|
-
return
|
|
2849
|
+
return built;
|
|
2850
|
+
}
|
|
2851
|
+
function injectUsePortIntoXctestrun(plist, port) {
|
|
2852
|
+
const clone = structuredClone(plist);
|
|
2853
|
+
let injected = 0;
|
|
2854
|
+
const visit = (node) => {
|
|
2855
|
+
if (Array.isArray(node)) {
|
|
2856
|
+
for (const entry of node) {
|
|
2857
|
+
visit(entry);
|
|
2858
|
+
}
|
|
2859
|
+
return;
|
|
2860
|
+
}
|
|
2861
|
+
if (!node || typeof node !== "object") {
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
const obj = node;
|
|
2865
|
+
const isTarget = typeof obj.TestBundlePath === "string" || typeof obj.TestHostPath === "string";
|
|
2866
|
+
const existingEnv = obj.EnvironmentVariables && typeof obj.EnvironmentVariables === "object" && !Array.isArray(obj.EnvironmentVariables) ? obj.EnvironmentVariables : void 0;
|
|
2867
|
+
if (isTarget || existingEnv) {
|
|
2868
|
+
const env = existingEnv ?? {};
|
|
2869
|
+
env[WDA_USE_PORT_ENV] = String(port);
|
|
2870
|
+
obj.EnvironmentVariables = env;
|
|
2871
|
+
injected += 1;
|
|
2872
|
+
}
|
|
2873
|
+
for (const value of Object.values(obj)) {
|
|
2874
|
+
visit(value);
|
|
2875
|
+
}
|
|
2876
|
+
};
|
|
2877
|
+
visit(clone);
|
|
2878
|
+
if (injected === 0) {
|
|
2879
|
+
throw new Error(
|
|
2880
|
+
"Could not find a test target in the WebDriverAgent .xctestrun to set USE_PORT. The xctestrun format may have changed; set PROWL_WDA_RUNNER to a compatible runner."
|
|
2881
|
+
);
|
|
2882
|
+
}
|
|
2883
|
+
return clone;
|
|
2884
|
+
}
|
|
2885
|
+
function runPlutil(args) {
|
|
2886
|
+
return new Promise((resolve, reject) => {
|
|
2887
|
+
execFile4(
|
|
2888
|
+
"plutil",
|
|
2889
|
+
args,
|
|
2890
|
+
{ encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 },
|
|
2891
|
+
(error, stdout, stderr) => {
|
|
2892
|
+
if (error) {
|
|
2893
|
+
reject(
|
|
2894
|
+
new Error(
|
|
2895
|
+
`\`plutil ${args.join(" ")}\` failed: ${(stderr || error.message).slice(0, 400)}`
|
|
2896
|
+
)
|
|
2897
|
+
);
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
resolve(stdout ?? "");
|
|
2901
|
+
}
|
|
2902
|
+
);
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2905
|
+
var defaultWdaTestRunPreparer = async ({ xctestrunPath, port }) => {
|
|
2906
|
+
const json = await runPlutil(["-convert", "json", "-o", "-", xctestrunPath]);
|
|
2907
|
+
let parsed;
|
|
2908
|
+
try {
|
|
2909
|
+
parsed = JSON.parse(json);
|
|
2910
|
+
} catch {
|
|
2911
|
+
throw new Error(`Could not parse the WebDriverAgent .xctestrun as JSON: ${xctestrunPath}`);
|
|
2912
|
+
}
|
|
2913
|
+
const injected = injectUsePortIntoXctestrun(parsed, port);
|
|
2914
|
+
const stem = `${PREPARED_XCTESTRUN_PREFIX}${port}-${process.pid}`;
|
|
2915
|
+
const outPath = path5.join(path5.dirname(xctestrunPath), `${stem}.xctestrun`);
|
|
2916
|
+
const jsonPath = path5.join(os2.tmpdir(), `${stem}.json`);
|
|
2917
|
+
fs5.writeFileSync(jsonPath, JSON.stringify(injected));
|
|
2918
|
+
try {
|
|
2919
|
+
await runPlutil(["-convert", "xml1", jsonPath, "-o", outPath]);
|
|
2920
|
+
} finally {
|
|
2921
|
+
fs5.rmSync(jsonPath, { force: true });
|
|
2922
|
+
}
|
|
2923
|
+
return outPath;
|
|
2924
|
+
};
|
|
2925
|
+
function cleanupPreparedTestRun(preparedPath) {
|
|
2926
|
+
if (!preparedPath) {
|
|
2927
|
+
return;
|
|
2928
|
+
}
|
|
2929
|
+
if (!path5.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
fs5.rmSync(preparedPath, { force: true });
|
|
2933
|
+
}
|
|
2934
|
+
function wdaTestRunArgs(xctestrunPath, udid) {
|
|
2935
|
+
return [
|
|
2936
|
+
"xcodebuild",
|
|
2937
|
+
"test-without-building",
|
|
2938
|
+
"-xctestrun",
|
|
2939
|
+
xctestrunPath,
|
|
2940
|
+
"-destination",
|
|
2941
|
+
`id=${udid}`
|
|
2942
|
+
];
|
|
2510
2943
|
}
|
|
2511
2944
|
async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
|
|
2512
2945
|
if (!looksLikeIosAppPath(app)) {
|
|
@@ -2537,6 +2970,8 @@ async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
|
|
|
2537
2970
|
}
|
|
2538
2971
|
async function launchIosSession(options) {
|
|
2539
2972
|
const runner = options.runner ?? execFileXcrunRunner;
|
|
2973
|
+
const spawner = options.spawner ?? spawnXcrunProcess;
|
|
2974
|
+
const preparer = options.testRunPreparer ?? defaultWdaTestRunPreparer;
|
|
2540
2975
|
const portAllocator = options.portAllocator ?? findFreePort;
|
|
2541
2976
|
const connector = options.agentConnector ?? defaultIosAgentConnector;
|
|
2542
2977
|
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
|
|
@@ -2560,14 +2995,14 @@ async function launchIosSession(options) {
|
|
|
2560
2995
|
options.coldStart ?? false,
|
|
2561
2996
|
options.allowedApps ?? []
|
|
2562
2997
|
);
|
|
2563
|
-
const
|
|
2564
|
-
await installApp(runner, udid, wdaRunnerApp);
|
|
2998
|
+
const baseTestRun = options.wdaTestRun ?? await resolveWdaTestRun({ runner, logger: options.logger });
|
|
2565
2999
|
for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
|
|
2566
3000
|
let client;
|
|
3001
|
+
let wdaProcess;
|
|
3002
|
+
let preparedTestRun;
|
|
2567
3003
|
let tornDown = false;
|
|
2568
|
-
let terminateWda = false;
|
|
2569
3004
|
let terminateTarget = false;
|
|
2570
|
-
let stage = "
|
|
3005
|
+
let stage = "prepare";
|
|
2571
3006
|
const teardownAttempt = async () => {
|
|
2572
3007
|
if (tornDown) {
|
|
2573
3008
|
return;
|
|
@@ -2576,18 +3011,18 @@ async function launchIosSession(options) {
|
|
|
2576
3011
|
if (client) {
|
|
2577
3012
|
await client.close().catch(() => void 0);
|
|
2578
3013
|
}
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
}
|
|
3014
|
+
wdaProcess?.kill();
|
|
3015
|
+
await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
|
|
2582
3016
|
if (terminateTarget) {
|
|
2583
3017
|
await terminateApp(runner, udid, bundleId);
|
|
2584
3018
|
}
|
|
3019
|
+
cleanupPreparedTestRun(preparedTestRun);
|
|
2585
3020
|
};
|
|
2586
3021
|
try {
|
|
2587
3022
|
const port = await portAllocator();
|
|
3023
|
+
preparedTestRun = await preparer({ xctestrunPath: baseTestRun, port });
|
|
2588
3024
|
stage = "wda-launch";
|
|
2589
|
-
|
|
2590
|
-
await launchApp(runner, udid, WDA_RUNNER_BUNDLE_ID, { [WDA_USE_PORT_ENV]: String(port) });
|
|
3025
|
+
wdaProcess = spawner(wdaTestRunArgs(preparedTestRun, udid));
|
|
2591
3026
|
stage = "target-launch";
|
|
2592
3027
|
terminateTarget = true;
|
|
2593
3028
|
await launchApp(runner, udid, bundleId);
|
|
@@ -3053,52 +3488,292 @@ function createRunPolicy(driver, options) {
|
|
|
3053
3488
|
throw new Error(`Navigation to disallowed domain: ${url.hostname}`);
|
|
3054
3489
|
}
|
|
3055
3490
|
}
|
|
3056
|
-
function ensureAppAllowed(app) {
|
|
3057
|
-
if (!allowedApps.includes(app)) {
|
|
3058
|
-
throw new Error(`Interaction with disallowed app: ${app}`);
|
|
3059
|
-
}
|
|
3491
|
+
function ensureAppAllowed(app) {
|
|
3492
|
+
if (!allowedApps.includes(app)) {
|
|
3493
|
+
throw new Error(`Interaction with disallowed app: ${app}`);
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
function ensureLocationAllowed(activeDriver) {
|
|
3497
|
+
if (activeDriver.capabilities.has("navigate")) {
|
|
3498
|
+
ensureUrlAllowed(activeDriver.currentUrl());
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
const healProbe = {
|
|
3502
|
+
locator: (selector) => ({ count: () => driver.count(selector) })
|
|
3503
|
+
};
|
|
3504
|
+
async function resolveActionSelector(selector) {
|
|
3505
|
+
assertAllowedSelector(selector);
|
|
3506
|
+
if (!selfHealing) {
|
|
3507
|
+
return { selector };
|
|
3508
|
+
}
|
|
3509
|
+
let matched = false;
|
|
3510
|
+
try {
|
|
3511
|
+
matched = await driver.count(selector) > 0;
|
|
3512
|
+
} catch {
|
|
3513
|
+
return { selector };
|
|
3514
|
+
}
|
|
3515
|
+
if (matched) {
|
|
3516
|
+
return { selector };
|
|
3517
|
+
}
|
|
3518
|
+
const healed = await healSelector(healProbe, selector, { enabled: true });
|
|
3519
|
+
if (!healed) {
|
|
3520
|
+
return { selector };
|
|
3521
|
+
}
|
|
3522
|
+
assertAllowedSelector(healed.selector);
|
|
3523
|
+
console.warn(
|
|
3524
|
+
`Self-healed selector: "${selector}" \u2192 "${healed.selector}" (${healed.strategy}). Update your hunt to use a stable selector.`
|
|
3525
|
+
);
|
|
3526
|
+
return { selector: healed.selector, healedFrom: healed.healedFrom };
|
|
3527
|
+
}
|
|
3528
|
+
return {
|
|
3529
|
+
assertWithinMaxSteps,
|
|
3530
|
+
ensureUrlAllowed,
|
|
3531
|
+
ensureAppAllowed,
|
|
3532
|
+
ensureLocationAllowed,
|
|
3533
|
+
assertAllowedSelector,
|
|
3534
|
+
resolveActionSelector
|
|
3535
|
+
};
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
// src/generator/ai.ts
|
|
3539
|
+
var DEFAULT_BASE_URL = {
|
|
3540
|
+
anthropic: "https://api.anthropic.com",
|
|
3541
|
+
openai: "https://api.openai.com"
|
|
3542
|
+
};
|
|
3543
|
+
var AI_REQUEST_TIMEOUT_MS = 3e4;
|
|
3544
|
+
function apiRoot(config) {
|
|
3545
|
+
return config.baseUrl ?? DEFAULT_BASE_URL[config.provider];
|
|
3546
|
+
}
|
|
3547
|
+
function defaultModelFor(provider) {
|
|
3548
|
+
return provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
|
|
3549
|
+
}
|
|
3550
|
+
function resolveAiEnv() {
|
|
3551
|
+
const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
|
|
3552
|
+
if (provider !== "anthropic" && provider !== "openai") {
|
|
3553
|
+
throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
|
|
3554
|
+
}
|
|
3555
|
+
const model = process.env.PROWL_AI_MODEL ?? defaultModelFor(provider);
|
|
3556
|
+
const baseUrl = normalizeBaseUrl(process.env.PROWL_AI_BASE_URL) ?? DEFAULT_BASE_URL[provider];
|
|
3557
|
+
return { provider, model, baseUrl, apiKey: process.env.PROWL_AI_KEY };
|
|
3558
|
+
}
|
|
3559
|
+
function normalizeBaseUrl(value) {
|
|
3560
|
+
if (value === void 0) return void 0;
|
|
3561
|
+
const trimmed = value.trim().replace(/\/+$/, "");
|
|
3562
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
3563
|
+
}
|
|
3564
|
+
function resolveAiConfig() {
|
|
3565
|
+
const env = resolveAiEnv();
|
|
3566
|
+
if (!env.apiKey) {
|
|
3567
|
+
throw new Error(
|
|
3568
|
+
"PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
|
|
3569
|
+
);
|
|
3570
|
+
}
|
|
3571
|
+
return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
|
|
3572
|
+
}
|
|
3573
|
+
function tryResolveAiConfig() {
|
|
3574
|
+
const env = resolveAiEnv();
|
|
3575
|
+
if (!env.apiKey) {
|
|
3576
|
+
return null;
|
|
3577
|
+
}
|
|
3578
|
+
return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
|
|
3579
|
+
}
|
|
3580
|
+
async function generateWithAi(prompt, config) {
|
|
3581
|
+
if (config.provider === "anthropic") {
|
|
3582
|
+
return generateWithAnthropic(prompt, config);
|
|
3583
|
+
}
|
|
3584
|
+
return generateWithOpenAi(prompt, config);
|
|
3585
|
+
}
|
|
3586
|
+
async function generateWithAnthropic(prompt, config) {
|
|
3587
|
+
const data = await postJson(
|
|
3588
|
+
"Anthropic",
|
|
3589
|
+
`${apiRoot(config)}/v1/messages`,
|
|
3590
|
+
anthropicHeaders(config),
|
|
3591
|
+
{
|
|
3592
|
+
model: config.model,
|
|
3593
|
+
max_tokens: 4096,
|
|
3594
|
+
messages: [
|
|
3595
|
+
{ role: "user", content: prompt }
|
|
3596
|
+
]
|
|
3597
|
+
}
|
|
3598
|
+
);
|
|
3599
|
+
return extractAnthropicText(data);
|
|
3600
|
+
}
|
|
3601
|
+
async function generateWithOpenAi(prompt, config) {
|
|
3602
|
+
const data = await postJson(
|
|
3603
|
+
"OpenAI",
|
|
3604
|
+
`${apiRoot(config)}/v1/chat/completions`,
|
|
3605
|
+
openAiHeaders(config),
|
|
3606
|
+
{
|
|
3607
|
+
model: config.model,
|
|
3608
|
+
messages: [
|
|
3609
|
+
{ role: "user", content: prompt }
|
|
3610
|
+
],
|
|
3611
|
+
max_tokens: 4096
|
|
3612
|
+
}
|
|
3613
|
+
);
|
|
3614
|
+
return extractOpenAiText(data);
|
|
3615
|
+
}
|
|
3616
|
+
function buildVisionPrompt(assertion) {
|
|
3617
|
+
return [
|
|
3618
|
+
"You are a meticulous QA reviewer. You are given a screenshot of an application",
|
|
3619
|
+
"and a single assertion describing what should be true about it.",
|
|
3620
|
+
"",
|
|
3621
|
+
"Assertion:",
|
|
3622
|
+
assertion,
|
|
3623
|
+
"",
|
|
3624
|
+
"Decide whether the assertion holds for the screenshot. Judge ONLY what is",
|
|
3625
|
+
"visible; do not assume behavior you cannot see. Be strict: if the assertion",
|
|
3626
|
+
"is not clearly satisfied, it fails.",
|
|
3627
|
+
"",
|
|
3628
|
+
"Reply with ONLY a single JSON object on one line, no markdown, no code fences:",
|
|
3629
|
+
'{"pass": <true|false>, "reason": "<one concise sentence explaining the verdict>"}'
|
|
3630
|
+
].join("\n");
|
|
3631
|
+
}
|
|
3632
|
+
function parseVisionVerdict(raw) {
|
|
3633
|
+
const cleaned = stripCodeFences(raw).trim();
|
|
3634
|
+
const candidate = extractJsonObject(cleaned);
|
|
3635
|
+
if (candidate === null) {
|
|
3636
|
+
throw new Error(
|
|
3637
|
+
`Could not parse a JSON verdict from the AI response: ${truncateForError(raw)}`
|
|
3638
|
+
);
|
|
3639
|
+
}
|
|
3640
|
+
let parsed;
|
|
3641
|
+
try {
|
|
3642
|
+
parsed = JSON.parse(candidate);
|
|
3643
|
+
} catch {
|
|
3644
|
+
throw new Error(
|
|
3645
|
+
`AI verdict was not valid JSON: ${truncateForError(raw)}`
|
|
3646
|
+
);
|
|
3060
3647
|
}
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
ensureUrlAllowed(activeDriver.currentUrl());
|
|
3064
|
-
}
|
|
3648
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
3649
|
+
throw new Error(`AI verdict was not a JSON object: ${truncateForError(raw)}`);
|
|
3065
3650
|
}
|
|
3066
|
-
const
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3651
|
+
const record = parsed;
|
|
3652
|
+
if (typeof record.pass !== "boolean") {
|
|
3653
|
+
throw new Error(
|
|
3654
|
+
`AI verdict is missing a boolean "pass" field: ${truncateForError(raw)}`
|
|
3655
|
+
);
|
|
3656
|
+
}
|
|
3657
|
+
const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : record.pass ? "Assertion satisfied." : "Assertion not satisfied.";
|
|
3658
|
+
return { pass: record.pass, reason };
|
|
3659
|
+
}
|
|
3660
|
+
function stripCodeFences(text) {
|
|
3661
|
+
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
3662
|
+
return fence ? fence[1] : text;
|
|
3663
|
+
}
|
|
3664
|
+
function extractJsonObject(text) {
|
|
3665
|
+
const trimmed = text.trim();
|
|
3666
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
3667
|
+
return trimmed;
|
|
3668
|
+
}
|
|
3669
|
+
const first = trimmed.indexOf("{");
|
|
3670
|
+
const last = trimmed.lastIndexOf("}");
|
|
3671
|
+
if (first === -1 || last === -1 || last <= first) {
|
|
3672
|
+
return null;
|
|
3673
|
+
}
|
|
3674
|
+
return trimmed.slice(first, last + 1);
|
|
3675
|
+
}
|
|
3676
|
+
function truncateForError(text) {
|
|
3677
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
3678
|
+
return collapsed.length > 200 ? `${collapsed.slice(0, 199)}\u2026` : collapsed;
|
|
3679
|
+
}
|
|
3680
|
+
async function assertWithAiVision(input, config) {
|
|
3681
|
+
const raw = config.provider === "anthropic" ? await visionWithAnthropic(input, config) : await visionWithOpenAi(input, config);
|
|
3682
|
+
return parseVisionVerdict(raw);
|
|
3683
|
+
}
|
|
3684
|
+
async function visionWithAnthropic(input, config) {
|
|
3685
|
+
const data = await postJson(
|
|
3686
|
+
"Anthropic",
|
|
3687
|
+
`${apiRoot(config)}/v1/messages`,
|
|
3688
|
+
anthropicHeaders(config),
|
|
3689
|
+
{
|
|
3690
|
+
model: config.model,
|
|
3691
|
+
max_tokens: 1024,
|
|
3692
|
+
temperature: 0,
|
|
3693
|
+
messages: [
|
|
3694
|
+
{
|
|
3695
|
+
role: "user",
|
|
3696
|
+
content: [
|
|
3697
|
+
{
|
|
3698
|
+
type: "image",
|
|
3699
|
+
source: {
|
|
3700
|
+
type: "base64",
|
|
3701
|
+
media_type: input.mediaType,
|
|
3702
|
+
data: input.imageBase64
|
|
3703
|
+
}
|
|
3704
|
+
},
|
|
3705
|
+
{ type: "text", text: buildVisionPrompt(input.assertion) }
|
|
3706
|
+
]
|
|
3707
|
+
}
|
|
3708
|
+
]
|
|
3082
3709
|
}
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3710
|
+
);
|
|
3711
|
+
return extractAnthropicText(data);
|
|
3712
|
+
}
|
|
3713
|
+
async function visionWithOpenAi(input, config) {
|
|
3714
|
+
const data = await postJson(
|
|
3715
|
+
"OpenAI",
|
|
3716
|
+
`${apiRoot(config)}/v1/chat/completions`,
|
|
3717
|
+
openAiHeaders(config),
|
|
3718
|
+
{
|
|
3719
|
+
model: config.model,
|
|
3720
|
+
max_tokens: 1024,
|
|
3721
|
+
temperature: 0,
|
|
3722
|
+
messages: [
|
|
3723
|
+
{
|
|
3724
|
+
role: "user",
|
|
3725
|
+
content: [
|
|
3726
|
+
{ type: "text", text: buildVisionPrompt(input.assertion) },
|
|
3727
|
+
{
|
|
3728
|
+
type: "image_url",
|
|
3729
|
+
image_url: { url: `data:${input.mediaType};base64,${input.imageBase64}` }
|
|
3730
|
+
}
|
|
3731
|
+
]
|
|
3732
|
+
}
|
|
3733
|
+
]
|
|
3086
3734
|
}
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3735
|
+
);
|
|
3736
|
+
return extractOpenAiText(data);
|
|
3737
|
+
}
|
|
3738
|
+
async function postJson(provider, url, headers, body) {
|
|
3739
|
+
const response = await fetch(url, {
|
|
3740
|
+
method: "POST",
|
|
3741
|
+
headers,
|
|
3742
|
+
body: JSON.stringify(body),
|
|
3743
|
+
signal: AbortSignal.timeout(AI_REQUEST_TIMEOUT_MS)
|
|
3744
|
+
});
|
|
3745
|
+
if (!response.ok) {
|
|
3746
|
+
const responseBody = await response.text();
|
|
3747
|
+
throw new Error(`${provider} API error (${response.status}): ${responseBody}`);
|
|
3092
3748
|
}
|
|
3749
|
+
return await response.json();
|
|
3750
|
+
}
|
|
3751
|
+
function anthropicHeaders(config) {
|
|
3093
3752
|
return {
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
ensureLocationAllowed,
|
|
3098
|
-
assertAllowedSelector,
|
|
3099
|
-
resolveActionSelector
|
|
3753
|
+
"Content-Type": "application/json",
|
|
3754
|
+
"x-api-key": config.apiKey,
|
|
3755
|
+
"anthropic-version": "2023-06-01"
|
|
3100
3756
|
};
|
|
3101
3757
|
}
|
|
3758
|
+
function openAiHeaders(config) {
|
|
3759
|
+
return {
|
|
3760
|
+
"Content-Type": "application/json",
|
|
3761
|
+
"Authorization": `Bearer ${config.apiKey}`
|
|
3762
|
+
};
|
|
3763
|
+
}
|
|
3764
|
+
function extractAnthropicText(data) {
|
|
3765
|
+
const textBlock = data.content?.find((c) => c.type === "text");
|
|
3766
|
+
if (!textBlock?.text) {
|
|
3767
|
+
throw new Error("Anthropic API returned no text content");
|
|
3768
|
+
}
|
|
3769
|
+
return textBlock.text;
|
|
3770
|
+
}
|
|
3771
|
+
function extractOpenAiText(data) {
|
|
3772
|
+
if (!data.choices?.[0]?.message?.content) {
|
|
3773
|
+
throw new Error("OpenAI API returned no content");
|
|
3774
|
+
}
|
|
3775
|
+
return data.choices[0].message.content;
|
|
3776
|
+
}
|
|
3102
3777
|
|
|
3103
3778
|
// src/runner/steps.ts
|
|
3104
3779
|
function getStepType(step) {
|
|
@@ -3128,6 +3803,7 @@ function getStepType(step) {
|
|
|
3128
3803
|
if ("evalScript" in step) return "evalScript";
|
|
3129
3804
|
if ("runScript" in step) return "runScript";
|
|
3130
3805
|
if ("assertScreenshot" in step) return "assertScreenshot";
|
|
3806
|
+
if ("assertWithAI" in step) return "assertWithAI";
|
|
3131
3807
|
if ("copyText" in step) return "copyText";
|
|
3132
3808
|
if ("waitForDownload" in step) return "waitForDownload";
|
|
3133
3809
|
return "step";
|
|
@@ -3187,6 +3863,9 @@ function applyRuntimeVars(step, vars) {
|
|
|
3187
3863
|
}
|
|
3188
3864
|
};
|
|
3189
3865
|
}
|
|
3866
|
+
if ("assertWithAI" in step) {
|
|
3867
|
+
return { assertWithAI: sub(step.assertWithAI) };
|
|
3868
|
+
}
|
|
3190
3869
|
if ("copyText" in step) {
|
|
3191
3870
|
return { copyText: { selector: sub(step.copyText.selector), as: step.copyText.as } };
|
|
3192
3871
|
}
|
|
@@ -4120,6 +4799,48 @@ var STEP_HANDLERS = {
|
|
|
4120
4799
|
);
|
|
4121
4800
|
}
|
|
4122
4801
|
},
|
|
4802
|
+
assertWithAI: {
|
|
4803
|
+
capabilities: ["screenshot"],
|
|
4804
|
+
run: async (h) => {
|
|
4805
|
+
if (!("assertWithAI" in h.step)) unknownStep();
|
|
4806
|
+
const assertion = h.step.assertWithAI;
|
|
4807
|
+
const resolve = h.context.resolveAiConfig ?? tryResolveAiConfig;
|
|
4808
|
+
const aiConfig = resolve();
|
|
4809
|
+
if (!aiConfig) {
|
|
4810
|
+
return {
|
|
4811
|
+
kind: "result",
|
|
4812
|
+
result: {
|
|
4813
|
+
type: "assertWithAI",
|
|
4814
|
+
status: "warn",
|
|
4815
|
+
durationMs: Date.now() - h.stepStart,
|
|
4816
|
+
value: `skipped: no AI provider configured (set PROWL_AI_KEY to enable) \u2014 "${assertion}"`
|
|
4817
|
+
}
|
|
4818
|
+
};
|
|
4819
|
+
}
|
|
4820
|
+
const fileName = `assertWithAI_step_${h.index + 1}.png`;
|
|
4821
|
+
const relative = await h.addScreenshot(fileName);
|
|
4822
|
+
const screenshotFullPath = path8.join(h.context.runDir, relative);
|
|
4823
|
+
const imageBase64 = fs8.readFileSync(screenshotFullPath).toString("base64");
|
|
4824
|
+
const assertVision = h.context.assertVision ?? assertWithAiVision;
|
|
4825
|
+
const verdict = await assertVision(
|
|
4826
|
+
{ imageBase64, mediaType: "image/png", assertion },
|
|
4827
|
+
aiConfig
|
|
4828
|
+
);
|
|
4829
|
+
if (verdict.pass) {
|
|
4830
|
+
return {
|
|
4831
|
+
kind: "result",
|
|
4832
|
+
result: {
|
|
4833
|
+
type: "assertWithAI",
|
|
4834
|
+
status: "pass",
|
|
4835
|
+
durationMs: Date.now() - h.stepStart,
|
|
4836
|
+
value: verdict.reason,
|
|
4837
|
+
screenshot: relative
|
|
4838
|
+
}
|
|
4839
|
+
};
|
|
4840
|
+
}
|
|
4841
|
+
throw new Error(`AI assertion failed: ${verdict.reason}`);
|
|
4842
|
+
}
|
|
4843
|
+
},
|
|
4123
4844
|
copyText: {
|
|
4124
4845
|
capabilities: ["query"],
|
|
4125
4846
|
run: async (h) => {
|
|
@@ -5882,6 +6603,273 @@ async function readStatusMenu(client, menuTimeoutSeconds) {
|
|
|
5882
6603
|
}
|
|
5883
6604
|
}
|
|
5884
6605
|
|
|
6606
|
+
// src/analyzer/android.ts
|
|
6607
|
+
var ANDROID_INTERACTIVE_CLASSES = /* @__PURE__ */ new Set([
|
|
6608
|
+
"android.widget.Button",
|
|
6609
|
+
"android.widget.ImageButton",
|
|
6610
|
+
"android.widget.EditText",
|
|
6611
|
+
"android.widget.CheckBox",
|
|
6612
|
+
"android.widget.RadioButton",
|
|
6613
|
+
"android.widget.Switch",
|
|
6614
|
+
"android.widget.ToggleButton",
|
|
6615
|
+
"android.widget.Spinner",
|
|
6616
|
+
"android.widget.SeekBar",
|
|
6617
|
+
"android.widget.RatingBar",
|
|
6618
|
+
"android.widget.CompoundButton",
|
|
6619
|
+
"android.widget.AutoCompleteTextView",
|
|
6620
|
+
"android.widget.MultiAutoCompleteTextView",
|
|
6621
|
+
"android.widget.CheckedTextView",
|
|
6622
|
+
"androidx.appcompat.widget.SwitchCompat",
|
|
6623
|
+
"androidx.appcompat.widget.AppCompatButton",
|
|
6624
|
+
"androidx.appcompat.widget.AppCompatEditText"
|
|
6625
|
+
]);
|
|
6626
|
+
function str2(value) {
|
|
6627
|
+
if (value === void 0) {
|
|
6628
|
+
return void 0;
|
|
6629
|
+
}
|
|
6630
|
+
const trimmed = value.trim();
|
|
6631
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
6632
|
+
}
|
|
6633
|
+
function bool(value) {
|
|
6634
|
+
if (value === void 0) {
|
|
6635
|
+
return void 0;
|
|
6636
|
+
}
|
|
6637
|
+
return value === "true";
|
|
6638
|
+
}
|
|
6639
|
+
function toAndroidNode(element) {
|
|
6640
|
+
const a = element.attrs;
|
|
6641
|
+
return {
|
|
6642
|
+
className: str2(a["class"]),
|
|
6643
|
+
resourceId: str2(a["resource-id"]),
|
|
6644
|
+
contentDesc: str2(a["content-desc"]),
|
|
6645
|
+
text: str2(a.text),
|
|
6646
|
+
package: str2(a.package),
|
|
6647
|
+
clickable: bool(a.clickable),
|
|
6648
|
+
longClickable: bool(a["long-clickable"]),
|
|
6649
|
+
checkable: bool(a.checkable),
|
|
6650
|
+
checked: bool(a.checked),
|
|
6651
|
+
scrollable: bool(a.scrollable),
|
|
6652
|
+
focusable: bool(a.focusable),
|
|
6653
|
+
focused: bool(a.focused),
|
|
6654
|
+
enabled: bool(a.enabled),
|
|
6655
|
+
children: element.children.map(toAndroidNode)
|
|
6656
|
+
};
|
|
6657
|
+
}
|
|
6658
|
+
function parseAndroidHierarchy(xml) {
|
|
6659
|
+
const root = parseXml(xml);
|
|
6660
|
+
return root ? toAndroidNode(root) : null;
|
|
6661
|
+
}
|
|
6662
|
+
function isAndroidInteractive(node) {
|
|
6663
|
+
if (node.clickable || node.longClickable || node.checkable || node.scrollable) {
|
|
6664
|
+
return true;
|
|
6665
|
+
}
|
|
6666
|
+
return node.className !== void 0 && ANDROID_INTERACTIVE_CLASSES.has(node.className);
|
|
6667
|
+
}
|
|
6668
|
+
function rankAndroidSelectors(node) {
|
|
6669
|
+
return rankNativeSelectors({
|
|
6670
|
+
id: node.resourceId,
|
|
6671
|
+
label: node.contentDesc,
|
|
6672
|
+
role: node.className,
|
|
6673
|
+
name: node.text
|
|
6674
|
+
});
|
|
6675
|
+
}
|
|
6676
|
+
function androidNodeToNative(node) {
|
|
6677
|
+
return {
|
|
6678
|
+
...node.resourceId !== void 0 ? { id: node.resourceId } : {},
|
|
6679
|
+
...node.contentDesc !== void 0 ? { label: node.contentDesc } : {},
|
|
6680
|
+
...node.className !== void 0 ? { role: node.className } : {},
|
|
6681
|
+
textValues: node.text !== void 0 ? [node.text] : [],
|
|
6682
|
+
...node.focused !== void 0 ? { focused: node.focused } : {}
|
|
6683
|
+
};
|
|
6684
|
+
}
|
|
6685
|
+
function matchAndroidSelector(xml, selector, options = {}) {
|
|
6686
|
+
const parsedSelector = parseNativeSelector(selector);
|
|
6687
|
+
const root = parseAndroidHierarchy(xml);
|
|
6688
|
+
if (!root) {
|
|
6689
|
+
return [];
|
|
6690
|
+
}
|
|
6691
|
+
return matchNativeTree(
|
|
6692
|
+
ANDROID_MATCH_DIALECT,
|
|
6693
|
+
parsedSelector,
|
|
6694
|
+
root,
|
|
6695
|
+
androidNodeToNative,
|
|
6696
|
+
(node) => node.children,
|
|
6697
|
+
options
|
|
6698
|
+
);
|
|
6699
|
+
}
|
|
6700
|
+
function toElement2(node) {
|
|
6701
|
+
return {
|
|
6702
|
+
className: node.className ?? "?",
|
|
6703
|
+
...node.resourceId ? { resourceId: node.resourceId } : {},
|
|
6704
|
+
...node.contentDesc ? { contentDesc: node.contentDesc } : {},
|
|
6705
|
+
...node.text ? { text: node.text } : {},
|
|
6706
|
+
...node.clickable !== void 0 ? { clickable: node.clickable } : {},
|
|
6707
|
+
...node.checkable !== void 0 ? { checkable: node.checkable } : {},
|
|
6708
|
+
...node.scrollable !== void 0 ? { scrollable: node.scrollable } : {},
|
|
6709
|
+
...node.enabled !== void 0 ? { enabled: node.enabled } : {},
|
|
6710
|
+
selectors: rankAndroidSelectors(node)
|
|
6711
|
+
};
|
|
6712
|
+
}
|
|
6713
|
+
function collectInteractive2(root) {
|
|
6714
|
+
const out = [];
|
|
6715
|
+
const visit = (node) => {
|
|
6716
|
+
if (isAndroidInteractive(node)) {
|
|
6717
|
+
out.push(toElement2(node));
|
|
6718
|
+
}
|
|
6719
|
+
for (const child of node.children) {
|
|
6720
|
+
visit(child);
|
|
6721
|
+
}
|
|
6722
|
+
};
|
|
6723
|
+
visit(root);
|
|
6724
|
+
return out;
|
|
6725
|
+
}
|
|
6726
|
+
async function analyzeAndroidApp(client, options) {
|
|
6727
|
+
const xml = await client.source();
|
|
6728
|
+
const root = parseAndroidHierarchy(xml);
|
|
6729
|
+
const elements = root ? collectInteractive2(root) : [];
|
|
6730
|
+
return { app: options.app, elements };
|
|
6731
|
+
}
|
|
6732
|
+
|
|
6733
|
+
// src/analyzer/ios.ts
|
|
6734
|
+
var IOS_INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
|
|
6735
|
+
"XCUIElementTypeButton",
|
|
6736
|
+
"XCUIElementTypeCell",
|
|
6737
|
+
"XCUIElementTypeTextField",
|
|
6738
|
+
"XCUIElementTypeSecureTextField",
|
|
6739
|
+
"XCUIElementTypeSearchField",
|
|
6740
|
+
"XCUIElementTypeSwitch",
|
|
6741
|
+
"XCUIElementTypeToggle",
|
|
6742
|
+
"XCUIElementTypeLink",
|
|
6743
|
+
"XCUIElementTypeMenuItem",
|
|
6744
|
+
"XCUIElementTypeSlider",
|
|
6745
|
+
"XCUIElementTypeStepper",
|
|
6746
|
+
"XCUIElementTypeTextView",
|
|
6747
|
+
"XCUIElementTypePickerWheel",
|
|
6748
|
+
"XCUIElementTypeTab",
|
|
6749
|
+
"XCUIElementTypeSegmentedControl",
|
|
6750
|
+
"XCUIElementTypeCheckBox",
|
|
6751
|
+
"XCUIElementTypeRadioButton",
|
|
6752
|
+
"XCUIElementTypeKey"
|
|
6753
|
+
]);
|
|
6754
|
+
var IOS_WINDOW_TYPE = "XCUIElementTypeWindow";
|
|
6755
|
+
function str3(value) {
|
|
6756
|
+
if (value === void 0) {
|
|
6757
|
+
return void 0;
|
|
6758
|
+
}
|
|
6759
|
+
const trimmed = value.trim();
|
|
6760
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
6761
|
+
}
|
|
6762
|
+
function bool2(value) {
|
|
6763
|
+
if (value === void 0) {
|
|
6764
|
+
return void 0;
|
|
6765
|
+
}
|
|
6766
|
+
return value === "true" || value === "1";
|
|
6767
|
+
}
|
|
6768
|
+
function toIosNode(element) {
|
|
6769
|
+
const a = element.attrs;
|
|
6770
|
+
return {
|
|
6771
|
+
type: str3(a.type) ?? str3(element.tag),
|
|
6772
|
+
name: str3(a.name),
|
|
6773
|
+
label: str3(a.label),
|
|
6774
|
+
value: str3(a.value),
|
|
6775
|
+
enabled: bool2(a.enabled),
|
|
6776
|
+
visible: bool2(a.visible),
|
|
6777
|
+
children: element.children.map(toIosNode)
|
|
6778
|
+
};
|
|
6779
|
+
}
|
|
6780
|
+
function parseIosHierarchy(xml) {
|
|
6781
|
+
const root = parseXml(xml);
|
|
6782
|
+
return root ? toIosNode(root) : null;
|
|
6783
|
+
}
|
|
6784
|
+
function hasAccessibilityId(node) {
|
|
6785
|
+
return node.name !== void 0 && node.name !== node.label;
|
|
6786
|
+
}
|
|
6787
|
+
function rankIosSelectors(node) {
|
|
6788
|
+
return rankNativeSelectors({
|
|
6789
|
+
id: hasAccessibilityId(node) ? node.name : void 0,
|
|
6790
|
+
label: node.label,
|
|
6791
|
+
role: node.type ? shortIosType(node.type) : void 0,
|
|
6792
|
+
name: node.label ?? node.value
|
|
6793
|
+
});
|
|
6794
|
+
}
|
|
6795
|
+
function iosNodeToNative(node) {
|
|
6796
|
+
const textValues = [];
|
|
6797
|
+
if (node.label !== void 0) {
|
|
6798
|
+
textValues.push(node.label);
|
|
6799
|
+
}
|
|
6800
|
+
if (node.value !== void 0) {
|
|
6801
|
+
textValues.push(node.value);
|
|
6802
|
+
}
|
|
6803
|
+
return {
|
|
6804
|
+
...node.name !== void 0 ? { id: node.name } : {},
|
|
6805
|
+
...node.label !== void 0 ? { label: node.label } : {},
|
|
6806
|
+
...node.type !== void 0 ? { role: node.type } : {},
|
|
6807
|
+
textValues
|
|
6808
|
+
};
|
|
6809
|
+
}
|
|
6810
|
+
function matchIosSelector(xml, selector) {
|
|
6811
|
+
const parsedSelector = parseNativeSelector(selector);
|
|
6812
|
+
const root = parseIosHierarchy(xml);
|
|
6813
|
+
if (!root) {
|
|
6814
|
+
return [];
|
|
6815
|
+
}
|
|
6816
|
+
return matchNativeTree(
|
|
6817
|
+
IOS_MATCH_DIALECT,
|
|
6818
|
+
parsedSelector,
|
|
6819
|
+
root,
|
|
6820
|
+
iosNodeToNative,
|
|
6821
|
+
(node) => node.children
|
|
6822
|
+
);
|
|
6823
|
+
}
|
|
6824
|
+
function toElement3(node) {
|
|
6825
|
+
return {
|
|
6826
|
+
type: node.type ?? "?",
|
|
6827
|
+
...node.name ? { name: node.name } : {},
|
|
6828
|
+
...node.label ? { label: node.label } : {},
|
|
6829
|
+
...node.value ? { value: node.value } : {},
|
|
6830
|
+
...node.enabled !== void 0 ? { enabled: node.enabled } : {},
|
|
6831
|
+
...node.visible !== void 0 ? { visible: node.visible } : {},
|
|
6832
|
+
selectors: rankIosSelectors(node)
|
|
6833
|
+
};
|
|
6834
|
+
}
|
|
6835
|
+
function toWindow2(node) {
|
|
6836
|
+
const [best] = rankIosSelectors(node);
|
|
6837
|
+
return {
|
|
6838
|
+
...node.name ? { name: node.name } : {},
|
|
6839
|
+
...node.label ? { label: node.label } : {},
|
|
6840
|
+
selector: best ?? `role=${shortIosType(IOS_WINDOW_TYPE)}`
|
|
6841
|
+
};
|
|
6842
|
+
}
|
|
6843
|
+
function isIosInteractive(node) {
|
|
6844
|
+
return node.type !== void 0 && IOS_INTERACTIVE_TYPES.has(node.type);
|
|
6845
|
+
}
|
|
6846
|
+
function collect(root) {
|
|
6847
|
+
const elements = [];
|
|
6848
|
+
const windows = [];
|
|
6849
|
+
const visit = (node) => {
|
|
6850
|
+
if (node.type === IOS_WINDOW_TYPE) {
|
|
6851
|
+
windows.push(toWindow2(node));
|
|
6852
|
+
}
|
|
6853
|
+
if (isIosInteractive(node)) {
|
|
6854
|
+
elements.push(toElement3(node));
|
|
6855
|
+
}
|
|
6856
|
+
for (const child of node.children) {
|
|
6857
|
+
visit(child);
|
|
6858
|
+
}
|
|
6859
|
+
};
|
|
6860
|
+
visit(root);
|
|
6861
|
+
return { elements, windows };
|
|
6862
|
+
}
|
|
6863
|
+
async function analyzeIosApp(client, options) {
|
|
6864
|
+
const xml = await client.source();
|
|
6865
|
+
const root = parseIosHierarchy(xml);
|
|
6866
|
+
if (!root) {
|
|
6867
|
+
return { app: options.app, elements: [], windows: [] };
|
|
6868
|
+
}
|
|
6869
|
+
const { elements, windows } = collect(root);
|
|
6870
|
+
return { app: options.app, elements, windows };
|
|
6871
|
+
}
|
|
6872
|
+
|
|
5885
6873
|
// src/generator/index.ts
|
|
5886
6874
|
import yaml from "yaml";
|
|
5887
6875
|
|
|
@@ -5942,6 +6930,7 @@ var STEP_REFERENCE = `
|
|
|
5942
6930
|
|
|
5943
6931
|
### Visual Regression
|
|
5944
6932
|
- assertScreenshot: { name: "baseline-name", threshold: 0.1 }
|
|
6933
|
+
- assertWithAI: "The login form should show email and password fields" \u2014 AI checks the screenshot against the claim
|
|
5945
6934
|
|
|
5946
6935
|
### Control Flow
|
|
5947
6936
|
- if: { visible: ".banner", then: [steps...] }
|
|
@@ -5983,81 +6972,6 @@ function extractYamlFromResponse(response) {
|
|
|
5983
6972
|
return response.trim();
|
|
5984
6973
|
}
|
|
5985
6974
|
|
|
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
6975
|
// src/generator/index.ts
|
|
6062
6976
|
function parseViewportFlag2(value) {
|
|
6063
6977
|
const match = /^(\d+)x(\d+)$/i.exec(value);
|
|
@@ -6124,6 +7038,23 @@ export {
|
|
|
6124
7038
|
SpawnMacHelperClient,
|
|
6125
7039
|
launchMacSession,
|
|
6126
7040
|
closeMacSession,
|
|
7041
|
+
decodeXmlEntities,
|
|
7042
|
+
parseXml,
|
|
7043
|
+
unquoteSelectorValue,
|
|
7044
|
+
quoteSelectorValue,
|
|
7045
|
+
parseNativeSelector,
|
|
7046
|
+
unwrapNativeTextSelector,
|
|
7047
|
+
qualifyResourceId,
|
|
7048
|
+
normalizeXcuiClassName,
|
|
7049
|
+
shortIosType,
|
|
7050
|
+
rankNativeSelectors,
|
|
7051
|
+
NATIVE_ATTRIBUTE_MAP,
|
|
7052
|
+
ANDROID_MATCH_DIALECT,
|
|
7053
|
+
IOS_MATCH_DIALECT,
|
|
7054
|
+
MACOS_MATCH_DIALECT,
|
|
7055
|
+
nodeMatchesSelector,
|
|
7056
|
+
matchNativeTree,
|
|
7057
|
+
parseSnapshot,
|
|
6127
7058
|
ANDROID_KEYCODES,
|
|
6128
7059
|
escapeUiSelectorArg,
|
|
6129
7060
|
parseAndroidSelector,
|
|
@@ -6150,7 +7081,6 @@ export {
|
|
|
6150
7081
|
closeAndroidSession,
|
|
6151
7082
|
IOS_PRESS_KEYS,
|
|
6152
7083
|
escapePredicateArg,
|
|
6153
|
-
normalizeXcuiClassName,
|
|
6154
7084
|
parseIosSelector,
|
|
6155
7085
|
iosQueryToLocator,
|
|
6156
7086
|
unwrapIosTextSelector,
|
|
@@ -6170,11 +7100,14 @@ export {
|
|
|
6170
7100
|
waitForWdaReady,
|
|
6171
7101
|
createWdaAgentClient,
|
|
6172
7102
|
WDA_RUNNER_BUNDLE_ID,
|
|
6173
|
-
|
|
7103
|
+
WDA_USE_PORT_ENV,
|
|
6174
7104
|
defaultIosAgentConnector,
|
|
6175
7105
|
resolveWdaProject,
|
|
6176
7106
|
wdaCacheDir,
|
|
6177
|
-
|
|
7107
|
+
resolveWdaTestRun,
|
|
7108
|
+
injectUsePortIntoXctestrun,
|
|
7109
|
+
defaultWdaTestRunPreparer,
|
|
7110
|
+
wdaTestRunArgs,
|
|
6178
7111
|
launchIosSession,
|
|
6179
7112
|
closeIosSession,
|
|
6180
7113
|
extractSelectorIntent,
|
|
@@ -6197,6 +7130,21 @@ export {
|
|
|
6197
7130
|
DEFAULT_ANALYZE_TREE_DEPTH,
|
|
6198
7131
|
rankMacSelectors,
|
|
6199
7132
|
analyzeMacApp,
|
|
7133
|
+
ANDROID_INTERACTIVE_CLASSES,
|
|
7134
|
+
parseAndroidHierarchy,
|
|
7135
|
+
isAndroidInteractive,
|
|
7136
|
+
rankAndroidSelectors,
|
|
7137
|
+
androidNodeToNative,
|
|
7138
|
+
matchAndroidSelector,
|
|
7139
|
+
analyzeAndroidApp,
|
|
7140
|
+
IOS_INTERACTIVE_TYPES,
|
|
7141
|
+
IOS_WINDOW_TYPE,
|
|
7142
|
+
parseIosHierarchy,
|
|
7143
|
+
rankIosSelectors,
|
|
7144
|
+
iosNodeToNative,
|
|
7145
|
+
matchIosSelector,
|
|
7146
|
+
isIosInteractive,
|
|
7147
|
+
analyzeIosApp,
|
|
6200
7148
|
generateHunt
|
|
6201
7149
|
};
|
|
6202
|
-
//# sourceMappingURL=chunk-
|
|
7150
|
+
//# sourceMappingURL=chunk-5KQR3IR3.js.map
|