prowl-tools 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +10 -0
- package/README.md +519 -2
- package/dist/{chunk-ZEFVTKQT.js → chunk-5KQR3IR3.js} +3321 -346
- package/dist/chunk-5KQR3IR3.js.map +1 -0
- package/dist/{chunk-MBAIGNVO.js → chunk-WHAMB4TY.js} +43 -3
- package/dist/chunk-WHAMB4TY.js.map +1 -0
- package/dist/index.cjs +3527 -481
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +325 -67
- package/dist/index.js.map +1 -1
- package/dist/lib.cjs +3444 -325
- package/dist/lib.cjs.map +1 -1
- package/dist/lib.d.cts +1236 -11
- package/dist/lib.d.ts +1236 -11
- package/dist/lib.js +194 -2
- package/dist/{loader-XMTDB6OL.js → loader-PBBYV3U7.js} +2 -2
- package/package.json +5 -1
- package/dist/chunk-MBAIGNVO.js.map +0 -1
- package/dist/chunk-ZEFVTKQT.js.map +0 -1
- /package/dist/{loader-XMTDB6OL.js.map → loader-PBBYV3U7.js.map} +0 -0
package/dist/lib.cjs
CHANGED
|
@@ -37,12 +37,12 @@ __export(visual_exports, {
|
|
|
37
37
|
ensureBaselineDir: () => ensureBaselineDir
|
|
38
38
|
});
|
|
39
39
|
async function compareScreenshots(baselinePath, currentPath, diffPath, threshold) {
|
|
40
|
-
const baselineData = import_pngjs.PNG.sync.read(
|
|
41
|
-
const currentData = import_pngjs.PNG.sync.read(
|
|
40
|
+
const baselineData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(baselinePath));
|
|
41
|
+
const currentData = import_pngjs.PNG.sync.read(import_node_fs8.default.readFileSync(currentPath));
|
|
42
42
|
const { width, height } = baselineData;
|
|
43
43
|
if (currentData.width !== width || currentData.height !== height) {
|
|
44
44
|
const diff2 = new import_pngjs.PNG({ width, height });
|
|
45
|
-
|
|
45
|
+
import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff2));
|
|
46
46
|
return {
|
|
47
47
|
match: false,
|
|
48
48
|
diffPercentage: 1,
|
|
@@ -60,7 +60,7 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
|
|
|
60
60
|
);
|
|
61
61
|
const totalPixels = width * height;
|
|
62
62
|
const diffPercentage = diffPixels / totalPixels;
|
|
63
|
-
|
|
63
|
+
import_node_fs8.default.writeFileSync(diffPath, import_pngjs.PNG.sync.write(diff));
|
|
64
64
|
return {
|
|
65
65
|
match: diffPercentage <= threshold,
|
|
66
66
|
diffPercentage,
|
|
@@ -68,16 +68,16 @@ async function compareScreenshots(baselinePath, currentPath, diffPath, threshold
|
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
70
|
function ensureBaselineDir(configDir) {
|
|
71
|
-
const baselineDir =
|
|
72
|
-
|
|
71
|
+
const baselineDir = import_node_path8.default.join(configDir, "baselines");
|
|
72
|
+
import_node_fs8.default.mkdirSync(baselineDir, { recursive: true });
|
|
73
73
|
return baselineDir;
|
|
74
74
|
}
|
|
75
|
-
var
|
|
75
|
+
var import_node_fs8, import_node_path8, import_pngjs, import_pixelmatch;
|
|
76
76
|
var init_visual = __esm({
|
|
77
77
|
"src/runner/visual.ts"() {
|
|
78
78
|
"use strict";
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
import_node_fs8 = __toESM(require("fs"), 1);
|
|
80
|
+
import_node_path8 = __toESM(require("path"), 1);
|
|
81
81
|
import_pngjs = require("pngjs");
|
|
82
82
|
import_pixelmatch = __toESM(require("pixelmatch"), 1);
|
|
83
83
|
}
|
|
@@ -86,41 +86,137 @@ var init_visual = __esm({
|
|
|
86
86
|
// src/index.ts
|
|
87
87
|
var src_exports = {};
|
|
88
88
|
__export(src_exports, {
|
|
89
|
+
ANDROID_INTERACTIVE_CLASSES: () => ANDROID_INTERACTIVE_CLASSES,
|
|
90
|
+
ANDROID_KEYCODES: () => ANDROID_KEYCODES,
|
|
91
|
+
ANDROID_MATCH_DIALECT: () => ANDROID_MATCH_DIALECT,
|
|
92
|
+
DEFAULT_AGENT_REQUEST_TIMEOUT_MS: () => DEFAULT_AGENT_REQUEST_TIMEOUT_MS,
|
|
93
|
+
DEFAULT_ANALYZE_TREE_DEPTH: () => DEFAULT_ANALYZE_TREE_DEPTH,
|
|
89
94
|
DEFAULT_FLAKY_THRESHOLD: () => DEFAULT_FLAKY_THRESHOLD,
|
|
90
95
|
DEFAULT_REQUEST_TIMEOUT_MS: () => DEFAULT_REQUEST_TIMEOUT_MS,
|
|
96
|
+
DEFAULT_SIMULATOR_LOCK_ROOT: () => DEFAULT_SIMULATOR_LOCK_ROOT,
|
|
97
|
+
DEFAULT_WDA_REQUEST_TIMEOUT_MS: () => DEFAULT_WDA_REQUEST_TIMEOUT_MS,
|
|
98
|
+
INTERACTIVE_ROLES: () => INTERACTIVE_ROLES,
|
|
99
|
+
IOS_INTERACTIVE_TYPES: () => IOS_INTERACTIVE_TYPES,
|
|
100
|
+
IOS_MATCH_DIALECT: () => IOS_MATCH_DIALECT,
|
|
101
|
+
IOS_PRESS_KEYS: () => IOS_PRESS_KEYS,
|
|
102
|
+
IOS_WINDOW_TYPE: () => IOS_WINDOW_TYPE,
|
|
103
|
+
MACOS_MATCH_DIALECT: () => MACOS_MATCH_DIALECT,
|
|
104
|
+
NATIVE_ATTRIBUTE_MAP: () => NATIVE_ATTRIBUTE_MAP,
|
|
91
105
|
SpawnMacHelperClient: () => SpawnMacHelperClient,
|
|
106
|
+
UIA2_REMOTE_PORT: () => UIA2_REMOTE_PORT,
|
|
107
|
+
Uia2HttpError: () => Uia2HttpError,
|
|
108
|
+
Uia2Transport: () => Uia2Transport,
|
|
109
|
+
WDA_RUNNER_BUNDLE_ID: () => WDA_RUNNER_BUNDLE_ID,
|
|
110
|
+
WDA_USE_PORT_ENV: () => WDA_USE_PORT_ENV,
|
|
92
111
|
WEB_ONLY_STEP_TYPES: () => WEB_ONLY_STEP_TYPES,
|
|
112
|
+
WdaHttpError: () => WdaHttpError,
|
|
113
|
+
WdaTransport: () => WdaTransport,
|
|
114
|
+
analyzeAndroidApp: () => analyzeAndroidApp,
|
|
115
|
+
analyzeIosApp: () => analyzeIosApp,
|
|
116
|
+
analyzeMacApp: () => analyzeMacApp,
|
|
93
117
|
analyzePage: () => analyzePage,
|
|
118
|
+
androidAppAllowedIdentities: () => androidAppAllowedIdentities,
|
|
119
|
+
androidNodeToNative: () => androidNodeToNative,
|
|
120
|
+
androidQueryToLocator: () => androidQueryToLocator,
|
|
121
|
+
assertAndroidAppAllowed: () => assertAndroidAppAllowed,
|
|
122
|
+
assertIosAppAllowed: () => assertIosAppAllowed,
|
|
94
123
|
assertStepsSupportedByTarget: () => assertStepsSupportedByTarget,
|
|
95
124
|
assertTargetAppAllowed: () => assertTargetAppAllowed,
|
|
125
|
+
bootedDevices: () => bootedDevices,
|
|
126
|
+
bootedSimulators: () => bootedSimulators,
|
|
96
127
|
buildHealCandidates: () => buildHealCandidates,
|
|
128
|
+
closeAndroidSession: () => closeAndroidSession,
|
|
129
|
+
closeIosSession: () => closeIosSession,
|
|
97
130
|
closeMacSession: () => closeMacSession,
|
|
98
131
|
clusterFailures: () => clusterFailures,
|
|
99
132
|
computeFlakeScore: () => computeFlakeScore,
|
|
100
133
|
configSchema: () => configSchema,
|
|
134
|
+
createAndroidDriver: () => createAndroidDriver,
|
|
135
|
+
createIosDriver: () => createIosDriver,
|
|
101
136
|
createMacDriver: () => createMacDriver,
|
|
137
|
+
createUia2AgentClient: () => createUia2AgentClient,
|
|
138
|
+
createUia2Session: () => createUia2Session,
|
|
139
|
+
createWdaAgentClient: () => createWdaAgentClient,
|
|
140
|
+
createWdaSession: () => createWdaSession,
|
|
141
|
+
decodeXmlEntities: () => decodeXmlEntities,
|
|
142
|
+
defaultAgentConnector: () => defaultAgentConnector,
|
|
143
|
+
defaultIosAgentConnector: () => defaultIosAgentConnector,
|
|
144
|
+
defaultWdaTestRunPreparer: () => defaultWdaTestRunPreparer,
|
|
145
|
+
escapePredicateArg: () => escapePredicateArg,
|
|
146
|
+
escapeUiSelectorArg: () => escapeUiSelectorArg,
|
|
147
|
+
extractElementId: () => extractElementId,
|
|
102
148
|
extractFailures: () => extractFailures,
|
|
103
149
|
extractSelectorIntent: () => extractSelectorIntent,
|
|
150
|
+
findFreePort: () => findFreePort,
|
|
104
151
|
generateHunt: () => generateHunt,
|
|
105
152
|
healSelector: () => healSelector,
|
|
106
153
|
huntSchema: () => huntSchema,
|
|
154
|
+
injectUsePortIntoXctestrun: () => injectUsePortIntoXctestrun,
|
|
107
155
|
interpolateHunt: () => interpolateHunt,
|
|
156
|
+
iosAppAllowedIdentities: () => iosAppAllowedIdentities,
|
|
157
|
+
iosNodeToNative: () => iosNodeToNative,
|
|
158
|
+
iosQueryToLocator: () => iosQueryToLocator,
|
|
159
|
+
isAndroidInteractive: () => isAndroidInteractive,
|
|
160
|
+
isIosInteractive: () => isIosInteractive,
|
|
161
|
+
launchAndroidSession: () => launchAndroidSession,
|
|
162
|
+
launchIosSession: () => launchIosSession,
|
|
108
163
|
launchMacSession: () => launchMacSession,
|
|
164
|
+
listDevices: () => listDevices,
|
|
109
165
|
listHunts: () => listHunts,
|
|
166
|
+
listSimulators: () => listSimulators,
|
|
110
167
|
loadConfig: () => loadConfig,
|
|
111
168
|
loadHunt: () => loadHunt,
|
|
112
169
|
loadHuntMeta: () => loadHuntMeta,
|
|
113
170
|
loadHuntTags: () => loadHuntTags,
|
|
114
171
|
macdriverBuildInstructions: () => macdriverBuildInstructions,
|
|
172
|
+
matchAndroidSelector: () => matchAndroidSelector,
|
|
173
|
+
matchIosSelector: () => matchIosSelector,
|
|
174
|
+
matchNativeTree: () => matchNativeTree,
|
|
175
|
+
nodeMatchesSelector: () => nodeMatchesSelector,
|
|
176
|
+
normalizeXcuiClassName: () => normalizeXcuiClassName,
|
|
177
|
+
parseAaptPackage: () => parseAaptPackage,
|
|
178
|
+
parseAdbDevices: () => parseAdbDevices,
|
|
179
|
+
parseAndroidHierarchy: () => parseAndroidHierarchy,
|
|
180
|
+
parseAndroidSelector: () => parseAndroidSelector,
|
|
181
|
+
parseForwardPort: () => parseForwardPort,
|
|
182
|
+
parseIosHierarchy: () => parseIosHierarchy,
|
|
183
|
+
parseIosSelector: () => parseIosSelector,
|
|
115
184
|
parseMacSelector: () => parseMacSelector,
|
|
185
|
+
parseNativeSelector: () => parseNativeSelector,
|
|
186
|
+
parseSimctlDevices: () => parseSimctlDevices,
|
|
187
|
+
parseSnapshot: () => parseSnapshot,
|
|
188
|
+
parseXcodeVersion: () => parseXcodeVersion,
|
|
189
|
+
parseXml: () => parseXml,
|
|
190
|
+
qualifyResourceId: () => qualifyResourceId,
|
|
191
|
+
quoteSelectorValue: () => quoteSelectorValue,
|
|
192
|
+
rankAndroidSelectors: () => rankAndroidSelectors,
|
|
116
193
|
rankFlaky: () => rankFlaky,
|
|
194
|
+
rankIosSelectors: () => rankIosSelectors,
|
|
195
|
+
rankMacSelectors: () => rankMacSelectors,
|
|
196
|
+
rankNativeSelectors: () => rankNativeSelectors,
|
|
117
197
|
readHistory: () => readHistory,
|
|
118
198
|
readHuntHistory: () => readHuntHistory,
|
|
199
|
+
readIosBundleIdentifier: () => readIosBundleIdentifier,
|
|
200
|
+
reserveSimulatorUdid: () => reserveSimulatorUdid,
|
|
201
|
+
resolveAgentApks: () => resolveAgentApks,
|
|
119
202
|
resolveHelperBinary: () => resolveHelperBinary,
|
|
203
|
+
resolveWdaProject: () => resolveWdaProject,
|
|
204
|
+
resolveWdaTestRun: () => resolveWdaTestRun,
|
|
120
205
|
runHunt: () => runHunt,
|
|
121
206
|
runSuite: () => runSuite,
|
|
207
|
+
selectDeviceSerial: () => selectDeviceSerial,
|
|
208
|
+
selectSimulatorUdid: () => selectSimulatorUdid,
|
|
209
|
+
shortIosType: () => shortIosType,
|
|
122
210
|
stepSchema: () => stepSchema,
|
|
211
|
+
unquoteSelectorValue: () => unquoteSelectorValue,
|
|
212
|
+
unwrapAndroidTextSelector: () => unwrapAndroidTextSelector,
|
|
213
|
+
unwrapIosTextSelector: () => unwrapIosTextSelector,
|
|
214
|
+
unwrapNativeTextSelector: () => unwrapNativeTextSelector,
|
|
123
215
|
updateBacklogFromSuite: () => updateBacklogFromSuite,
|
|
216
|
+
waitForAgentReady: () => waitForAgentReady,
|
|
217
|
+
waitForWdaReady: () => waitForWdaReady,
|
|
218
|
+
wdaCacheDir: () => wdaCacheDir,
|
|
219
|
+
wdaTestRunArgs: () => wdaTestRunArgs,
|
|
124
220
|
webOnlyReason: () => webOnlyReason
|
|
125
221
|
});
|
|
126
222
|
module.exports = __toCommonJS(src_exports);
|
|
@@ -273,7 +369,11 @@ function createMacDriver(client, options = {}) {
|
|
|
273
369
|
return rejectUnsupported("evalScript");
|
|
274
370
|
},
|
|
275
371
|
async screenshot(screenshotOptions) {
|
|
276
|
-
await client.request("screenshot", { path: screenshotOptions.path });
|
|
372
|
+
const result = await client.request("screenshot", { path: screenshotOptions.path });
|
|
373
|
+
const warning = result.warning;
|
|
374
|
+
if (typeof warning === "string" && warning.length > 0) {
|
|
375
|
+
console.warn(`macOS screenshot fell back to full screen: ${warning}`);
|
|
376
|
+
}
|
|
277
377
|
},
|
|
278
378
|
// network / dialogs / downloads (all web-only) -------------------------
|
|
279
379
|
onResponse(_handler) {
|
|
@@ -504,147 +604,2313 @@ async function closeMacSession(session) {
|
|
|
504
604
|
}
|
|
505
605
|
}
|
|
506
606
|
|
|
507
|
-
// src/
|
|
508
|
-
var import_node_child_process2 = require("child_process");
|
|
607
|
+
// src/browser/android-driver.ts
|
|
509
608
|
var import_node_fs2 = __toESM(require("fs"), 1);
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
"
|
|
514
|
-
"
|
|
515
|
-
"
|
|
516
|
-
"
|
|
517
|
-
"
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
"
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
609
|
+
|
|
610
|
+
// src/analyzer/xml.ts
|
|
611
|
+
var ENTITIES = {
|
|
612
|
+
amp: "&",
|
|
613
|
+
lt: "<",
|
|
614
|
+
gt: ">",
|
|
615
|
+
quot: '"',
|
|
616
|
+
apos: "'"
|
|
617
|
+
};
|
|
618
|
+
function decodeXmlEntities(value) {
|
|
619
|
+
if (!value.includes("&")) {
|
|
620
|
+
return value;
|
|
621
|
+
}
|
|
622
|
+
return value.replace(/&(#(?:[xX][0-9a-fA-F]+|[0-9]+)|[a-zA-Z]+);/g, (match, code) => {
|
|
623
|
+
if (code[0] === "#") {
|
|
624
|
+
const hex = code[1] === "x" || code[1] === "X";
|
|
625
|
+
const num2 = Number.parseInt(code.slice(hex ? 2 : 1), hex ? 16 : 10);
|
|
626
|
+
return Number.isSafeInteger(num2) && num2 <= 1114111 ? String.fromCodePoint(num2) : match;
|
|
627
|
+
}
|
|
628
|
+
const named = ENTITIES[code];
|
|
629
|
+
return named ?? match;
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
var ATTR_RE = /([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
|
633
|
+
function parseTagBody(body) {
|
|
634
|
+
const trimmed = body.trim();
|
|
635
|
+
const nameMatch = /^([^\s/>]+)/.exec(trimmed);
|
|
636
|
+
const tag = nameMatch ? nameMatch[1] : "";
|
|
637
|
+
const attrs = {};
|
|
638
|
+
const rest = trimmed.slice(tag.length);
|
|
639
|
+
ATTR_RE.lastIndex = 0;
|
|
640
|
+
let m;
|
|
641
|
+
while ((m = ATTR_RE.exec(rest)) !== null) {
|
|
642
|
+
const rawValue = m[3] !== void 0 ? m[3] : m[4] ?? "";
|
|
643
|
+
attrs[m[1]] = decodeXmlEntities(rawValue);
|
|
644
|
+
}
|
|
645
|
+
return { tag, attrs };
|
|
646
|
+
}
|
|
647
|
+
function parseXml(input) {
|
|
648
|
+
const n = input.length;
|
|
649
|
+
const stack = [];
|
|
650
|
+
let root = null;
|
|
651
|
+
let i = 0;
|
|
652
|
+
while (i < n) {
|
|
653
|
+
const lt = input.indexOf("<", i);
|
|
654
|
+
if (lt < 0) {
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
i = lt + 1;
|
|
658
|
+
const ch = input[i];
|
|
659
|
+
if (ch === "?") {
|
|
660
|
+
const end = input.indexOf("?>", i);
|
|
661
|
+
i = end < 0 ? n : end + 2;
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
if (ch === "!") {
|
|
665
|
+
if (input.startsWith("!--", i)) {
|
|
666
|
+
const end = input.indexOf("-->", i);
|
|
667
|
+
i = end < 0 ? n : end + 3;
|
|
668
|
+
} else {
|
|
669
|
+
const end = input.indexOf(">", i);
|
|
670
|
+
i = end < 0 ? n : end + 1;
|
|
671
|
+
}
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
if (ch === "/") {
|
|
675
|
+
const gt = input.indexOf(">", i);
|
|
676
|
+
i = gt < 0 ? n : gt + 1;
|
|
677
|
+
stack.pop();
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
let j = i;
|
|
681
|
+
let quote2 = null;
|
|
682
|
+
while (j < n) {
|
|
683
|
+
const c = input[j];
|
|
684
|
+
if (quote2 !== null) {
|
|
685
|
+
if (c === quote2) {
|
|
686
|
+
quote2 = null;
|
|
687
|
+
}
|
|
688
|
+
} else if (c === '"' || c === "'") {
|
|
689
|
+
quote2 = c;
|
|
690
|
+
} else if (c === ">") {
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
j += 1;
|
|
694
|
+
}
|
|
695
|
+
const inner = input.slice(i, j);
|
|
696
|
+
i = j + 1;
|
|
697
|
+
const selfClose = inner.endsWith("/");
|
|
698
|
+
const body = selfClose ? inner.slice(0, -1) : inner;
|
|
699
|
+
const { tag, attrs } = parseTagBody(body);
|
|
700
|
+
if (tag.length === 0) {
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
const element = { tag, attrs, children: [] };
|
|
704
|
+
const parent = stack[stack.length - 1];
|
|
705
|
+
if (parent) {
|
|
706
|
+
parent.children.push(element);
|
|
707
|
+
}
|
|
708
|
+
if (root === null) {
|
|
709
|
+
root = element;
|
|
710
|
+
}
|
|
711
|
+
if (!selfClose) {
|
|
712
|
+
stack.push(element);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return root;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// src/selector/native.ts
|
|
719
|
+
function unquoteSelectorValue(value) {
|
|
720
|
+
const trimmed = value.trim();
|
|
721
|
+
const first = trimmed[0];
|
|
722
|
+
if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
|
|
723
|
+
return trimmed.slice(1, -1);
|
|
724
|
+
}
|
|
725
|
+
return trimmed;
|
|
726
|
+
}
|
|
727
|
+
function quoteSelectorValue(value) {
|
|
728
|
+
return `"${value}"`;
|
|
729
|
+
}
|
|
730
|
+
var ROLE_SELECTOR_RE = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s;
|
|
731
|
+
var NATIVE_SELECTOR_PREFIX_RE = /^(id|label|text|role)=/;
|
|
732
|
+
function invalidSelectorMessage(selector, reason) {
|
|
733
|
+
return `Invalid native selector ${JSON.stringify(selector)}: ${reason}.`;
|
|
734
|
+
}
|
|
735
|
+
function parseNativeSelector(selector) {
|
|
736
|
+
const trimmed = selector.trim();
|
|
737
|
+
if (trimmed.length === 0) {
|
|
738
|
+
throw new Error(
|
|
739
|
+
invalidSelectorMessage(
|
|
740
|
+
selector,
|
|
741
|
+
"selector is empty; use id=, label=, text=, role=, :focus, or a bare text value"
|
|
742
|
+
)
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
if (trimmed === ":focus") {
|
|
746
|
+
return { kind: "focused" };
|
|
747
|
+
}
|
|
748
|
+
const idMatch = /^id=(.+)$/s.exec(trimmed);
|
|
749
|
+
if (idMatch) {
|
|
750
|
+
return { kind: "id", value: unquoteSelectorValue(idMatch[1]) };
|
|
751
|
+
}
|
|
752
|
+
const roleMatch = ROLE_SELECTOR_RE.exec(trimmed);
|
|
753
|
+
if (roleMatch) {
|
|
754
|
+
const name = roleMatch[2] !== void 0 ? unquoteSelectorValue(roleMatch[2]) : void 0;
|
|
755
|
+
return name !== void 0 && name.length > 0 ? { kind: "role", role: roleMatch[1], name } : { kind: "role", role: roleMatch[1] };
|
|
756
|
+
}
|
|
757
|
+
const labelMatch = /^label=(.+)$/s.exec(trimmed);
|
|
758
|
+
if (labelMatch) {
|
|
759
|
+
return { kind: "label", value: unquoteSelectorValue(labelMatch[1]) };
|
|
760
|
+
}
|
|
761
|
+
const textMatch = /^text=(.+)$/s.exec(trimmed);
|
|
762
|
+
if (textMatch) {
|
|
763
|
+
return { kind: "text", value: unquoteSelectorValue(textMatch[1]) };
|
|
764
|
+
}
|
|
765
|
+
const prefix = NATIVE_SELECTOR_PREFIX_RE.exec(trimmed);
|
|
766
|
+
if (prefix) {
|
|
767
|
+
throw new Error(
|
|
768
|
+
invalidSelectorMessage(
|
|
769
|
+
selector,
|
|
770
|
+
`malformed ${prefix[1]}= selector; expected id=<value>, label=<value>, text=<value>, or role=<Type>[name=<value>]`
|
|
771
|
+
)
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
return { kind: "text", value: trimmed };
|
|
775
|
+
}
|
|
776
|
+
function unwrapNativeTextSelector(selector) {
|
|
777
|
+
const trimmed = selector.trim();
|
|
778
|
+
if (!trimmed.startsWith("text=")) {
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
return unquoteSelectorValue(trimmed.slice("text=".length));
|
|
782
|
+
}
|
|
783
|
+
function qualifyResourceId(value, appPackage) {
|
|
784
|
+
if (value.includes(":") || !appPackage) {
|
|
785
|
+
return value;
|
|
786
|
+
}
|
|
787
|
+
return `${appPackage}:id/${value}`;
|
|
788
|
+
}
|
|
789
|
+
function normalizeXcuiClassName(role) {
|
|
790
|
+
return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
|
|
791
|
+
}
|
|
792
|
+
function shortIosType(type) {
|
|
793
|
+
return type.startsWith("XCUIElementType") ? type.slice("XCUIElementType".length) : type;
|
|
794
|
+
}
|
|
795
|
+
function rankNativeSelectors(fields) {
|
|
796
|
+
const selectors = [];
|
|
797
|
+
if (fields.id) {
|
|
798
|
+
selectors.push(`id=${fields.id}`);
|
|
799
|
+
}
|
|
800
|
+
if (fields.label) {
|
|
801
|
+
selectors.push(`label=${quoteSelectorValue(fields.label)}`);
|
|
802
|
+
}
|
|
803
|
+
if (fields.role && fields.name) {
|
|
804
|
+
selectors.push(`role=${fields.role}[name=${quoteSelectorValue(fields.name)}]`);
|
|
805
|
+
}
|
|
806
|
+
if (fields.name) {
|
|
807
|
+
selectors.push(`text=${quoteSelectorValue(fields.name)}`);
|
|
808
|
+
}
|
|
809
|
+
if (selectors.length === 0 && fields.role) {
|
|
810
|
+
selectors.push(`role=${fields.role}`);
|
|
811
|
+
}
|
|
812
|
+
return selectors;
|
|
813
|
+
}
|
|
814
|
+
var NATIVE_ATTRIBUTE_MAP = {
|
|
815
|
+
android: {
|
|
816
|
+
id: { attribute: "resource-id", match: "exact (package-qualified)" },
|
|
817
|
+
label: { attribute: "content-desc", match: "exact" },
|
|
818
|
+
text: { attribute: "text", match: "substring" },
|
|
819
|
+
role: { attribute: "class", match: "exact" }
|
|
820
|
+
},
|
|
821
|
+
ios: {
|
|
822
|
+
id: { attribute: "accessibility id (name)", match: "exact" },
|
|
823
|
+
label: { attribute: "label", match: "exact" },
|
|
824
|
+
text: { attribute: "label | value", match: "substring" },
|
|
825
|
+
role: { attribute: "type (XCUIElementType\u2026)", match: "exact" }
|
|
826
|
+
},
|
|
827
|
+
macos: {
|
|
828
|
+
id: { attribute: "AXIdentifier", match: "exact" },
|
|
829
|
+
label: { attribute: "title | description", match: "exact" },
|
|
830
|
+
text: { attribute: "title | description | value", match: "substring" },
|
|
831
|
+
role: { attribute: "AXRole", match: "exact" }
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
var ANDROID_MATCH_DIALECT = {
|
|
835
|
+
platform: "android",
|
|
836
|
+
normalizeRole: (role) => role,
|
|
837
|
+
normalizeId: (value, options) => qualifyResourceId(value, options.appPackage)
|
|
838
|
+
};
|
|
839
|
+
var IOS_MATCH_DIALECT = {
|
|
840
|
+
platform: "ios",
|
|
841
|
+
normalizeRole: (role) => normalizeXcuiClassName(role),
|
|
842
|
+
normalizeId: (value) => value
|
|
843
|
+
};
|
|
844
|
+
var MACOS_MATCH_DIALECT = {
|
|
845
|
+
platform: "macos",
|
|
846
|
+
normalizeRole: (role) => role,
|
|
847
|
+
normalizeId: (value) => value
|
|
848
|
+
};
|
|
849
|
+
function nodeMatchesSelector(dialect, selector, node, options = {}) {
|
|
850
|
+
switch (selector.kind) {
|
|
851
|
+
case "id":
|
|
852
|
+
return node.id !== void 0 && node.id === dialect.normalizeId(selector.value, options);
|
|
853
|
+
case "label":
|
|
854
|
+
return node.label !== void 0 && node.label === selector.value;
|
|
855
|
+
case "text":
|
|
856
|
+
return node.textValues.some((t) => t.includes(selector.value));
|
|
857
|
+
case "focused":
|
|
858
|
+
return node.focused === true;
|
|
859
|
+
case "role": {
|
|
860
|
+
if (node.role === void 0) {
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
if (dialect.normalizeRole(node.role) !== dialect.normalizeRole(selector.role)) {
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
if (selector.name === void 0 || selector.name.length === 0) {
|
|
867
|
+
return true;
|
|
868
|
+
}
|
|
869
|
+
const name = selector.name;
|
|
870
|
+
return node.textValues.some((t) => t.includes(name));
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
function matchNativeTree(dialect, selector, root, project, children, options = {}) {
|
|
875
|
+
const out = [];
|
|
876
|
+
const visit = (node) => {
|
|
877
|
+
if (nodeMatchesSelector(dialect, selector, project(node), options)) {
|
|
878
|
+
out.push(node);
|
|
879
|
+
}
|
|
880
|
+
for (const child of children(node)) {
|
|
881
|
+
visit(child);
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
visit(root);
|
|
885
|
+
return out;
|
|
886
|
+
}
|
|
887
|
+
function parseSnapshot(xml) {
|
|
888
|
+
return parseXml(xml);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// src/browser/android-driver.ts
|
|
892
|
+
var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
893
|
+
"query",
|
|
894
|
+
"interact",
|
|
895
|
+
"wait",
|
|
896
|
+
"screenshot"
|
|
526
897
|
]);
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
898
|
+
var WAIT_POLL_INTERVAL_MS = 250;
|
|
899
|
+
var DEFAULT_WAIT_TIMEOUT_MS = 5e3;
|
|
900
|
+
var ANDROID_KEYCODES = {
|
|
901
|
+
enter: 66,
|
|
902
|
+
return: 66,
|
|
903
|
+
tab: 61,
|
|
904
|
+
space: 62,
|
|
905
|
+
backspace: 67,
|
|
906
|
+
delete: 67,
|
|
907
|
+
del: 67,
|
|
908
|
+
escape: 111,
|
|
909
|
+
esc: 111,
|
|
910
|
+
back: 4,
|
|
911
|
+
home: 3,
|
|
912
|
+
menu: 82,
|
|
913
|
+
search: 84,
|
|
914
|
+
up: 19,
|
|
915
|
+
arrowup: 19,
|
|
916
|
+
down: 20,
|
|
917
|
+
arrowdown: 20,
|
|
918
|
+
left: 21,
|
|
919
|
+
arrowleft: 21,
|
|
920
|
+
right: 22,
|
|
921
|
+
arrowright: 22,
|
|
922
|
+
pageup: 92,
|
|
923
|
+
pagedown: 93
|
|
924
|
+
};
|
|
925
|
+
function escapeUiSelectorArg(value) {
|
|
926
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
927
|
+
}
|
|
928
|
+
function toAndroidQuery(selector) {
|
|
929
|
+
switch (selector.kind) {
|
|
930
|
+
case "focused":
|
|
931
|
+
return { by: "focused" };
|
|
932
|
+
case "id":
|
|
933
|
+
return { by: "id", value: selector.value };
|
|
934
|
+
case "role":
|
|
935
|
+
return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
|
|
936
|
+
case "label":
|
|
937
|
+
return { by: "accessibilityId", value: selector.value };
|
|
938
|
+
case "text":
|
|
939
|
+
return { by: "text", value: selector.value };
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
function parseAndroidSelector(selector) {
|
|
943
|
+
return toAndroidQuery(parseNativeSelector(selector));
|
|
944
|
+
}
|
|
945
|
+
function locator(strategy, selector) {
|
|
946
|
+
return { strategy, selector, context: "" };
|
|
947
|
+
}
|
|
948
|
+
function androidQueryToLocator(query, options = {}) {
|
|
949
|
+
switch (query.by) {
|
|
950
|
+
case "id":
|
|
951
|
+
return locator("id", qualifyResourceId(query.value, options.appPackage));
|
|
952
|
+
case "accessibilityId":
|
|
953
|
+
return locator("accessibility id", query.value);
|
|
954
|
+
case "text":
|
|
955
|
+
return locator(
|
|
956
|
+
"-android uiautomator",
|
|
957
|
+
`new UiSelector().textContains("${escapeUiSelectorArg(query.value)}")`
|
|
958
|
+
);
|
|
959
|
+
case "focused":
|
|
960
|
+
return locator("-android uiautomator", "new UiSelector().focused(true)");
|
|
961
|
+
case "role": {
|
|
962
|
+
const className = escapeUiSelectorArg(query.role);
|
|
963
|
+
if (query.name === void 0 || query.name.length === 0) {
|
|
964
|
+
return locator("class name", query.role);
|
|
965
|
+
}
|
|
966
|
+
return locator(
|
|
967
|
+
"-android uiautomator",
|
|
968
|
+
`new UiSelector().className("${className}").textContains("${escapeUiSelectorArg(query.name)}")`
|
|
969
|
+
);
|
|
531
970
|
}
|
|
532
971
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
972
|
+
}
|
|
973
|
+
function unwrapAndroidTextSelector(selector) {
|
|
974
|
+
return unwrapNativeTextSelector(selector);
|
|
975
|
+
}
|
|
976
|
+
function keyCodeFor(key) {
|
|
977
|
+
const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
|
|
978
|
+
if (code === void 0) {
|
|
979
|
+
throw new Error(
|
|
980
|
+
`Unsupported key "${key}" for the Android target. Supported keys: ${Object.keys(ANDROID_KEYCODES).sort().join(", ")}.`
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
return code;
|
|
984
|
+
}
|
|
985
|
+
function delay(ms) {
|
|
986
|
+
return new Promise((resolve) => {
|
|
987
|
+
setTimeout(resolve, ms);
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
function createAndroidDriver(client, options = {}) {
|
|
991
|
+
const unsupported = (verb) => new Error(`${verb} is not supported by the Android target`);
|
|
992
|
+
const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
|
|
993
|
+
async function resolveOne(selector) {
|
|
994
|
+
const id = await client.findElement(parseAndroidSelector(selector));
|
|
995
|
+
if (id === null) {
|
|
996
|
+
throw new Error(`No element matched selector: ${selector}`);
|
|
537
997
|
}
|
|
998
|
+
return id;
|
|
538
999
|
}
|
|
539
|
-
|
|
1000
|
+
async function clickSelector(selector) {
|
|
1001
|
+
await client.click(await resolveOne(selector));
|
|
1002
|
+
}
|
|
1003
|
+
async function fillSelector(selector, value) {
|
|
1004
|
+
await client.setValue(await resolveOne(selector), value);
|
|
1005
|
+
}
|
|
1006
|
+
return {
|
|
1007
|
+
capabilities: ANDROID_CAPABILITIES,
|
|
1008
|
+
// navigation -----------------------------------------------------------
|
|
1009
|
+
goto(_url, _options) {
|
|
1010
|
+
return rejectUnsupported("navigate");
|
|
1011
|
+
},
|
|
1012
|
+
currentUrl() {
|
|
1013
|
+
return `android:${options.appLabel ?? ""}`;
|
|
1014
|
+
},
|
|
1015
|
+
// queries --------------------------------------------------------------
|
|
1016
|
+
async count(selector) {
|
|
1017
|
+
return (await client.findElements(parseAndroidSelector(selector))).length;
|
|
1018
|
+
},
|
|
1019
|
+
async textContent(selector) {
|
|
1020
|
+
const id = await client.findElement(parseAndroidSelector(selector));
|
|
1021
|
+
if (id === null) {
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
1024
|
+
return client.getText(id);
|
|
1025
|
+
},
|
|
1026
|
+
// interactions ---------------------------------------------------------
|
|
1027
|
+
click: clickSelector,
|
|
1028
|
+
clickFirst: clickSelector,
|
|
1029
|
+
fill: fillSelector,
|
|
1030
|
+
fillFirst: fillSelector,
|
|
1031
|
+
async press(_selector, key) {
|
|
1032
|
+
await client.pressKeyCode(keyCodeFor(key));
|
|
1033
|
+
},
|
|
1034
|
+
selectOption() {
|
|
1035
|
+
return rejectUnsupported("select");
|
|
1036
|
+
},
|
|
1037
|
+
selectOptionFirst() {
|
|
1038
|
+
return rejectUnsupported("select");
|
|
1039
|
+
},
|
|
1040
|
+
hover() {
|
|
1041
|
+
return rejectUnsupported("hover");
|
|
1042
|
+
},
|
|
1043
|
+
scrollIntoView() {
|
|
1044
|
+
return rejectUnsupported("scrollTo");
|
|
1045
|
+
},
|
|
1046
|
+
setInputFiles() {
|
|
1047
|
+
return rejectUnsupported("setInputFiles");
|
|
1048
|
+
},
|
|
1049
|
+
// semantic locators ----------------------------------------------------
|
|
1050
|
+
async countByRole(role, name) {
|
|
1051
|
+
return (await client.findElements({ by: "role", role, name })).length;
|
|
1052
|
+
},
|
|
1053
|
+
async clickFirstByRole(role, name) {
|
|
1054
|
+
const id = await client.findElement({ by: "role", role, name });
|
|
1055
|
+
if (id === null) {
|
|
1056
|
+
throw new Error(`No element matched role=${role}[name="${name}"]`);
|
|
1057
|
+
}
|
|
1058
|
+
await client.click(id);
|
|
1059
|
+
},
|
|
1060
|
+
async countByLabel(label) {
|
|
1061
|
+
return (await client.findElements({ by: "accessibilityId", value: label })).length;
|
|
1062
|
+
},
|
|
1063
|
+
async fillFirstByLabel(label, value) {
|
|
1064
|
+
const id = await client.findElement({ by: "accessibilityId", value: label });
|
|
1065
|
+
if (id === null) {
|
|
1066
|
+
throw new Error(`No element matched label="${label}"`);
|
|
1067
|
+
}
|
|
1068
|
+
await client.setValue(id, value);
|
|
1069
|
+
},
|
|
1070
|
+
selectOptionFirstByLabel() {
|
|
1071
|
+
return rejectUnsupported("select");
|
|
1072
|
+
},
|
|
1073
|
+
// waiting --------------------------------------------------------------
|
|
1074
|
+
async waitForSelector(selector, waitOptions) {
|
|
1075
|
+
const query = parseAndroidSelector(selector);
|
|
1076
|
+
const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
1077
|
+
const deadline = Date.now() + timeoutMs;
|
|
1078
|
+
for (; ; ) {
|
|
1079
|
+
if ((await client.findElements(query)).length > 0) {
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
if (Date.now() >= deadline) {
|
|
1083
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
|
|
1084
|
+
}
|
|
1085
|
+
await delay(Math.min(WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
|
|
1086
|
+
}
|
|
1087
|
+
},
|
|
1088
|
+
waitForUrl() {
|
|
1089
|
+
return rejectUnsupported("waitForUrl");
|
|
1090
|
+
},
|
|
1091
|
+
waitForNetworkIdle() {
|
|
1092
|
+
return rejectUnsupported("waitForNetworkIdle");
|
|
1093
|
+
},
|
|
1094
|
+
// scripting & artifacts ------------------------------------------------
|
|
1095
|
+
evaluate() {
|
|
1096
|
+
return rejectUnsupported("evalScript");
|
|
1097
|
+
},
|
|
1098
|
+
async screenshot(screenshotOptions) {
|
|
1099
|
+
const png = await client.screenshotPng();
|
|
1100
|
+
import_node_fs2.default.writeFileSync(screenshotOptions.path, png);
|
|
1101
|
+
},
|
|
1102
|
+
// network / dialogs / downloads (all web-only) -------------------------
|
|
1103
|
+
onResponse(_handler) {
|
|
1104
|
+
throw unsupported("onResponse");
|
|
1105
|
+
},
|
|
1106
|
+
route(_url, _handler) {
|
|
1107
|
+
return rejectUnsupported("mockRoute");
|
|
1108
|
+
},
|
|
1109
|
+
unroute() {
|
|
1110
|
+
return rejectUnsupported("unmockRoute");
|
|
1111
|
+
},
|
|
1112
|
+
onDialog(_action) {
|
|
1113
|
+
throw unsupported("onDialog");
|
|
1114
|
+
},
|
|
1115
|
+
waitForDownloadEvent() {
|
|
1116
|
+
return rejectUnsupported("waitForDownload");
|
|
1117
|
+
},
|
|
1118
|
+
parseTextSelector(selector) {
|
|
1119
|
+
return unwrapAndroidTextSelector(selector);
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
540
1122
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
1123
|
+
|
|
1124
|
+
// src/browser/android-helper.ts
|
|
1125
|
+
var import_node_module = require("module");
|
|
1126
|
+
var import_node_child_process4 = require("child_process");
|
|
1127
|
+
var import_node_fs4 = __toESM(require("fs"), 1);
|
|
1128
|
+
var import_node_path3 = __toESM(require("path"), 1);
|
|
1129
|
+
|
|
1130
|
+
// src/browser/android-adb.ts
|
|
1131
|
+
var import_node_child_process2 = require("child_process");
|
|
1132
|
+
var execFileAdbRunner = (args, options) => new Promise((resolve) => {
|
|
1133
|
+
(0, import_node_child_process2.execFile)(
|
|
1134
|
+
"adb",
|
|
1135
|
+
args,
|
|
1136
|
+
{ encoding: "utf-8", timeout: options?.timeoutMs, maxBuffer: 16 * 1024 * 1024 },
|
|
1137
|
+
(error, stdout, stderr) => {
|
|
1138
|
+
const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
|
|
1139
|
+
const capturedStderr = stderr ?? "";
|
|
1140
|
+
resolve({
|
|
1141
|
+
stdout: stdout ?? "",
|
|
1142
|
+
stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
|
|
1143
|
+
code
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
);
|
|
1147
|
+
});
|
|
1148
|
+
var spawnAdbProcess = (args) => {
|
|
1149
|
+
const child = (0, import_node_child_process2.spawn)("adb", args, { stdio: "ignore" });
|
|
1150
|
+
child.on("error", () => {
|
|
1151
|
+
});
|
|
1152
|
+
return { kill: () => child.kill() };
|
|
1153
|
+
};
|
|
1154
|
+
function withSerial(serial, args) {
|
|
1155
|
+
return serial ? ["-s", serial, ...args] : args;
|
|
1156
|
+
}
|
|
1157
|
+
function parseAdbDevices(stdout) {
|
|
1158
|
+
const devices = [];
|
|
1159
|
+
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
1160
|
+
const line = rawLine.trim();
|
|
1161
|
+
if (!line || /^list of devices attached/i.test(line)) {
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
const [serial, state, ...rest] = line.split(/\s+/);
|
|
1165
|
+
if (!serial || !state) {
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
const description = {};
|
|
1169
|
+
for (const token of rest) {
|
|
1170
|
+
const eq = token.indexOf(":");
|
|
1171
|
+
if (eq > 0) {
|
|
1172
|
+
description[token.slice(0, eq)] = token.slice(eq + 1);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
devices.push({ serial, state, description });
|
|
544
1176
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
1177
|
+
return devices;
|
|
1178
|
+
}
|
|
1179
|
+
function bootedDevices(devices) {
|
|
1180
|
+
return devices.filter((device) => device.state === "device");
|
|
1181
|
+
}
|
|
1182
|
+
function selectDeviceSerial(devices, requested) {
|
|
1183
|
+
const booted = bootedDevices(devices);
|
|
1184
|
+
if (requested) {
|
|
1185
|
+
const match = devices.find((device) => device.serial === requested);
|
|
1186
|
+
if (!match) {
|
|
1187
|
+
const attached = devices.length > 0 ? devices.map((d) => d.serial).join(", ") : "none";
|
|
548
1188
|
throw new Error(
|
|
549
|
-
`
|
|
1189
|
+
`Android device "${requested}" is not attached. Attached devices: ${attached}. Check \`adb devices -l\`.`
|
|
550
1190
|
);
|
|
551
1191
|
}
|
|
552
|
-
if ("
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
1192
|
+
if (match.state !== "device") {
|
|
1193
|
+
throw new Error(
|
|
1194
|
+
`Android device "${requested}" is present but not ready (state: ${match.state}). Boot or authorize it, then retry.`
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
return requested;
|
|
1198
|
+
}
|
|
1199
|
+
if (booted.length === 0) {
|
|
1200
|
+
throw new Error(
|
|
1201
|
+
"No booted Android device found. Start an emulator or connect a device with USB debugging, then confirm it appears in `adb devices -l`."
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
if (booted.length > 1) {
|
|
1205
|
+
throw new Error(
|
|
1206
|
+
`Multiple Android devices attached (${booted.map((d) => d.serial).join(", ")}). Set target.deviceSerial to pick one.`
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
return booted[0].serial;
|
|
1210
|
+
}
|
|
1211
|
+
async function listDevices(runner) {
|
|
1212
|
+
const result = await runner(["devices", "-l"], { timeoutMs: 1e4 });
|
|
1213
|
+
if (result.code !== 0) {
|
|
1214
|
+
throw new Error(
|
|
1215
|
+
`\`adb devices\` failed (exit ${result.code}). Is adb on PATH and the server running? ` + (result.stderr.trim() || "").slice(0, 400)
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
return parseAdbDevices(result.stdout);
|
|
1219
|
+
}
|
|
1220
|
+
function parseForwardPort(stdout) {
|
|
1221
|
+
const port = Number.parseInt(stdout.trim(), 10);
|
|
1222
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
1223
|
+
throw new Error(`Could not parse a forwarded port from adb output: "${stdout.trim()}"`);
|
|
1224
|
+
}
|
|
1225
|
+
return port;
|
|
1226
|
+
}
|
|
1227
|
+
function parseAaptPackage(stdout) {
|
|
1228
|
+
const match = /package:\s*name='([^']+)'/.exec(stdout);
|
|
1229
|
+
return match?.[1] ?? null;
|
|
1230
|
+
}
|
|
1231
|
+
async function forwardDynamicPort(runner, serial, remotePort) {
|
|
1232
|
+
const result = await runner(withSerial(serial, ["forward", "tcp:0", `tcp:${remotePort}`]), {
|
|
1233
|
+
timeoutMs: 1e4
|
|
1234
|
+
});
|
|
1235
|
+
if (result.code !== 0) {
|
|
1236
|
+
throw new Error(`adb forward failed (exit ${result.code}): ${result.stderr.trim()}`);
|
|
1237
|
+
}
|
|
1238
|
+
return parseForwardPort(result.stdout);
|
|
1239
|
+
}
|
|
1240
|
+
async function removeForward(runner, serial, localPort) {
|
|
1241
|
+
await runner(withSerial(serial, ["forward", "--remove", `tcp:${localPort}`]), { timeoutMs: 1e4 }).catch(
|
|
1242
|
+
() => void 0
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
async function installApk(runner, serial, apkPath) {
|
|
1246
|
+
const result = await runner(withSerial(serial, ["install", "-r", "-t", "-g", apkPath]), {
|
|
1247
|
+
timeoutMs: 12e4
|
|
1248
|
+
});
|
|
1249
|
+
if (result.code !== 0 || /failure/i.test(result.stdout)) {
|
|
1250
|
+
throw new Error(
|
|
1251
|
+
`Failed to install APK "${apkPath}" (exit ${result.code}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
|
|
1252
|
+
);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
function formatUnknownError(error) {
|
|
1256
|
+
return error instanceof Error ? error.message : String(error);
|
|
1257
|
+
}
|
|
1258
|
+
function runnerPhaseError(message, error) {
|
|
1259
|
+
return new Error(`${message}: ${formatUnknownError(error)}`, { cause: error });
|
|
1260
|
+
}
|
|
1261
|
+
function parseResolvedComponent(stdout, pkg) {
|
|
1262
|
+
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
1263
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1264
|
+
if (/^[\w.]+\/[\w.$]+$/.test(lines[i]) && lines[i].startsWith(`${pkg}/`)) {
|
|
1265
|
+
return lines[i];
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
async function launchPackage(runner, serial, pkg) {
|
|
1271
|
+
let resolved;
|
|
1272
|
+
try {
|
|
1273
|
+
resolved = await runner(
|
|
1274
|
+
withSerial(serial, [
|
|
1275
|
+
"shell",
|
|
1276
|
+
"cmd",
|
|
1277
|
+
"package",
|
|
1278
|
+
"resolve-activity",
|
|
1279
|
+
"--brief",
|
|
1280
|
+
"-c",
|
|
1281
|
+
"android.intent.category.LAUNCHER",
|
|
1282
|
+
pkg
|
|
1283
|
+
]),
|
|
1284
|
+
{ timeoutMs: 3e4 }
|
|
1285
|
+
);
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
throw runnerPhaseError(
|
|
1288
|
+
`Failed to resolve the launcher activity for Android package "${pkg}" via adb`,
|
|
1289
|
+
error
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
const component = parseResolvedComponent(resolved.stdout, pkg);
|
|
1293
|
+
if (resolved.code !== 0 || !component) {
|
|
1294
|
+
throw new Error(
|
|
1295
|
+
`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?`
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
let start;
|
|
1299
|
+
try {
|
|
1300
|
+
start = await runner(withSerial(serial, ["shell", "am", "start", "-n", component]), {
|
|
1301
|
+
timeoutMs: 3e4
|
|
1302
|
+
});
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
throw runnerPhaseError(
|
|
1305
|
+
`Failed to launch Android package "${pkg}" with \`am start\` (${component}) via adb`,
|
|
1306
|
+
error
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
if (start.code !== 0 || /error|does not exist|cannot start/i.test(start.stdout + start.stderr)) {
|
|
1310
|
+
throw new Error(
|
|
1311
|
+
`Failed to launch Android package "${pkg}" (${component}, exit ${start.code}): ${(start.stdout + start.stderr).trim().slice(0, 400)}.`
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
async function forceStop(runner, serial, pkg) {
|
|
1316
|
+
await runner(withSerial(serial, ["shell", "am", "force-stop", pkg]), { timeoutMs: 3e4 });
|
|
1317
|
+
}
|
|
1318
|
+
async function clearPackage(runner, serial, pkg) {
|
|
1319
|
+
const result = await runner(withSerial(serial, ["shell", "pm", "clear", pkg]), { timeoutMs: 3e4 });
|
|
1320
|
+
if (result.code !== 0 || !/success/i.test(result.stdout)) {
|
|
1321
|
+
throw new Error(
|
|
1322
|
+
`Failed to clear Android package "${pkg}" for cold start (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 300)}`
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
function startInstrumentation(spawner, serial) {
|
|
1327
|
+
return spawner(
|
|
1328
|
+
withSerial(serial, [
|
|
1329
|
+
"shell",
|
|
1330
|
+
"am",
|
|
1331
|
+
"instrument",
|
|
1332
|
+
"-w",
|
|
1333
|
+
"-e",
|
|
1334
|
+
"disableAnalytics",
|
|
1335
|
+
"true",
|
|
1336
|
+
"io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner"
|
|
1337
|
+
])
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// src/browser/android-agent.ts
|
|
1342
|
+
var DEFAULT_AGENT_REQUEST_TIMEOUT_MS = 3e4;
|
|
1343
|
+
var W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
|
|
1344
|
+
var Uia2HttpError = class extends Error {
|
|
1345
|
+
constructor(message, status, webdriverError) {
|
|
1346
|
+
super(message);
|
|
1347
|
+
this.status = status;
|
|
1348
|
+
this.webdriverError = webdriverError;
|
|
1349
|
+
this.name = "Uia2HttpError";
|
|
1350
|
+
}
|
|
1351
|
+
};
|
|
1352
|
+
var Uia2Transport = class {
|
|
1353
|
+
baseUrl;
|
|
1354
|
+
requestTimeoutMs;
|
|
1355
|
+
fetchImpl;
|
|
1356
|
+
constructor(options) {
|
|
1357
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
1358
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_AGENT_REQUEST_TIMEOUT_MS;
|
|
1359
|
+
const injected = options.fetchImpl;
|
|
1360
|
+
if (injected) {
|
|
1361
|
+
this.fetchImpl = injected;
|
|
1362
|
+
} else if (typeof fetch === "function") {
|
|
1363
|
+
this.fetchImpl = (url, init) => fetch(url, init);
|
|
1364
|
+
} else {
|
|
1365
|
+
throw new Error("global fetch is unavailable; Node 20+ is required for the Android target");
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Send one request and return the parsed `value` field. Rejects with a
|
|
1370
|
+
* {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
|
|
1371
|
+
* per-request deadline elapses.
|
|
1372
|
+
*/
|
|
1373
|
+
async request(method, path18, body, timeoutMs) {
|
|
1374
|
+
const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
|
|
1375
|
+
const controller = new AbortController();
|
|
1376
|
+
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
1377
|
+
timer.unref?.();
|
|
1378
|
+
const url = `${this.baseUrl}${path18}`;
|
|
1379
|
+
let response;
|
|
1380
|
+
try {
|
|
1381
|
+
response = await this.fetchImpl(url, {
|
|
1382
|
+
method,
|
|
1383
|
+
signal: controller.signal,
|
|
1384
|
+
headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
|
|
1385
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
1386
|
+
});
|
|
1387
|
+
} catch (error) {
|
|
1388
|
+
if (controller.signal.aborted) {
|
|
1389
|
+
const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
|
|
1390
|
+
throw new Error(`uiautomator2 request ${method} ${path18} timed out after ${shown}`);
|
|
1391
|
+
}
|
|
1392
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
1393
|
+
} finally {
|
|
1394
|
+
clearTimeout(timer);
|
|
1395
|
+
}
|
|
1396
|
+
const text = await response.text();
|
|
1397
|
+
const parsed = parseJson(text);
|
|
1398
|
+
if (!response.ok) {
|
|
1399
|
+
const wdError = extractWebdriverError(parsed);
|
|
1400
|
+
throw new Uia2HttpError(
|
|
1401
|
+
`uiautomator2 ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
|
|
1402
|
+
response.status,
|
|
1403
|
+
wdError
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
return parsed?.value;
|
|
1407
|
+
}
|
|
1408
|
+
};
|
|
1409
|
+
function parseJson(text) {
|
|
1410
|
+
if (!text) {
|
|
1411
|
+
return void 0;
|
|
1412
|
+
}
|
|
1413
|
+
try {
|
|
1414
|
+
return JSON.parse(text);
|
|
1415
|
+
} catch {
|
|
1416
|
+
return void 0;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
function extractWebdriverError(parsed) {
|
|
1420
|
+
const value = parsed?.value;
|
|
1421
|
+
if (value && typeof value === "object") {
|
|
1422
|
+
const record = value;
|
|
1423
|
+
const error = typeof record.error === "string" ? record.error : void 0;
|
|
1424
|
+
const message = typeof record.message === "string" ? record.message : void 0;
|
|
1425
|
+
return error ?? message;
|
|
1426
|
+
}
|
|
1427
|
+
return void 0;
|
|
1428
|
+
}
|
|
1429
|
+
function extractElementId(value) {
|
|
1430
|
+
if (!value || typeof value !== "object") {
|
|
1431
|
+
return null;
|
|
1432
|
+
}
|
|
1433
|
+
const record = value;
|
|
1434
|
+
const id = record[W3C_ELEMENT_KEY] ?? record.ELEMENT;
|
|
1435
|
+
return typeof id === "string" ? id : null;
|
|
1436
|
+
}
|
|
1437
|
+
function isNoSuchElement(error) {
|
|
1438
|
+
if (error instanceof Uia2HttpError) {
|
|
1439
|
+
return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
|
|
1440
|
+
}
|
|
1441
|
+
return false;
|
|
1442
|
+
}
|
|
1443
|
+
async function createUia2Session(transport) {
|
|
1444
|
+
const value = await transport.request("POST", "/session", {
|
|
1445
|
+
capabilities: { alwaysMatch: {}, firstMatch: [{}] }
|
|
1446
|
+
});
|
|
1447
|
+
const record = value ?? {};
|
|
1448
|
+
const sessionId = record.sessionId;
|
|
1449
|
+
if (typeof sessionId === "string" && sessionId.length > 0) {
|
|
1450
|
+
return sessionId;
|
|
1451
|
+
}
|
|
1452
|
+
throw new Error("uiautomator2 did not return a session id");
|
|
1453
|
+
}
|
|
1454
|
+
async function waitForAgentReady(transport, options = { deadlineMs: 3e4 }) {
|
|
1455
|
+
const interval = options.intervalMs ?? 300;
|
|
1456
|
+
const deadline = Date.now() + options.deadlineMs;
|
|
1457
|
+
let lastError;
|
|
1458
|
+
for (; ; ) {
|
|
1459
|
+
try {
|
|
1460
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
1461
|
+
const value = await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
|
|
1462
|
+
const ready = value?.ready;
|
|
1463
|
+
if (ready === void 0 || ready === true) {
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
} catch (error) {
|
|
1467
|
+
lastError = error;
|
|
1468
|
+
}
|
|
1469
|
+
if (Date.now() >= deadline) {
|
|
1470
|
+
const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
|
|
1471
|
+
throw new Error(`uiautomator2 agent did not become ready within ${options.deadlineMs}ms${detail}`);
|
|
1472
|
+
}
|
|
1473
|
+
await sleep(Math.min(interval, Math.max(0, deadline - Date.now())));
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
function sleep(ms) {
|
|
1477
|
+
return new Promise((resolve) => {
|
|
1478
|
+
const timer = setTimeout(resolve, ms);
|
|
1479
|
+
timer.unref?.();
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
function createUia2AgentClient(transport, sessionId, options = {}) {
|
|
1483
|
+
const base = `/session/${sessionId}`;
|
|
1484
|
+
async function locate(query, path18) {
|
|
1485
|
+
return transport.request(
|
|
1486
|
+
"POST",
|
|
1487
|
+
`${base}${path18}`,
|
|
1488
|
+
androidQueryToLocator(query, { appPackage: options.appPackage })
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
return {
|
|
1492
|
+
async findElement(query) {
|
|
1493
|
+
try {
|
|
1494
|
+
return extractElementId(await locate(query, "/element"));
|
|
1495
|
+
} catch (error) {
|
|
1496
|
+
if (isNoSuchElement(error)) {
|
|
1497
|
+
return null;
|
|
1498
|
+
}
|
|
1499
|
+
throw error;
|
|
1500
|
+
}
|
|
1501
|
+
},
|
|
1502
|
+
async findElements(query) {
|
|
1503
|
+
const value = await locate(query, "/elements");
|
|
1504
|
+
if (!Array.isArray(value)) {
|
|
1505
|
+
return [];
|
|
1506
|
+
}
|
|
1507
|
+
return value.map((entry) => extractElementId(entry)).filter((id) => id !== null);
|
|
1508
|
+
},
|
|
1509
|
+
async click(elementId) {
|
|
1510
|
+
await transport.request("POST", `${base}/element/${elementId}/click`, {});
|
|
1511
|
+
},
|
|
1512
|
+
async setValue(elementId, text) {
|
|
1513
|
+
await transport.request("POST", `${base}/element/${elementId}/value`, { text });
|
|
1514
|
+
},
|
|
1515
|
+
async getText(elementId) {
|
|
1516
|
+
const value = await transport.request("GET", `${base}/element/${elementId}/text`);
|
|
1517
|
+
return typeof value === "string" ? value : value == null ? null : String(value);
|
|
1518
|
+
},
|
|
1519
|
+
async pressKeyCode(keyCode) {
|
|
1520
|
+
await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
|
|
1521
|
+
},
|
|
1522
|
+
async screenshotPng() {
|
|
1523
|
+
const value = await transport.request("GET", `${base}/screenshot`);
|
|
1524
|
+
if (typeof value !== "string") {
|
|
1525
|
+
throw new Error("uiautomator2 screenshot did not return base64 data");
|
|
1526
|
+
}
|
|
1527
|
+
return Buffer.from(value, "base64");
|
|
1528
|
+
},
|
|
1529
|
+
async source() {
|
|
1530
|
+
const value = await transport.request("GET", `${base}/source`);
|
|
1531
|
+
if (typeof value !== "string") {
|
|
1532
|
+
throw new Error("uiautomator2 /source did not return XML text; cannot analyze Android UI hierarchy");
|
|
1533
|
+
}
|
|
1534
|
+
return value;
|
|
1535
|
+
},
|
|
1536
|
+
async close() {
|
|
1537
|
+
await transport.request("DELETE", base).catch(() => void 0);
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// src/config/target.ts
|
|
1543
|
+
var import_node_child_process3 = require("child_process");
|
|
1544
|
+
var import_node_fs3 = __toESM(require("fs"), 1);
|
|
1545
|
+
var import_node_path2 = __toESM(require("path"), 1);
|
|
1546
|
+
var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
|
|
1547
|
+
"navigate",
|
|
1548
|
+
"waitForUrl",
|
|
1549
|
+
"waitForNetworkIdle",
|
|
1550
|
+
"mockRoute",
|
|
1551
|
+
"unmockRoute",
|
|
1552
|
+
"evalScript",
|
|
1553
|
+
"runScript",
|
|
1554
|
+
"onDialog",
|
|
1555
|
+
"select",
|
|
1556
|
+
"selectOption",
|
|
1557
|
+
"setInputFiles",
|
|
1558
|
+
"waitForDownload",
|
|
1559
|
+
"scroll"
|
|
1560
|
+
// directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
|
|
1561
|
+
]);
|
|
1562
|
+
function webOnlyReason(step) {
|
|
1563
|
+
for (const type of WEB_ONLY_STEP_TYPES) {
|
|
1564
|
+
if (type in step) {
|
|
1565
|
+
return type;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
if ("assert" in step) {
|
|
1569
|
+
const assertion = step.assert;
|
|
1570
|
+
if (assertion.urlIncludes !== void 0 || assertion.urlEquals !== void 0) {
|
|
1571
|
+
return "assert (url)";
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1576
|
+
function nativeTargetLabel(target) {
|
|
1577
|
+
if (target === "android") {
|
|
1578
|
+
return "Android";
|
|
1579
|
+
}
|
|
1580
|
+
if (target === "ios") {
|
|
1581
|
+
return "iOS";
|
|
1582
|
+
}
|
|
1583
|
+
return "macOS";
|
|
1584
|
+
}
|
|
1585
|
+
function assertStepsSupportedByTarget(steps, target) {
|
|
1586
|
+
if (target === "web") {
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
const label = nativeTargetLabel(target);
|
|
1590
|
+
for (const step of steps) {
|
|
1591
|
+
const reason = webOnlyReason(step);
|
|
1592
|
+
if (reason) {
|
|
1593
|
+
throw new Error(
|
|
1594
|
+
`Step "${reason}" is not supported by the ${label} target. It is web-only; use a portable step (click, fill, type, press, wait, assert visible, screenshot, etc.).`
|
|
1595
|
+
);
|
|
1596
|
+
}
|
|
1597
|
+
if ("if" in step) {
|
|
1598
|
+
assertStepsSupportedByTarget(step.if.then, target);
|
|
1599
|
+
if (step.if.else) {
|
|
1600
|
+
assertStepsSupportedByTarget(step.if.else, target);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
if ("repeat" in step) {
|
|
1604
|
+
assertStepsSupportedByTarget(step.repeat.steps, target);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
function assertHuntAssertionsSupportedByTarget(assertions, target) {
|
|
1609
|
+
if (target === "web" || !assertions || assertions.length === 0) {
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
throw new Error(
|
|
1613
|
+
`Hunt-level assertions are not supported by the ${nativeTargetLabel(target)} target. Use inline assert visible/notVisible steps instead.`
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
function trimTrailingPathSeparators(value) {
|
|
1617
|
+
return value.replace(/[\\/]+$/g, "");
|
|
1618
|
+
}
|
|
1619
|
+
function looksLikeAppBundlePath(app) {
|
|
1620
|
+
const trimmed = trimTrailingPathSeparators(app);
|
|
1621
|
+
return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
|
|
1622
|
+
}
|
|
1623
|
+
function looksLikeIosAppPath(app) {
|
|
1624
|
+
const trimmed = trimTrailingPathSeparators(app);
|
|
1625
|
+
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
|
1626
|
+
return true;
|
|
1627
|
+
}
|
|
1628
|
+
if (trimmed.toLowerCase().endsWith(".app")) {
|
|
1629
|
+
try {
|
|
1630
|
+
return import_node_fs3.default.statSync(normalizeAppPath(app)).isDirectory();
|
|
1631
|
+
} catch {
|
|
1632
|
+
return false;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
return false;
|
|
1636
|
+
}
|
|
1637
|
+
function normalizeAppPath(app) {
|
|
1638
|
+
return import_node_path2.default.resolve(trimTrailingPathSeparators(app));
|
|
1639
|
+
}
|
|
1640
|
+
function parseBundleIdentifier(plist) {
|
|
1641
|
+
const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
|
|
1642
|
+
return match?.[1]?.trim() || null;
|
|
1643
|
+
}
|
|
1644
|
+
function readBundleIdentifier(appPath, ...plistSubPath) {
|
|
1645
|
+
const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), ...plistSubPath);
|
|
1646
|
+
if (!import_node_fs3.default.existsSync(infoPlistPath)) {
|
|
1647
|
+
return null;
|
|
1648
|
+
}
|
|
1649
|
+
try {
|
|
1650
|
+
const parsed = parseBundleIdentifier(import_node_fs3.default.readFileSync(infoPlistPath, "utf-8"));
|
|
1651
|
+
if (parsed) {
|
|
1652
|
+
return parsed;
|
|
1653
|
+
}
|
|
1654
|
+
} catch {
|
|
1655
|
+
}
|
|
1656
|
+
if (process.platform !== "darwin" || !import_node_fs3.default.existsSync("/usr/bin/plutil")) {
|
|
1657
|
+
return null;
|
|
1658
|
+
}
|
|
1659
|
+
try {
|
|
1660
|
+
const output = (0, import_node_child_process3.execFileSync)(
|
|
1661
|
+
"/usr/bin/plutil",
|
|
1662
|
+
["-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlistPath],
|
|
1663
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1e3 }
|
|
1664
|
+
);
|
|
1665
|
+
return output.trim() || null;
|
|
1666
|
+
} catch {
|
|
1667
|
+
return null;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
function readMacosBundleIdentifier(appPath) {
|
|
1671
|
+
return readBundleIdentifier(appPath, "Contents", "Info.plist");
|
|
1672
|
+
}
|
|
1673
|
+
function readIosBundleIdentifier(appPath) {
|
|
1674
|
+
return readBundleIdentifier(appPath, "Info.plist");
|
|
1675
|
+
}
|
|
1676
|
+
function appBundleAllowedIdentities(app, readBundleId, isAppPath = looksLikeAppBundlePath) {
|
|
1677
|
+
const identities = /* @__PURE__ */ new Set([app]);
|
|
1678
|
+
if (isAppPath(app)) {
|
|
1679
|
+
const normalizedPath = normalizeAppPath(app);
|
|
1680
|
+
identities.add(trimTrailingPathSeparators(app));
|
|
1681
|
+
identities.add(normalizedPath);
|
|
1682
|
+
const bundleName = import_node_path2.default.basename(normalizedPath).replace(/\.app$/i, "");
|
|
1683
|
+
if (bundleName) {
|
|
1684
|
+
identities.add(bundleName);
|
|
1685
|
+
}
|
|
1686
|
+
const bundleId = readBundleId(app);
|
|
1687
|
+
if (bundleId) {
|
|
1688
|
+
identities.add(bundleId);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
return [...identities];
|
|
1692
|
+
}
|
|
1693
|
+
function macosAppAllowedIdentities(app) {
|
|
1694
|
+
return appBundleAllowedIdentities(app, readMacosBundleIdentifier);
|
|
1695
|
+
}
|
|
1696
|
+
function iosAppAllowedIdentities(app) {
|
|
1697
|
+
return appBundleAllowedIdentities(app, readIosBundleIdentifier, looksLikeIosAppPath);
|
|
1698
|
+
}
|
|
1699
|
+
function assertTargetAppAllowed(allowedApps, app) {
|
|
1700
|
+
assertNativeAppAllowed(allowedApps, app, macosAppAllowedIdentities);
|
|
1701
|
+
}
|
|
1702
|
+
function assertIosAppAllowed(allowedApps, app) {
|
|
1703
|
+
assertNativeAppAllowed(allowedApps, app, iosAppAllowedIdentities);
|
|
1704
|
+
}
|
|
1705
|
+
function looksLikeApkPath(app) {
|
|
1706
|
+
const trimmed = trimTrailingPathSeparators(app);
|
|
1707
|
+
return trimmed.includes("/") || trimmed.includes("\\") || trimmed.toLowerCase().endsWith(".apk");
|
|
1708
|
+
}
|
|
1709
|
+
function normalizeAndroidApkPath(app) {
|
|
1710
|
+
const resolved = import_node_path2.default.resolve(trimTrailingPathSeparators(app));
|
|
1711
|
+
try {
|
|
1712
|
+
return import_node_fs3.default.realpathSync.native(resolved);
|
|
1713
|
+
} catch {
|
|
1714
|
+
return resolved;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
function androidAppAllowedIdentities(app, resolvedPackage) {
|
|
1718
|
+
if (looksLikeApkPath(app)) {
|
|
1719
|
+
return resolvedPackage ? [normalizeAndroidApkPath(app), resolvedPackage] : [normalizeAndroidApkPath(app)];
|
|
1720
|
+
}
|
|
1721
|
+
return [app];
|
|
1722
|
+
}
|
|
1723
|
+
function assertAndroidAppAllowed(allowedApps, app, resolvedPackage) {
|
|
1724
|
+
assertNativeAppAllowed(
|
|
1725
|
+
allowedApps,
|
|
1726
|
+
app,
|
|
1727
|
+
(value) => androidAppAllowedIdentities(value, value === app ? resolvedPackage : void 0)
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1730
|
+
function assertNativeAppAllowed(allowedApps, app, resolveIdentities) {
|
|
1731
|
+
if (allowedApps.length === 0) {
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => resolveIdentities(allowedApp)));
|
|
1735
|
+
if (resolveIdentities(app).some((identity) => allowedIdentities.has(identity))) {
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
throw new Error(
|
|
1739
|
+
`Target app "${app}" is not in guardrails.allowedApps (${allowedApps.join(", ")}).`
|
|
1740
|
+
);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// src/browser/android-helper.ts
|
|
1744
|
+
var import_meta2 = {};
|
|
1745
|
+
var UIA2_REMOTE_PORT = 6790;
|
|
1746
|
+
function resolveAgentApks(requireFn = (0, import_node_module.createRequire)(import_meta2.url)) {
|
|
1747
|
+
let pkgJsonPath;
|
|
1748
|
+
try {
|
|
1749
|
+
pkgJsonPath = requireFn.resolve("appium-uiautomator2-server/package.json");
|
|
1750
|
+
} catch {
|
|
1751
|
+
throw new Error(
|
|
1752
|
+
"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"
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
const pkgDir = import_node_path3.default.dirname(pkgJsonPath);
|
|
1756
|
+
const version = requireFn(pkgJsonPath).version;
|
|
1757
|
+
const serverApk = import_node_path3.default.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
|
|
1758
|
+
const testApk = import_node_path3.default.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
|
|
1759
|
+
for (const apk of [serverApk, testApk]) {
|
|
1760
|
+
if (!import_node_fs4.default.existsSync(apk)) {
|
|
1761
|
+
throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
return { serverApk, testApk };
|
|
1765
|
+
}
|
|
1766
|
+
function looksLikeApk(app) {
|
|
1767
|
+
return app.toLowerCase().endsWith(".apk");
|
|
1768
|
+
}
|
|
1769
|
+
var execFileAaptResolver = async (apkPath) => {
|
|
1770
|
+
for (const tool of ["aapt", "aapt2"]) {
|
|
1771
|
+
const output = await new Promise((resolve) => {
|
|
1772
|
+
(0, import_node_child_process4.execFile)(
|
|
1773
|
+
tool,
|
|
1774
|
+
["dump", "badging", apkPath],
|
|
1775
|
+
{ encoding: "utf-8", timeout: 2e4, maxBuffer: 8 * 1024 * 1024 },
|
|
1776
|
+
(error, stdout) => resolve(error ? null : stdout)
|
|
1777
|
+
);
|
|
1778
|
+
});
|
|
1779
|
+
const pkg = output ? parseAaptPackage(output) : null;
|
|
1780
|
+
if (pkg) {
|
|
1781
|
+
return pkg;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
return null;
|
|
1785
|
+
};
|
|
1786
|
+
var defaultAgentConnector = async ({
|
|
1787
|
+
host,
|
|
1788
|
+
port,
|
|
1789
|
+
requestTimeoutMs,
|
|
1790
|
+
readyDeadlineMs,
|
|
1791
|
+
appPackage
|
|
1792
|
+
}) => {
|
|
1793
|
+
const transport = new Uia2Transport({
|
|
1794
|
+
baseUrl: `http://${host}:${port}/wd/hub`,
|
|
1795
|
+
requestTimeoutMs
|
|
1796
|
+
});
|
|
1797
|
+
await waitForAgentReady(transport, { deadlineMs: readyDeadlineMs });
|
|
1798
|
+
const sessionId = await createUia2Session(transport);
|
|
1799
|
+
return createUia2AgentClient(transport, sessionId, { appPackage });
|
|
1800
|
+
};
|
|
1801
|
+
async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
|
|
1802
|
+
if (!looksLikeApk(app)) {
|
|
1803
|
+
assertAndroidAppAllowed(allowedApps, app);
|
|
1804
|
+
return app;
|
|
1805
|
+
}
|
|
1806
|
+
const apkPath = import_node_path3.default.resolve(app);
|
|
1807
|
+
if (!import_node_fs4.default.existsSync(apkPath)) {
|
|
1808
|
+
throw new Error(`APK not found: ${apkPath}`);
|
|
1809
|
+
}
|
|
1810
|
+
const pkg = await aaptResolver(apkPath);
|
|
1811
|
+
if (!pkg) {
|
|
1812
|
+
throw new Error(
|
|
1813
|
+
`Could not determine the package name for "${app}". Put Android build-tools \`aapt\`/\`aapt2\` on PATH, or set target.app to the package name instead of the .apk path.`
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
assertAndroidAppAllowed(allowedApps, apkPath, pkg);
|
|
1817
|
+
await installApk(runner, serial, apkPath);
|
|
1818
|
+
return pkg;
|
|
1819
|
+
}
|
|
1820
|
+
async function launchAndroidSession(options) {
|
|
1821
|
+
const runner = options.runner ?? execFileAdbRunner;
|
|
1822
|
+
const spawner = options.spawner ?? spawnAdbProcess;
|
|
1823
|
+
const connector = options.agentConnector ?? defaultAgentConnector;
|
|
1824
|
+
const aaptResolver = options.aaptResolver ?? execFileAaptResolver;
|
|
1825
|
+
const apks = options.apks ?? resolveAgentApks();
|
|
1826
|
+
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS) + 5e3;
|
|
1827
|
+
const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS);
|
|
1828
|
+
const devices = await listDevices(runner);
|
|
1829
|
+
const serial = selectDeviceSerial(devices, options.deviceSerial);
|
|
1830
|
+
const pkg = await resolvePackage(options.app, runner, serial, aaptResolver, options.allowedApps ?? []);
|
|
1831
|
+
await installApk(runner, serial, apks.serverApk);
|
|
1832
|
+
await installApk(runner, serial, apks.testApk);
|
|
1833
|
+
if (options.coldStart) {
|
|
1834
|
+
await clearPackage(runner, serial, pkg);
|
|
1835
|
+
}
|
|
1836
|
+
await launchPackage(runner, serial, pkg);
|
|
1837
|
+
let instrumentation;
|
|
1838
|
+
let localPort;
|
|
1839
|
+
let client;
|
|
1840
|
+
let tornDown = false;
|
|
1841
|
+
const teardown = async () => {
|
|
1842
|
+
if (tornDown) {
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
tornDown = true;
|
|
1846
|
+
if (client) {
|
|
1847
|
+
await client.close().catch(() => void 0);
|
|
1848
|
+
}
|
|
1849
|
+
instrumentation?.kill();
|
|
1850
|
+
const forwardedPort = localPort;
|
|
1851
|
+
localPort = void 0;
|
|
1852
|
+
if (forwardedPort !== void 0) {
|
|
1853
|
+
await removeForward(runner, serial, forwardedPort);
|
|
1854
|
+
}
|
|
1855
|
+
await forceStop(runner, serial, pkg).catch(() => void 0);
|
|
1856
|
+
};
|
|
1857
|
+
try {
|
|
1858
|
+
instrumentation = startInstrumentation(spawner, serial);
|
|
1859
|
+
localPort = await forwardDynamicPort(runner, serial, UIA2_REMOTE_PORT);
|
|
1860
|
+
client = await connector({
|
|
1861
|
+
host: "127.0.0.1",
|
|
1862
|
+
port: localPort,
|
|
1863
|
+
requestTimeoutMs,
|
|
1864
|
+
readyDeadlineMs,
|
|
1865
|
+
appPackage: pkg
|
|
1866
|
+
});
|
|
1867
|
+
const driver = createAndroidDriver(client, { appLabel: pkg });
|
|
1868
|
+
return { client, driver, package: pkg, serial, teardown };
|
|
1869
|
+
} catch (error) {
|
|
1870
|
+
await teardown();
|
|
1871
|
+
throw error;
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
async function closeAndroidSession(session) {
|
|
1875
|
+
await session.teardown();
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// src/browser/ios-driver.ts
|
|
1879
|
+
var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
1880
|
+
"query",
|
|
1881
|
+
"interact",
|
|
1882
|
+
"wait",
|
|
1883
|
+
"screenshot"
|
|
1884
|
+
]);
|
|
1885
|
+
var WAIT_POLL_INTERVAL_MS2 = 250;
|
|
1886
|
+
var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
|
|
1887
|
+
var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
|
|
1888
|
+
function escapePredicateArg(value) {
|
|
1889
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1890
|
+
}
|
|
1891
|
+
function toIosQuery(selector) {
|
|
1892
|
+
switch (selector.kind) {
|
|
1893
|
+
case "focused":
|
|
1894
|
+
return { by: "focused" };
|
|
1895
|
+
case "id":
|
|
1896
|
+
return { by: "accessibilityId", value: selector.value };
|
|
1897
|
+
case "role":
|
|
1898
|
+
return selector.name !== void 0 ? { by: "role", role: selector.role, name: selector.name } : { by: "role", role: selector.role };
|
|
1899
|
+
case "label":
|
|
1900
|
+
return { by: "label", value: selector.value };
|
|
1901
|
+
case "text":
|
|
1902
|
+
return { by: "text", value: selector.value };
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
function parseIosSelector(selector) {
|
|
1906
|
+
return toIosQuery(parseNativeSelector(selector));
|
|
1907
|
+
}
|
|
1908
|
+
function iosQueryToLocator(query) {
|
|
1909
|
+
switch (query.by) {
|
|
1910
|
+
case "accessibilityId":
|
|
1911
|
+
return { using: "accessibility id", value: query.value };
|
|
1912
|
+
case "label":
|
|
1913
|
+
return { using: "predicate string", value: `label == "${escapePredicateArg(query.value)}"` };
|
|
1914
|
+
case "text": {
|
|
1915
|
+
const escaped = escapePredicateArg(query.value);
|
|
1916
|
+
return {
|
|
1917
|
+
using: "predicate string",
|
|
1918
|
+
value: `label CONTAINS "${escaped}" OR value CONTAINS "${escaped}"`
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
case "focused":
|
|
1922
|
+
return { using: "predicate string", value: "hasKeyboardFocus == 1" };
|
|
1923
|
+
case "role": {
|
|
1924
|
+
const className = normalizeXcuiClassName(query.role);
|
|
1925
|
+
if (query.name === void 0 || query.name.length === 0) {
|
|
1926
|
+
return { using: "class name", value: className };
|
|
1927
|
+
}
|
|
1928
|
+
const escapedClass = escapePredicateArg(className);
|
|
1929
|
+
const escapedName = escapePredicateArg(query.name);
|
|
1930
|
+
return {
|
|
1931
|
+
using: "predicate string",
|
|
1932
|
+
value: `type == "${escapedClass}" AND (label CONTAINS "${escapedName}" OR value CONTAINS "${escapedName}")`
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
function unwrapIosTextSelector(selector) {
|
|
1938
|
+
return unwrapNativeTextSelector(selector);
|
|
1939
|
+
}
|
|
1940
|
+
function delay2(ms) {
|
|
1941
|
+
return new Promise((resolve) => {
|
|
1942
|
+
setTimeout(resolve, ms);
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
function createIosDriver(client, options) {
|
|
1946
|
+
const unsupported = (verb) => new Error(`${verb} is not supported by the iOS target`);
|
|
1947
|
+
const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
|
|
1948
|
+
async function resolveOne(selector) {
|
|
1949
|
+
const id = await client.findElement(parseIosSelector(selector));
|
|
1950
|
+
if (id === null) {
|
|
1951
|
+
throw new Error(`No element matched selector: ${selector}`);
|
|
1952
|
+
}
|
|
1953
|
+
return id;
|
|
1954
|
+
}
|
|
1955
|
+
async function clickSelector(selector) {
|
|
1956
|
+
await client.click(await resolveOne(selector));
|
|
1957
|
+
}
|
|
1958
|
+
async function fillSelector(selector, value) {
|
|
1959
|
+
await client.setValue(await resolveOne(selector), value);
|
|
1960
|
+
}
|
|
1961
|
+
async function pressKey(key) {
|
|
1962
|
+
const name = key.trim().toLowerCase();
|
|
1963
|
+
if (name === "enter" || name === "return") {
|
|
1964
|
+
await client.sendKeys(["\n"]);
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
if (name === "delete" || name === "backspace" || name === "del") {
|
|
1968
|
+
await client.sendKeys(["\b"]);
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
if (name === "home") {
|
|
1972
|
+
await client.homescreen();
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
throw new Error(
|
|
1976
|
+
`Unsupported key "${key}" for the iOS target. Supported keys: ${IOS_PRESS_KEYS.join(", ")}.`
|
|
1977
|
+
);
|
|
1978
|
+
}
|
|
1979
|
+
return {
|
|
1980
|
+
capabilities: IOS_CAPABILITIES,
|
|
1981
|
+
// navigation -----------------------------------------------------------
|
|
1982
|
+
goto(_url, _options) {
|
|
1983
|
+
return rejectUnsupported("navigate");
|
|
1984
|
+
},
|
|
1985
|
+
currentUrl() {
|
|
1986
|
+
return `ios:${options.appLabel ?? ""}`;
|
|
1987
|
+
},
|
|
1988
|
+
// queries --------------------------------------------------------------
|
|
1989
|
+
async count(selector) {
|
|
1990
|
+
return (await client.findElements(parseIosSelector(selector))).length;
|
|
1991
|
+
},
|
|
1992
|
+
async textContent(selector) {
|
|
1993
|
+
const id = await client.findElement(parseIosSelector(selector));
|
|
1994
|
+
if (id === null) {
|
|
1995
|
+
return null;
|
|
1996
|
+
}
|
|
1997
|
+
return client.getText(id);
|
|
1998
|
+
},
|
|
1999
|
+
// interactions ---------------------------------------------------------
|
|
2000
|
+
click: clickSelector,
|
|
2001
|
+
clickFirst: clickSelector,
|
|
2002
|
+
fill: fillSelector,
|
|
2003
|
+
fillFirst: fillSelector,
|
|
2004
|
+
async press(_selector, key) {
|
|
2005
|
+
await pressKey(key);
|
|
2006
|
+
},
|
|
2007
|
+
selectOption() {
|
|
2008
|
+
return rejectUnsupported("select");
|
|
2009
|
+
},
|
|
2010
|
+
selectOptionFirst() {
|
|
2011
|
+
return rejectUnsupported("select");
|
|
2012
|
+
},
|
|
2013
|
+
hover() {
|
|
2014
|
+
return rejectUnsupported("hover");
|
|
2015
|
+
},
|
|
2016
|
+
scrollIntoView() {
|
|
2017
|
+
return rejectUnsupported("scrollTo");
|
|
2018
|
+
},
|
|
2019
|
+
setInputFiles() {
|
|
2020
|
+
return rejectUnsupported("setInputFiles");
|
|
2021
|
+
},
|
|
2022
|
+
// semantic locators ----------------------------------------------------
|
|
2023
|
+
async countByRole(role, name) {
|
|
2024
|
+
return (await client.findElements({ by: "role", role, name })).length;
|
|
2025
|
+
},
|
|
2026
|
+
async clickFirstByRole(role, name) {
|
|
2027
|
+
const id = await client.findElement({ by: "role", role, name });
|
|
2028
|
+
if (id === null) {
|
|
2029
|
+
throw new Error(`No element matched role=${role}[name="${name}"]`);
|
|
2030
|
+
}
|
|
2031
|
+
await client.click(id);
|
|
2032
|
+
},
|
|
2033
|
+
async countByLabel(label) {
|
|
2034
|
+
return (await client.findElements({ by: "label", value: label })).length;
|
|
2035
|
+
},
|
|
2036
|
+
async fillFirstByLabel(label, value) {
|
|
2037
|
+
const id = await client.findElement({ by: "label", value: label });
|
|
2038
|
+
if (id === null) {
|
|
2039
|
+
throw new Error(`No element matched label="${label}"`);
|
|
2040
|
+
}
|
|
2041
|
+
await client.setValue(id, value);
|
|
2042
|
+
},
|
|
2043
|
+
selectOptionFirstByLabel() {
|
|
2044
|
+
return rejectUnsupported("select");
|
|
2045
|
+
},
|
|
2046
|
+
// waiting --------------------------------------------------------------
|
|
2047
|
+
async waitForSelector(selector, waitOptions) {
|
|
2048
|
+
const query = parseIosSelector(selector);
|
|
2049
|
+
const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
|
|
2050
|
+
const deadline = Date.now() + timeoutMs;
|
|
2051
|
+
for (; ; ) {
|
|
2052
|
+
if ((await client.findElements(query)).length > 0) {
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
if (Date.now() >= deadline) {
|
|
2056
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
|
|
2057
|
+
}
|
|
2058
|
+
await delay2(Math.min(WAIT_POLL_INTERVAL_MS2, Math.max(0, deadline - Date.now())));
|
|
2059
|
+
}
|
|
2060
|
+
},
|
|
2061
|
+
waitForUrl() {
|
|
2062
|
+
return rejectUnsupported("waitForUrl");
|
|
2063
|
+
},
|
|
2064
|
+
waitForNetworkIdle() {
|
|
2065
|
+
return rejectUnsupported("waitForNetworkIdle");
|
|
2066
|
+
},
|
|
2067
|
+
// scripting & artifacts ------------------------------------------------
|
|
2068
|
+
evaluate() {
|
|
2069
|
+
return rejectUnsupported("evalScript");
|
|
2070
|
+
},
|
|
2071
|
+
async screenshot(screenshotOptions) {
|
|
2072
|
+
await options.captureScreenshot(screenshotOptions.path);
|
|
2073
|
+
},
|
|
2074
|
+
// network / dialogs / downloads (all web-only) -------------------------
|
|
2075
|
+
onResponse(_handler) {
|
|
2076
|
+
throw unsupported("onResponse");
|
|
2077
|
+
},
|
|
2078
|
+
route(_url, _handler) {
|
|
2079
|
+
return rejectUnsupported("mockRoute");
|
|
2080
|
+
},
|
|
2081
|
+
unroute() {
|
|
2082
|
+
return rejectUnsupported("unmockRoute");
|
|
2083
|
+
},
|
|
2084
|
+
onDialog(_action) {
|
|
2085
|
+
throw unsupported("onDialog");
|
|
2086
|
+
},
|
|
2087
|
+
waitForDownloadEvent() {
|
|
2088
|
+
return rejectUnsupported("waitForDownload");
|
|
2089
|
+
},
|
|
2090
|
+
parseTextSelector(selector) {
|
|
2091
|
+
return unwrapIosTextSelector(selector);
|
|
2092
|
+
}
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
// src/browser/ios-helper.ts
|
|
2097
|
+
var import_node_module2 = require("module");
|
|
2098
|
+
var import_node_child_process6 = require("child_process");
|
|
2099
|
+
var import_node_fs5 = __toESM(require("fs"), 1);
|
|
2100
|
+
var import_node_os2 = __toESM(require("os"), 1);
|
|
2101
|
+
var import_node_path5 = __toESM(require("path"), 1);
|
|
2102
|
+
|
|
2103
|
+
// src/browser/ios-simctl.ts
|
|
2104
|
+
var import_node_child_process5 = require("child_process");
|
|
2105
|
+
var import_promises = require("fs/promises");
|
|
2106
|
+
var import_node_net = __toESM(require("net"), 1);
|
|
2107
|
+
var import_node_os = __toESM(require("os"), 1);
|
|
2108
|
+
var import_node_path4 = __toESM(require("path"), 1);
|
|
2109
|
+
var spawnXcrunProcess = (args, options) => {
|
|
2110
|
+
const child = (0, import_node_child_process5.spawn)("xcrun", args, {
|
|
2111
|
+
stdio: "ignore",
|
|
2112
|
+
env: options?.env ? { ...process.env, ...options.env } : process.env
|
|
2113
|
+
});
|
|
2114
|
+
child.on("error", () => {
|
|
2115
|
+
});
|
|
2116
|
+
return {
|
|
2117
|
+
kill: () => {
|
|
2118
|
+
child.kill();
|
|
2119
|
+
}
|
|
2120
|
+
};
|
|
2121
|
+
};
|
|
2122
|
+
var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path4.default.join(import_node_os.default.tmpdir(), "prowl-ios-simulator-locks");
|
|
2123
|
+
var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
|
|
2124
|
+
var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
|
|
2125
|
+
(0, import_node_child_process5.execFile)(
|
|
2126
|
+
"xcrun",
|
|
2127
|
+
args,
|
|
2128
|
+
{
|
|
2129
|
+
encoding: "utf-8",
|
|
2130
|
+
timeout: options?.timeoutMs,
|
|
2131
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
2132
|
+
env: options?.env ? { ...process.env, ...options.env } : process.env
|
|
2133
|
+
},
|
|
2134
|
+
(error, stdout, stderr) => {
|
|
2135
|
+
const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
|
|
2136
|
+
const capturedStderr = stderr ?? "";
|
|
2137
|
+
resolve({
|
|
2138
|
+
stdout: stdout ?? "",
|
|
2139
|
+
stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
|
|
2140
|
+
code
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
2143
|
+
);
|
|
2144
|
+
});
|
|
2145
|
+
function simulatorLockName(udid) {
|
|
2146
|
+
const safe = udid.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
2147
|
+
return `${safe || "simulator"}.lock`;
|
|
2148
|
+
}
|
|
2149
|
+
function isErrno(error, code) {
|
|
2150
|
+
return error?.code === code;
|
|
2151
|
+
}
|
|
2152
|
+
function isProcessAlive(pid) {
|
|
2153
|
+
try {
|
|
2154
|
+
process.kill(pid, 0);
|
|
2155
|
+
return true;
|
|
2156
|
+
} catch (error) {
|
|
2157
|
+
return isErrno(error, "EPERM");
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
async function removeStaleSimulatorLock(lockPath) {
|
|
2161
|
+
try {
|
|
2162
|
+
const ownerText = await (0, import_promises.readFile)(import_node_path4.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
|
|
2163
|
+
const owner = JSON.parse(ownerText);
|
|
2164
|
+
if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
|
|
2165
|
+
await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
|
|
2166
|
+
return true;
|
|
2167
|
+
}
|
|
2168
|
+
} catch {
|
|
2169
|
+
return false;
|
|
2170
|
+
}
|
|
2171
|
+
return false;
|
|
2172
|
+
}
|
|
2173
|
+
function simulatorReservedError(udid) {
|
|
2174
|
+
return new Error(
|
|
2175
|
+
`iOS simulator "${udid}" is already reserved by another Prowl process. Wait for that run to finish, or boot/select a different simulator with target.udid.`
|
|
2176
|
+
);
|
|
2177
|
+
}
|
|
2178
|
+
async function reserveSimulatorUdid(udid, options = {}) {
|
|
2179
|
+
const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
|
|
2180
|
+
const lockPath = import_node_path4.default.join(lockRoot, simulatorLockName(udid));
|
|
2181
|
+
await (0, import_promises.mkdir)(lockRoot, { recursive: true });
|
|
2182
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
2183
|
+
try {
|
|
2184
|
+
await (0, import_promises.mkdir)(lockPath);
|
|
2185
|
+
} catch (error) {
|
|
2186
|
+
if (!isErrno(error, "EEXIST")) {
|
|
2187
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
2188
|
+
}
|
|
2189
|
+
if (attempt === 0 && await removeStaleSimulatorLock(lockPath)) {
|
|
2190
|
+
continue;
|
|
2191
|
+
}
|
|
2192
|
+
throw simulatorReservedError(udid);
|
|
2193
|
+
}
|
|
2194
|
+
let released = false;
|
|
2195
|
+
const release = async () => {
|
|
2196
|
+
if (released) {
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
released = true;
|
|
2200
|
+
await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
|
|
2201
|
+
};
|
|
2202
|
+
try {
|
|
2203
|
+
await (0, import_promises.writeFile)(
|
|
2204
|
+
import_node_path4.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
|
|
2205
|
+
`${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
|
|
2206
|
+
`,
|
|
2207
|
+
{ flag: "wx" }
|
|
2208
|
+
);
|
|
2209
|
+
} catch (error) {
|
|
2210
|
+
await release().catch(() => void 0);
|
|
2211
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
2212
|
+
}
|
|
2213
|
+
return { udid, release };
|
|
2214
|
+
}
|
|
2215
|
+
throw simulatorReservedError(udid);
|
|
2216
|
+
}
|
|
2217
|
+
function findFreePort() {
|
|
2218
|
+
return new Promise((resolve, reject) => {
|
|
2219
|
+
const server = import_node_net.default.createServer();
|
|
2220
|
+
server.on("error", reject);
|
|
2221
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2222
|
+
const address = server.address();
|
|
2223
|
+
if (address && typeof address === "object") {
|
|
2224
|
+
const { port } = address;
|
|
2225
|
+
server.close(() => resolve(port));
|
|
2226
|
+
} else {
|
|
2227
|
+
server.close(() => reject(new Error("Could not allocate a local port")));
|
|
2228
|
+
}
|
|
2229
|
+
});
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
function parseSimctlDevices(json) {
|
|
2233
|
+
let parsed;
|
|
2234
|
+
try {
|
|
2235
|
+
parsed = JSON.parse(json);
|
|
2236
|
+
} catch {
|
|
2237
|
+
throw new Error("Could not parse `simctl list devices --json` output.");
|
|
2238
|
+
}
|
|
2239
|
+
const byRuntime = parsed?.devices;
|
|
2240
|
+
if (!byRuntime || typeof byRuntime !== "object") {
|
|
2241
|
+
return [];
|
|
2242
|
+
}
|
|
2243
|
+
const devices = [];
|
|
2244
|
+
for (const [runtime, entries] of Object.entries(byRuntime)) {
|
|
2245
|
+
if (!Array.isArray(entries)) {
|
|
2246
|
+
continue;
|
|
2247
|
+
}
|
|
2248
|
+
for (const entry of entries) {
|
|
2249
|
+
if (!entry || typeof entry !== "object") {
|
|
2250
|
+
continue;
|
|
2251
|
+
}
|
|
2252
|
+
const record = entry;
|
|
2253
|
+
const udid = typeof record.udid === "string" ? record.udid : void 0;
|
|
2254
|
+
const name = typeof record.name === "string" ? record.name : void 0;
|
|
2255
|
+
const state = typeof record.state === "string" ? record.state : "Unknown";
|
|
2256
|
+
if (!udid || !name) {
|
|
2257
|
+
continue;
|
|
2258
|
+
}
|
|
2259
|
+
devices.push({
|
|
2260
|
+
udid,
|
|
2261
|
+
name,
|
|
2262
|
+
state,
|
|
2263
|
+
runtime,
|
|
2264
|
+
isAvailable: record.isAvailable !== false
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
return devices;
|
|
2269
|
+
}
|
|
2270
|
+
function bootedSimulators(devices) {
|
|
2271
|
+
return devices.filter((device) => device.state === "Booted");
|
|
2272
|
+
}
|
|
2273
|
+
function describeSimulator(device) {
|
|
2274
|
+
const runtime = device.runtime.replace(/^com\.apple\.CoreSimulator\.SimRuntime\./, "");
|
|
2275
|
+
return `${device.name} [${runtime}] (${device.udid})`;
|
|
2276
|
+
}
|
|
2277
|
+
function selectSimulatorUdid(devices, requested) {
|
|
2278
|
+
const booted = bootedSimulators(devices);
|
|
2279
|
+
if (requested) {
|
|
2280
|
+
const match = devices.find((device) => device.udid === requested);
|
|
2281
|
+
if (!match) {
|
|
2282
|
+
const known = devices.length > 0 ? devices.map((d) => d.udid).join(", ") : "none";
|
|
2283
|
+
throw new Error(
|
|
2284
|
+
`iOS simulator "${requested}" was not found. Known simulators: ${known}. Check \`xcrun simctl list devices\`.`
|
|
2285
|
+
);
|
|
2286
|
+
}
|
|
2287
|
+
if (match.state !== "Booted") {
|
|
2288
|
+
throw new Error(
|
|
2289
|
+
`iOS simulator "${requested}" is not booted (state: ${match.state}). Boot it with \`xcrun simctl boot ${requested}\`, then retry.`
|
|
2290
|
+
);
|
|
2291
|
+
}
|
|
2292
|
+
return requested;
|
|
2293
|
+
}
|
|
2294
|
+
if (booted.length === 0) {
|
|
2295
|
+
throw new Error(
|
|
2296
|
+
"No booted iOS simulator found. Boot one from Xcode or with `xcrun simctl boot <udid>` (see `xcrun simctl list devices available`), then retry."
|
|
2297
|
+
);
|
|
2298
|
+
}
|
|
2299
|
+
if (booted.length > 1) {
|
|
2300
|
+
throw new Error(
|
|
2301
|
+
`Multiple iOS simulators are booted (${booted.map(describeSimulator).join("; ")}). Set target.udid to pick one.`
|
|
2302
|
+
);
|
|
2303
|
+
}
|
|
2304
|
+
return booted[0].udid;
|
|
2305
|
+
}
|
|
2306
|
+
async function listSimulators(runner) {
|
|
2307
|
+
const result = await runner(["simctl", "list", "devices", "--json"], { timeoutMs: 3e4 });
|
|
2308
|
+
if (result.code !== 0) {
|
|
2309
|
+
throw new Error(
|
|
2310
|
+
`\`xcrun simctl list devices\` failed. Is Xcode installed and are the command-line tools selected (\`xcode-select -p\`)? ${(result.stderr.trim() || "").slice(0, 400)}`
|
|
2311
|
+
);
|
|
2312
|
+
}
|
|
2313
|
+
return parseSimctlDevices(result.stdout);
|
|
2314
|
+
}
|
|
2315
|
+
async function installApp(runner, udid, appPath) {
|
|
2316
|
+
const result = await runner(["simctl", "install", udid, appPath], { timeoutMs: 12e4 });
|
|
2317
|
+
if (result.code !== 0) {
|
|
2318
|
+
throw new Error(
|
|
2319
|
+
`Failed to install "${appPath}" onto simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
|
|
2320
|
+
);
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
async function uninstallApp(runner, udid, bundleId) {
|
|
2324
|
+
await runner(["simctl", "uninstall", udid, bundleId], { timeoutMs: 6e4 }).catch(() => void 0);
|
|
2325
|
+
}
|
|
2326
|
+
async function launchApp(runner, udid, bundleId, childEnv = {}) {
|
|
2327
|
+
const env = {};
|
|
2328
|
+
for (const [key, value] of Object.entries(childEnv)) {
|
|
2329
|
+
env[`SIMCTL_CHILD_${key}`] = value;
|
|
2330
|
+
}
|
|
2331
|
+
const result = await runner(["simctl", "launch", udid, bundleId], {
|
|
2332
|
+
timeoutMs: 6e4,
|
|
2333
|
+
env: Object.keys(env).length > 0 ? env : void 0
|
|
2334
|
+
});
|
|
2335
|
+
if (result.code !== 0) {
|
|
2336
|
+
throw new Error(
|
|
2337
|
+
`Failed to launch "${bundleId}" on simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}. Is it installed?`
|
|
2338
|
+
);
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
async function terminateApp(runner, udid, bundleId) {
|
|
2342
|
+
await runner(["simctl", "terminate", udid, bundleId], { timeoutMs: 3e4 }).catch(() => void 0);
|
|
2343
|
+
}
|
|
2344
|
+
async function captureScreenshot(runner, udid, outPath) {
|
|
2345
|
+
const result = await runner(["simctl", "io", udid, "screenshot", outPath], { timeoutMs: 3e4 });
|
|
2346
|
+
if (result.code !== 0) {
|
|
2347
|
+
throw new Error(
|
|
2348
|
+
`Failed to capture a simulator screenshot (${udid}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 300)}`
|
|
2349
|
+
);
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
function parseXcodeVersion(stdout) {
|
|
2353
|
+
const match = /Xcode\s+([\d.]+)/i.exec(stdout);
|
|
2354
|
+
return match?.[1] ?? null;
|
|
2355
|
+
}
|
|
2356
|
+
async function xcodeVersion(runner) {
|
|
2357
|
+
const result = await runner(["xcodebuild", "-version"], { timeoutMs: 3e4 });
|
|
2358
|
+
if (result.code !== 0) {
|
|
2359
|
+
return null;
|
|
2360
|
+
}
|
|
2361
|
+
return parseXcodeVersion(result.stdout);
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
// src/browser/ios-agent.ts
|
|
2365
|
+
var DEFAULT_WDA_REQUEST_TIMEOUT_MS = 3e4;
|
|
2366
|
+
var W3C_ELEMENT_KEY2 = "element-6066-11e4-a52e-4f735466cecf";
|
|
2367
|
+
var WdaHttpError = class extends Error {
|
|
2368
|
+
constructor(message, status, webdriverError) {
|
|
2369
|
+
super(message);
|
|
2370
|
+
this.status = status;
|
|
2371
|
+
this.webdriverError = webdriverError;
|
|
2372
|
+
this.name = "WdaHttpError";
|
|
2373
|
+
}
|
|
2374
|
+
};
|
|
2375
|
+
var WdaTransport = class {
|
|
2376
|
+
baseUrl;
|
|
2377
|
+
requestTimeoutMs;
|
|
2378
|
+
fetchImpl;
|
|
2379
|
+
constructor(options) {
|
|
2380
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
2381
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_WDA_REQUEST_TIMEOUT_MS;
|
|
2382
|
+
const injected = options.fetchImpl;
|
|
2383
|
+
if (injected) {
|
|
2384
|
+
this.fetchImpl = injected;
|
|
2385
|
+
} else if (typeof fetch === "function") {
|
|
2386
|
+
this.fetchImpl = (url, init) => fetch(url, init);
|
|
2387
|
+
} else {
|
|
2388
|
+
throw new Error("global fetch is unavailable; Node 20+ is required for the iOS target");
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
/**
|
|
2392
|
+
* Send one request and return the full parsed JSON body. Rejects with a
|
|
2393
|
+
* {@link WdaHttpError} on a non-2xx response, or a timeout error when the
|
|
2394
|
+
* per-request deadline elapses.
|
|
2395
|
+
*/
|
|
2396
|
+
async requestFull(method, path18, body, timeoutMs) {
|
|
2397
|
+
const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
|
|
2398
|
+
const controller = new AbortController();
|
|
2399
|
+
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
2400
|
+
timer.unref?.();
|
|
2401
|
+
const url = `${this.baseUrl}${path18}`;
|
|
2402
|
+
const timeoutError = () => {
|
|
2403
|
+
const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
|
|
2404
|
+
return new Error(`WebDriverAgent request ${method} ${path18} timed out after ${shown}`);
|
|
2405
|
+
};
|
|
2406
|
+
let response;
|
|
2407
|
+
try {
|
|
2408
|
+
response = await this.fetchImpl(url, {
|
|
2409
|
+
method,
|
|
2410
|
+
signal: controller.signal,
|
|
2411
|
+
headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
|
|
2412
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
2413
|
+
});
|
|
2414
|
+
} catch (error) {
|
|
2415
|
+
clearTimeout(timer);
|
|
2416
|
+
if (controller.signal.aborted) {
|
|
2417
|
+
throw timeoutError();
|
|
2418
|
+
}
|
|
2419
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
2420
|
+
}
|
|
2421
|
+
let text;
|
|
2422
|
+
try {
|
|
2423
|
+
text = await response.text();
|
|
2424
|
+
} catch (error) {
|
|
2425
|
+
if (controller.signal.aborted) {
|
|
2426
|
+
throw timeoutError();
|
|
2427
|
+
}
|
|
2428
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
2429
|
+
} finally {
|
|
2430
|
+
clearTimeout(timer);
|
|
2431
|
+
}
|
|
2432
|
+
const parsed = parseJson2(text);
|
|
2433
|
+
if (!response.ok) {
|
|
2434
|
+
const wdError = extractWebdriverError2(parsed);
|
|
2435
|
+
throw new WdaHttpError(
|
|
2436
|
+
`WebDriverAgent ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
|
|
2437
|
+
response.status,
|
|
2438
|
+
wdError
|
|
2439
|
+
);
|
|
2440
|
+
}
|
|
2441
|
+
return parsed;
|
|
2442
|
+
}
|
|
2443
|
+
/** Like {@link requestFull} but returns just the `value` field. */
|
|
2444
|
+
async request(method, path18, body, timeoutMs) {
|
|
2445
|
+
const parsed = await this.requestFull(method, path18, body, timeoutMs);
|
|
2446
|
+
return parsed?.value;
|
|
2447
|
+
}
|
|
2448
|
+
};
|
|
2449
|
+
function parseJson2(text) {
|
|
2450
|
+
if (!text) {
|
|
2451
|
+
return void 0;
|
|
2452
|
+
}
|
|
2453
|
+
try {
|
|
2454
|
+
return JSON.parse(text);
|
|
2455
|
+
} catch {
|
|
2456
|
+
return void 0;
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
function extractWebdriverError2(parsed) {
|
|
2460
|
+
const value = parsed?.value;
|
|
2461
|
+
if (value && typeof value === "object") {
|
|
2462
|
+
const record = value;
|
|
2463
|
+
const error = typeof record.error === "string" ? record.error : void 0;
|
|
2464
|
+
const message = typeof record.message === "string" ? record.message : void 0;
|
|
2465
|
+
return error ?? message;
|
|
2466
|
+
}
|
|
2467
|
+
return void 0;
|
|
2468
|
+
}
|
|
2469
|
+
function extractElementId2(value) {
|
|
2470
|
+
if (!value || typeof value !== "object") {
|
|
2471
|
+
return null;
|
|
2472
|
+
}
|
|
2473
|
+
const record = value;
|
|
2474
|
+
const id = record[W3C_ELEMENT_KEY2] ?? record.ELEMENT;
|
|
2475
|
+
return typeof id === "string" ? id : null;
|
|
2476
|
+
}
|
|
2477
|
+
function isNoSuchElement2(error) {
|
|
2478
|
+
if (error instanceof WdaHttpError) {
|
|
2479
|
+
return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
|
|
2480
|
+
}
|
|
2481
|
+
return false;
|
|
2482
|
+
}
|
|
2483
|
+
async function createWdaSession(transport, bundleId) {
|
|
2484
|
+
const body = await transport.requestFull("POST", "/session", {
|
|
2485
|
+
capabilities: { alwaysMatch: { bundleId }, firstMatch: [{}] }
|
|
2486
|
+
});
|
|
2487
|
+
const envelope = body ?? {};
|
|
2488
|
+
const value = envelope.value ?? {};
|
|
2489
|
+
const sessionId = typeof value.sessionId === "string" && value.sessionId || typeof envelope.sessionId === "string" && envelope.sessionId || "";
|
|
2490
|
+
if (sessionId.length > 0) {
|
|
2491
|
+
return sessionId;
|
|
2492
|
+
}
|
|
2493
|
+
throw new Error("WebDriverAgent did not return a session id");
|
|
2494
|
+
}
|
|
2495
|
+
async function waitForWdaReady(transport, options = { deadlineMs: 6e4 }) {
|
|
2496
|
+
const interval = options.intervalMs ?? 300;
|
|
2497
|
+
const deadline = Date.now() + options.deadlineMs;
|
|
2498
|
+
let lastError;
|
|
2499
|
+
for (; ; ) {
|
|
2500
|
+
try {
|
|
2501
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
2502
|
+
await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
|
|
2503
|
+
return;
|
|
2504
|
+
} catch (error) {
|
|
2505
|
+
lastError = error;
|
|
2506
|
+
}
|
|
2507
|
+
if (Date.now() >= deadline) {
|
|
2508
|
+
const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
|
|
2509
|
+
throw new Error(`WebDriverAgent did not become ready within ${options.deadlineMs}ms${detail}`);
|
|
2510
|
+
}
|
|
2511
|
+
await sleep2(Math.min(interval, Math.max(0, deadline - Date.now())));
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
function sleep2(ms) {
|
|
2515
|
+
return new Promise((resolve) => {
|
|
2516
|
+
setTimeout(resolve, ms);
|
|
2517
|
+
});
|
|
2518
|
+
}
|
|
2519
|
+
function createWdaAgentClient(transport, sessionId) {
|
|
2520
|
+
const base = `/session/${sessionId}`;
|
|
2521
|
+
async function locate(query, path18) {
|
|
2522
|
+
return transport.request("POST", `${base}${path18}`, iosQueryToLocator(query));
|
|
2523
|
+
}
|
|
2524
|
+
return {
|
|
2525
|
+
async findElement(query) {
|
|
2526
|
+
try {
|
|
2527
|
+
return extractElementId2(await locate(query, "/element"));
|
|
2528
|
+
} catch (error) {
|
|
2529
|
+
if (isNoSuchElement2(error)) {
|
|
2530
|
+
return null;
|
|
2531
|
+
}
|
|
2532
|
+
throw error;
|
|
2533
|
+
}
|
|
2534
|
+
},
|
|
2535
|
+
async findElements(query) {
|
|
2536
|
+
const value = await locate(query, "/elements");
|
|
2537
|
+
if (!Array.isArray(value)) {
|
|
2538
|
+
return [];
|
|
2539
|
+
}
|
|
2540
|
+
return value.map((entry) => extractElementId2(entry)).filter((id) => id !== null);
|
|
2541
|
+
},
|
|
2542
|
+
async click(elementId) {
|
|
2543
|
+
await transport.request("POST", `${base}/element/${elementId}/click`, {});
|
|
2544
|
+
},
|
|
2545
|
+
async setValue(elementId, text) {
|
|
2546
|
+
await transport.request("POST", `${base}/element/${elementId}/value`, { text });
|
|
2547
|
+
},
|
|
2548
|
+
async getText(elementId) {
|
|
2549
|
+
const value = await transport.request("GET", `${base}/element/${elementId}/text`);
|
|
2550
|
+
return typeof value === "string" ? value : value == null ? null : String(value);
|
|
2551
|
+
},
|
|
2552
|
+
async sendKeys(keys) {
|
|
2553
|
+
await transport.request("POST", `${base}/wda/keys`, { value: keys });
|
|
2554
|
+
},
|
|
2555
|
+
async homescreen() {
|
|
2556
|
+
await transport.request("POST", "/wda/homescreen", {});
|
|
2557
|
+
},
|
|
2558
|
+
async source() {
|
|
2559
|
+
const value = await transport.request("GET", "/source");
|
|
2560
|
+
if (typeof value !== "string") {
|
|
2561
|
+
throw new Error("WebDriverAgent /source did not return XML text; cannot analyze iOS UI hierarchy");
|
|
556
2562
|
}
|
|
2563
|
+
return value;
|
|
2564
|
+
},
|
|
2565
|
+
async close() {
|
|
2566
|
+
await transport.request("DELETE", base).catch(() => void 0);
|
|
557
2567
|
}
|
|
558
|
-
|
|
559
|
-
assertStepsSupportedByTarget(step.repeat.steps, target);
|
|
560
|
-
}
|
|
561
|
-
}
|
|
2568
|
+
};
|
|
562
2569
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
2570
|
+
|
|
2571
|
+
// src/browser/ios-helper.ts
|
|
2572
|
+
var import_meta3 = {};
|
|
2573
|
+
var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
|
|
2574
|
+
var WDA_USE_PORT_ENV = "USE_PORT";
|
|
2575
|
+
var PREPARED_XCTESTRUN_PREFIX = "prowl-wda-xctestrun-";
|
|
2576
|
+
var WDA_STARTUP_ATTEMPTS = 2;
|
|
2577
|
+
var defaultIosAgentConnector = async ({
|
|
2578
|
+
host,
|
|
2579
|
+
port,
|
|
2580
|
+
bundleId,
|
|
2581
|
+
requestTimeoutMs,
|
|
2582
|
+
readyDeadlineMs
|
|
2583
|
+
}) => {
|
|
2584
|
+
const transport = new WdaTransport({ baseUrl: `http://${host}:${port}`, requestTimeoutMs });
|
|
2585
|
+
await waitForWdaReady(transport, { deadlineMs: readyDeadlineMs });
|
|
2586
|
+
const sessionId = await createWdaSession(transport, bundleId);
|
|
2587
|
+
return createWdaAgentClient(transport, sessionId);
|
|
2588
|
+
};
|
|
2589
|
+
function resolveWdaProject(requireFn = (0, import_node_module2.createRequire)(import_meta3.url)) {
|
|
2590
|
+
let pkgJsonPath;
|
|
2591
|
+
try {
|
|
2592
|
+
pkgJsonPath = requireFn.resolve("appium-webdriveragent/package.json");
|
|
2593
|
+
} catch {
|
|
2594
|
+
throw new Error(
|
|
2595
|
+
"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"
|
|
2596
|
+
);
|
|
566
2597
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
);
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
}
|
|
574
|
-
function looksLikeMacosAppPath(app) {
|
|
575
|
-
const trimmed = trimTrailingPathSeparators(app);
|
|
576
|
-
return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
|
|
2598
|
+
const pkgDir = import_node_path5.default.dirname(pkgJsonPath);
|
|
2599
|
+
const version = requireFn(pkgJsonPath).version;
|
|
2600
|
+
const projectPath = import_node_path5.default.join(pkgDir, "WebDriverAgent.xcodeproj");
|
|
2601
|
+
if (!import_node_fs5.default.existsSync(projectPath)) {
|
|
2602
|
+
throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
|
|
2603
|
+
}
|
|
2604
|
+
return { projectPath, version };
|
|
577
2605
|
}
|
|
578
|
-
function
|
|
579
|
-
return
|
|
2606
|
+
function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os2.default.homedir()) {
|
|
2607
|
+
return import_node_path5.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
|
|
580
2608
|
}
|
|
581
|
-
function
|
|
582
|
-
|
|
583
|
-
return match?.[1]?.trim() || null;
|
|
2609
|
+
function productsDir(derivedDataPath) {
|
|
2610
|
+
return import_node_path5.default.join(derivedDataPath, "Build", "Products");
|
|
584
2611
|
}
|
|
585
|
-
function
|
|
586
|
-
|
|
587
|
-
|
|
2612
|
+
function findXctestrunIn(dir) {
|
|
2613
|
+
let entries;
|
|
2614
|
+
try {
|
|
2615
|
+
entries = import_node_fs5.default.readdirSync(dir);
|
|
2616
|
+
} catch {
|
|
588
2617
|
return null;
|
|
589
2618
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
2619
|
+
const match = entries.filter((name) => name.endsWith(".xctestrun") && !name.startsWith(PREPARED_XCTESTRUN_PREFIX)).sort()[0];
|
|
2620
|
+
return match ? import_node_path5.default.join(dir, match) : null;
|
|
2621
|
+
}
|
|
2622
|
+
function resolveOverrideXctestrun(override) {
|
|
2623
|
+
if (!import_node_fs5.default.existsSync(override)) {
|
|
2624
|
+
throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
|
|
2625
|
+
}
|
|
2626
|
+
if (override.endsWith(".xctestrun")) {
|
|
2627
|
+
return override;
|
|
2628
|
+
}
|
|
2629
|
+
const candidates = [];
|
|
2630
|
+
const stat = import_node_fs5.default.statSync(override);
|
|
2631
|
+
if (override.endsWith(".app")) {
|
|
2632
|
+
candidates.push(import_node_path5.default.dirname(import_node_path5.default.dirname(override)));
|
|
2633
|
+
} else if (stat.isDirectory()) {
|
|
2634
|
+
candidates.push(override, productsDir(override));
|
|
2635
|
+
}
|
|
2636
|
+
for (const dir of candidates) {
|
|
2637
|
+
const found = findXctestrunIn(dir);
|
|
2638
|
+
if (found) {
|
|
2639
|
+
return found;
|
|
594
2640
|
}
|
|
595
|
-
} catch {
|
|
596
2641
|
}
|
|
597
|
-
|
|
598
|
-
|
|
2642
|
+
throw new Error(
|
|
2643
|
+
`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\`.`
|
|
2644
|
+
);
|
|
2645
|
+
}
|
|
2646
|
+
async function resolveWdaTestRun(options = {}) {
|
|
2647
|
+
const runner = options.runner ?? execFileXcrunRunner;
|
|
2648
|
+
const env = options.env ?? process.env;
|
|
2649
|
+
const homeDir = options.homeDir ?? import_node_os2.default.homedir();
|
|
2650
|
+
const log = options.logger ?? ((message) => process.stderr.write(`${message}
|
|
2651
|
+
`));
|
|
2652
|
+
const override = env.PROWL_WDA_RUNNER;
|
|
2653
|
+
if (override) {
|
|
2654
|
+
return resolveOverrideXctestrun(override);
|
|
599
2655
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
2656
|
+
const { projectPath, version } = resolveWdaProject(options.requireFn);
|
|
2657
|
+
const xcode = await xcodeVersion(runner);
|
|
2658
|
+
if (!xcode) {
|
|
2659
|
+
throw new Error(
|
|
2660
|
+
"Could not determine the Xcode version (`xcrun xcodebuild -version`). The iOS target requires Xcode on macOS. Install it and run `xcode-select --switch`, then retry."
|
|
2661
|
+
);
|
|
2662
|
+
}
|
|
2663
|
+
const cacheDir = wdaCacheDir(version, xcode, homeDir);
|
|
2664
|
+
const cached = findXctestrunIn(productsDir(cacheDir));
|
|
2665
|
+
if (cached) {
|
|
2666
|
+
return cached;
|
|
2667
|
+
}
|
|
2668
|
+
log(
|
|
2669
|
+
`Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
|
|
2670
|
+
);
|
|
2671
|
+
import_node_fs5.default.mkdirSync(cacheDir, { recursive: true });
|
|
2672
|
+
const result = await runner(
|
|
2673
|
+
[
|
|
2674
|
+
"xcodebuild",
|
|
2675
|
+
"build-for-testing",
|
|
2676
|
+
"-project",
|
|
2677
|
+
projectPath,
|
|
2678
|
+
"-scheme",
|
|
2679
|
+
"WebDriverAgentRunner",
|
|
2680
|
+
"-destination",
|
|
2681
|
+
"generic/platform=iOS Simulator",
|
|
2682
|
+
"-derivedDataPath",
|
|
2683
|
+
cacheDir,
|
|
2684
|
+
"CODE_SIGNING_ALLOWED=NO"
|
|
2685
|
+
],
|
|
2686
|
+
{ timeoutMs: 12e5 }
|
|
2687
|
+
);
|
|
2688
|
+
if (result.code !== 0) {
|
|
2689
|
+
throw new Error(
|
|
2690
|
+
`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)}`
|
|
2691
|
+
);
|
|
2692
|
+
}
|
|
2693
|
+
const built = findXctestrunIn(productsDir(cacheDir));
|
|
2694
|
+
if (!built) {
|
|
2695
|
+
throw new Error(
|
|
2696
|
+
`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.`
|
|
605
2697
|
);
|
|
606
|
-
return output.trim() || null;
|
|
607
|
-
} catch {
|
|
608
|
-
return null;
|
|
609
2698
|
}
|
|
2699
|
+
return built;
|
|
610
2700
|
}
|
|
611
|
-
function
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
2701
|
+
function injectUsePortIntoXctestrun(plist, port) {
|
|
2702
|
+
const clone = structuredClone(plist);
|
|
2703
|
+
let injected = 0;
|
|
2704
|
+
const visit = (node) => {
|
|
2705
|
+
if (Array.isArray(node)) {
|
|
2706
|
+
for (const entry of node) {
|
|
2707
|
+
visit(entry);
|
|
2708
|
+
}
|
|
2709
|
+
return;
|
|
620
2710
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
2711
|
+
if (!node || typeof node !== "object") {
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
const obj = node;
|
|
2715
|
+
const isTarget = typeof obj.TestBundlePath === "string" || typeof obj.TestHostPath === "string";
|
|
2716
|
+
const existingEnv = obj.EnvironmentVariables && typeof obj.EnvironmentVariables === "object" && !Array.isArray(obj.EnvironmentVariables) ? obj.EnvironmentVariables : void 0;
|
|
2717
|
+
if (isTarget || existingEnv) {
|
|
2718
|
+
const env = existingEnv ?? {};
|
|
2719
|
+
env[WDA_USE_PORT_ENV] = String(port);
|
|
2720
|
+
obj.EnvironmentVariables = env;
|
|
2721
|
+
injected += 1;
|
|
624
2722
|
}
|
|
2723
|
+
for (const value of Object.values(obj)) {
|
|
2724
|
+
visit(value);
|
|
2725
|
+
}
|
|
2726
|
+
};
|
|
2727
|
+
visit(clone);
|
|
2728
|
+
if (injected === 0) {
|
|
2729
|
+
throw new Error(
|
|
2730
|
+
"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."
|
|
2731
|
+
);
|
|
625
2732
|
}
|
|
626
|
-
return
|
|
2733
|
+
return clone;
|
|
2734
|
+
}
|
|
2735
|
+
function runPlutil(args) {
|
|
2736
|
+
return new Promise((resolve, reject) => {
|
|
2737
|
+
(0, import_node_child_process6.execFile)(
|
|
2738
|
+
"plutil",
|
|
2739
|
+
args,
|
|
2740
|
+
{ encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 },
|
|
2741
|
+
(error, stdout, stderr) => {
|
|
2742
|
+
if (error) {
|
|
2743
|
+
reject(
|
|
2744
|
+
new Error(
|
|
2745
|
+
`\`plutil ${args.join(" ")}\` failed: ${(stderr || error.message).slice(0, 400)}`
|
|
2746
|
+
)
|
|
2747
|
+
);
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
resolve(stdout ?? "");
|
|
2751
|
+
}
|
|
2752
|
+
);
|
|
2753
|
+
});
|
|
627
2754
|
}
|
|
628
|
-
|
|
629
|
-
|
|
2755
|
+
var defaultWdaTestRunPreparer = async ({ xctestrunPath, port }) => {
|
|
2756
|
+
const json = await runPlutil(["-convert", "json", "-o", "-", xctestrunPath]);
|
|
2757
|
+
let parsed;
|
|
2758
|
+
try {
|
|
2759
|
+
parsed = JSON.parse(json);
|
|
2760
|
+
} catch {
|
|
2761
|
+
throw new Error(`Could not parse the WebDriverAgent .xctestrun as JSON: ${xctestrunPath}`);
|
|
2762
|
+
}
|
|
2763
|
+
const injected = injectUsePortIntoXctestrun(parsed, port);
|
|
2764
|
+
const stem = `${PREPARED_XCTESTRUN_PREFIX}${port}-${process.pid}`;
|
|
2765
|
+
const outPath = import_node_path5.default.join(import_node_path5.default.dirname(xctestrunPath), `${stem}.xctestrun`);
|
|
2766
|
+
const jsonPath = import_node_path5.default.join(import_node_os2.default.tmpdir(), `${stem}.json`);
|
|
2767
|
+
import_node_fs5.default.writeFileSync(jsonPath, JSON.stringify(injected));
|
|
2768
|
+
try {
|
|
2769
|
+
await runPlutil(["-convert", "xml1", jsonPath, "-o", outPath]);
|
|
2770
|
+
} finally {
|
|
2771
|
+
import_node_fs5.default.rmSync(jsonPath, { force: true });
|
|
2772
|
+
}
|
|
2773
|
+
return outPath;
|
|
2774
|
+
};
|
|
2775
|
+
function cleanupPreparedTestRun(preparedPath) {
|
|
2776
|
+
if (!preparedPath) {
|
|
630
2777
|
return;
|
|
631
2778
|
}
|
|
632
|
-
|
|
633
|
-
if (macosAppAllowedIdentities(app).some((identity) => allowedIdentities.has(identity))) {
|
|
2779
|
+
if (!import_node_path5.default.basename(preparedPath).startsWith(PREPARED_XCTESTRUN_PREFIX)) {
|
|
634
2780
|
return;
|
|
635
2781
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
2782
|
+
import_node_fs5.default.rmSync(preparedPath, { force: true });
|
|
2783
|
+
}
|
|
2784
|
+
function wdaTestRunArgs(xctestrunPath, udid) {
|
|
2785
|
+
return [
|
|
2786
|
+
"xcodebuild",
|
|
2787
|
+
"test-without-building",
|
|
2788
|
+
"-xctestrun",
|
|
2789
|
+
xctestrunPath,
|
|
2790
|
+
"-destination",
|
|
2791
|
+
`id=${udid}`
|
|
2792
|
+
];
|
|
2793
|
+
}
|
|
2794
|
+
async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
|
|
2795
|
+
if (!looksLikeIosAppPath(app)) {
|
|
2796
|
+
assertIosAppAllowed(allowedApps, app);
|
|
2797
|
+
if (coldStart) {
|
|
2798
|
+
throw new Error(
|
|
2799
|
+
`coldStart requires target.app to be a built .app bundle path (a bare bundle id like "${app}" cannot be reinstalled). Point target.app at the .app, or drop coldStart.`
|
|
2800
|
+
);
|
|
2801
|
+
}
|
|
2802
|
+
return app;
|
|
2803
|
+
}
|
|
2804
|
+
const appPath = import_node_path5.default.resolve(app);
|
|
2805
|
+
if (!import_node_fs5.default.existsSync(appPath)) {
|
|
2806
|
+
throw new Error(`.app bundle not found: ${appPath}`);
|
|
2807
|
+
}
|
|
2808
|
+
const bundleId = readIosBundleIdentifier(appPath);
|
|
2809
|
+
if (!bundleId) {
|
|
2810
|
+
throw new Error(
|
|
2811
|
+
`Could not read CFBundleIdentifier from "${appPath}" (root Info.plist). Ensure target.app points at a built iOS .app bundle.`
|
|
2812
|
+
);
|
|
2813
|
+
}
|
|
2814
|
+
assertIosAppAllowed(allowedApps, appPath);
|
|
2815
|
+
if (coldStart) {
|
|
2816
|
+
await uninstallApp(runner, udid, bundleId);
|
|
2817
|
+
}
|
|
2818
|
+
await installApp(runner, udid, appPath);
|
|
2819
|
+
return bundleId;
|
|
2820
|
+
}
|
|
2821
|
+
async function launchIosSession(options) {
|
|
2822
|
+
const runner = options.runner ?? execFileXcrunRunner;
|
|
2823
|
+
const spawner = options.spawner ?? spawnXcrunProcess;
|
|
2824
|
+
const preparer = options.testRunPreparer ?? defaultWdaTestRunPreparer;
|
|
2825
|
+
const portAllocator = options.portAllocator ?? findFreePort;
|
|
2826
|
+
const connector = options.agentConnector ?? defaultIosAgentConnector;
|
|
2827
|
+
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
|
|
2828
|
+
const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, 6e4);
|
|
2829
|
+
const devices = await listSimulators(runner);
|
|
2830
|
+
const udid = selectSimulatorUdid(devices, options.udid);
|
|
2831
|
+
const reservation = await reserveSimulatorUdid(udid, { lockRoot: options.simulatorLockRoot });
|
|
2832
|
+
let reservationReleased = false;
|
|
2833
|
+
const releaseReservation = async () => {
|
|
2834
|
+
if (reservationReleased) {
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
reservationReleased = true;
|
|
2838
|
+
await reservation.release();
|
|
2839
|
+
};
|
|
2840
|
+
try {
|
|
2841
|
+
const bundleId = await resolveBundleId(
|
|
2842
|
+
options.app,
|
|
2843
|
+
runner,
|
|
2844
|
+
udid,
|
|
2845
|
+
options.coldStart ?? false,
|
|
2846
|
+
options.allowedApps ?? []
|
|
2847
|
+
);
|
|
2848
|
+
const baseTestRun = options.wdaTestRun ?? await resolveWdaTestRun({ runner, logger: options.logger });
|
|
2849
|
+
for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
|
|
2850
|
+
let client;
|
|
2851
|
+
let wdaProcess;
|
|
2852
|
+
let preparedTestRun;
|
|
2853
|
+
let tornDown = false;
|
|
2854
|
+
let terminateTarget = false;
|
|
2855
|
+
let stage = "prepare";
|
|
2856
|
+
const teardownAttempt = async () => {
|
|
2857
|
+
if (tornDown) {
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
tornDown = true;
|
|
2861
|
+
if (client) {
|
|
2862
|
+
await client.close().catch(() => void 0);
|
|
2863
|
+
}
|
|
2864
|
+
wdaProcess?.kill();
|
|
2865
|
+
await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
|
|
2866
|
+
if (terminateTarget) {
|
|
2867
|
+
await terminateApp(runner, udid, bundleId);
|
|
2868
|
+
}
|
|
2869
|
+
cleanupPreparedTestRun(preparedTestRun);
|
|
2870
|
+
};
|
|
2871
|
+
try {
|
|
2872
|
+
const port = await portAllocator();
|
|
2873
|
+
preparedTestRun = await preparer({ xctestrunPath: baseTestRun, port });
|
|
2874
|
+
stage = "wda-launch";
|
|
2875
|
+
wdaProcess = spawner(wdaTestRunArgs(preparedTestRun, udid));
|
|
2876
|
+
stage = "target-launch";
|
|
2877
|
+
terminateTarget = true;
|
|
2878
|
+
await launchApp(runner, udid, bundleId);
|
|
2879
|
+
stage = "connect";
|
|
2880
|
+
client = await connector({ host: "127.0.0.1", port, bundleId, requestTimeoutMs, readyDeadlineMs });
|
|
2881
|
+
const driver = createIosDriver(client, {
|
|
2882
|
+
appLabel: bundleId,
|
|
2883
|
+
captureScreenshot: (outPath) => captureScreenshot(runner, udid, outPath)
|
|
2884
|
+
});
|
|
2885
|
+
const teardown = async () => {
|
|
2886
|
+
await teardownAttempt();
|
|
2887
|
+
await releaseReservation();
|
|
2888
|
+
};
|
|
2889
|
+
return { client, driver, bundleId, udid, teardown };
|
|
2890
|
+
} catch (error) {
|
|
2891
|
+
await teardownAttempt();
|
|
2892
|
+
if (stage === "target-launch" || attempt >= WDA_STARTUP_ATTEMPTS) {
|
|
2893
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
throw new Error("WebDriverAgent startup failed without an error.");
|
|
2898
|
+
} catch (error) {
|
|
2899
|
+
await releaseReservation().catch(() => void 0);
|
|
2900
|
+
throw error;
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
async function closeIosSession(session) {
|
|
2904
|
+
await session.teardown();
|
|
639
2905
|
}
|
|
640
2906
|
|
|
641
2907
|
// src/runner/index.ts
|
|
642
|
-
var
|
|
643
|
-
var
|
|
2908
|
+
var import_node_fs14 = __toESM(require("fs"), 1);
|
|
2909
|
+
var import_node_path14 = __toESM(require("path"), 1);
|
|
644
2910
|
|
|
645
2911
|
// src/config/loader.ts
|
|
646
|
-
var
|
|
647
|
-
var
|
|
2912
|
+
var import_node_fs6 = __toESM(require("fs"), 1);
|
|
2913
|
+
var import_node_path6 = __toESM(require("path"), 1);
|
|
648
2914
|
var import_yaml = __toESM(require("yaml"), 1);
|
|
649
2915
|
var import_dotenv = __toESM(require("dotenv"), 1);
|
|
650
2916
|
|
|
@@ -676,7 +2942,24 @@ var macosTargetSchema = import_zod.z.object({
|
|
|
676
2942
|
type: import_zod.z.literal("macos"),
|
|
677
2943
|
app: import_zod.z.string().min(1)
|
|
678
2944
|
}).strict();
|
|
679
|
-
var
|
|
2945
|
+
var androidTargetSchema = import_zod.z.object({
|
|
2946
|
+
type: import_zod.z.literal("android"),
|
|
2947
|
+
app: import_zod.z.string().min(1),
|
|
2948
|
+
deviceSerial: import_zod.z.string().min(1).optional(),
|
|
2949
|
+
coldStart: import_zod.z.boolean().optional()
|
|
2950
|
+
}).strict();
|
|
2951
|
+
var iosTargetSchema = import_zod.z.object({
|
|
2952
|
+
type: import_zod.z.literal("ios"),
|
|
2953
|
+
app: import_zod.z.string().min(1),
|
|
2954
|
+
udid: import_zod.z.string().min(1).optional(),
|
|
2955
|
+
coldStart: import_zod.z.boolean().optional()
|
|
2956
|
+
}).strict();
|
|
2957
|
+
var targetSchema = import_zod.z.union([
|
|
2958
|
+
macosTargetSchema,
|
|
2959
|
+
androidTargetSchema,
|
|
2960
|
+
iosTargetSchema,
|
|
2961
|
+
webTargetSchema
|
|
2962
|
+
]);
|
|
680
2963
|
var configSchema = import_zod.z.object({
|
|
681
2964
|
target: targetSchema,
|
|
682
2965
|
browser: import_zod.z.object({
|
|
@@ -896,6 +3179,9 @@ var assertScreenshotStepSchema = import_zod.z.object({
|
|
|
896
3179
|
threshold: import_zod.z.number().min(0).max(1).optional()
|
|
897
3180
|
}).strict()
|
|
898
3181
|
}).strict();
|
|
3182
|
+
var assertWithAiStepSchema = import_zod.z.object({
|
|
3183
|
+
assertWithAI: import_zod.z.string().min(1)
|
|
3184
|
+
}).strict();
|
|
899
3185
|
var copyTextStepSchema = import_zod.z.object({
|
|
900
3186
|
copyText: import_zod.z.object({
|
|
901
3187
|
selector: import_zod.z.string().min(1),
|
|
@@ -938,6 +3224,7 @@ var stepSchema = import_zod.z.union([
|
|
|
938
3224
|
evalScriptStepSchema,
|
|
939
3225
|
runScriptStepSchema,
|
|
940
3226
|
assertScreenshotStepSchema,
|
|
3227
|
+
assertWithAiStepSchema,
|
|
941
3228
|
copyTextStepSchema,
|
|
942
3229
|
waitForDownloadStepSchema
|
|
943
3230
|
]);
|
|
@@ -1018,12 +3305,12 @@ function findConfigPath(startDir) {
|
|
|
1018
3305
|
let current = startDir;
|
|
1019
3306
|
while (current) {
|
|
1020
3307
|
for (const dir of [CONFIG_DIR, LEGACY_CONFIG_DIR]) {
|
|
1021
|
-
const candidate =
|
|
1022
|
-
if (
|
|
3308
|
+
const candidate = import_node_path6.default.join(current, dir, "config.yml");
|
|
3309
|
+
if (import_node_fs6.default.existsSync(candidate)) {
|
|
1023
3310
|
return candidate;
|
|
1024
3311
|
}
|
|
1025
3312
|
}
|
|
1026
|
-
const parent =
|
|
3313
|
+
const parent = import_node_path6.default.dirname(current);
|
|
1027
3314
|
if (parent === current) {
|
|
1028
3315
|
break;
|
|
1029
3316
|
}
|
|
@@ -1050,9 +3337,28 @@ function resolveViewport(value) {
|
|
|
1050
3337
|
return value;
|
|
1051
3338
|
}
|
|
1052
3339
|
function resolveTarget(target) {
|
|
1053
|
-
|
|
3340
|
+
const type = target?.type;
|
|
3341
|
+
if (type === "macos") {
|
|
1054
3342
|
return { type: "macos", app: target.app };
|
|
1055
3343
|
}
|
|
3344
|
+
if (type === "android") {
|
|
3345
|
+
const androidTarget = target;
|
|
3346
|
+
return {
|
|
3347
|
+
type: "android",
|
|
3348
|
+
app: androidTarget.app,
|
|
3349
|
+
...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
|
|
3350
|
+
...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
|
|
3351
|
+
};
|
|
3352
|
+
}
|
|
3353
|
+
if (type === "ios") {
|
|
3354
|
+
const iosTarget = target;
|
|
3355
|
+
return {
|
|
3356
|
+
type: "ios",
|
|
3357
|
+
app: iosTarget.app,
|
|
3358
|
+
...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
|
|
3359
|
+
...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
|
|
3360
|
+
};
|
|
3361
|
+
}
|
|
1056
3362
|
return {
|
|
1057
3363
|
type: "web",
|
|
1058
3364
|
url: target?.url ?? DEFAULT_WEB_URL
|
|
@@ -1111,19 +3417,19 @@ function ensureAllowedDomain(allowed, urlValue) {
|
|
|
1111
3417
|
return allowed;
|
|
1112
3418
|
}
|
|
1113
3419
|
function loadConfig(configPath) {
|
|
1114
|
-
const resolvedPath = configPath ?
|
|
3420
|
+
const resolvedPath = configPath ? import_node_path6.default.resolve(configPath) : findConfigPath(process.cwd());
|
|
1115
3421
|
if (!resolvedPath) {
|
|
1116
3422
|
throw new Error("Could not find .prowl/config.yml. Run `prowl init` first.");
|
|
1117
3423
|
}
|
|
1118
|
-
if (!
|
|
3424
|
+
if (!import_node_fs6.default.existsSync(resolvedPath)) {
|
|
1119
3425
|
throw new Error(`Config file not found at ${resolvedPath}`);
|
|
1120
3426
|
}
|
|
1121
|
-
const configDir =
|
|
1122
|
-
if (
|
|
3427
|
+
const configDir = import_node_path6.default.dirname(resolvedPath);
|
|
3428
|
+
if (import_node_path6.default.basename(configDir) === LEGACY_CONFIG_DIR) {
|
|
1123
3429
|
warnLegacyConfigDir();
|
|
1124
3430
|
}
|
|
1125
|
-
import_dotenv.default.config({ path:
|
|
1126
|
-
const raw =
|
|
3431
|
+
import_dotenv.default.config({ path: import_node_path6.default.join(configDir, ".env"), override: false });
|
|
3432
|
+
const raw = import_node_fs6.default.readFileSync(resolvedPath, "utf-8");
|
|
1127
3433
|
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
1128
3434
|
const validated = configSchema.parse(parsed);
|
|
1129
3435
|
const config = mergeConfig(validated);
|
|
@@ -1137,32 +3443,32 @@ function loadConfig(configPath) {
|
|
|
1137
3443
|
}
|
|
1138
3444
|
function loadHunt(huntName, configDir) {
|
|
1139
3445
|
assertValidHuntName(huntName);
|
|
1140
|
-
const huntPath =
|
|
1141
|
-
if (!
|
|
3446
|
+
const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
|
|
3447
|
+
if (!import_node_fs6.default.existsSync(huntPath)) {
|
|
1142
3448
|
throw new Error(`Hunt file not found: ${huntPath}`);
|
|
1143
3449
|
}
|
|
1144
|
-
const raw =
|
|
3450
|
+
const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
|
|
1145
3451
|
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
1146
3452
|
const validated = huntSchema.parse(parsed);
|
|
1147
3453
|
return validated;
|
|
1148
3454
|
}
|
|
1149
3455
|
function loadHuntTags(huntName, configDir) {
|
|
1150
3456
|
assertValidHuntName(huntName);
|
|
1151
|
-
const huntPath =
|
|
1152
|
-
if (!
|
|
3457
|
+
const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
|
|
3458
|
+
if (!import_node_fs6.default.existsSync(huntPath)) {
|
|
1153
3459
|
return [];
|
|
1154
3460
|
}
|
|
1155
|
-
const raw =
|
|
3461
|
+
const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
|
|
1156
3462
|
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
1157
3463
|
return Array.isArray(parsed.tags) ? parsed.tags : [];
|
|
1158
3464
|
}
|
|
1159
3465
|
function loadHuntMeta(huntName, configDir) {
|
|
1160
3466
|
assertValidHuntName(huntName);
|
|
1161
|
-
const huntPath =
|
|
1162
|
-
if (!
|
|
3467
|
+
const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
|
|
3468
|
+
if (!import_node_fs6.default.existsSync(huntPath)) {
|
|
1163
3469
|
return { tags: [] };
|
|
1164
3470
|
}
|
|
1165
|
-
const raw =
|
|
3471
|
+
const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
|
|
1166
3472
|
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
1167
3473
|
return {
|
|
1168
3474
|
description: typeof parsed.description === "string" ? parsed.description : void 0,
|
|
@@ -1170,24 +3476,24 @@ function loadHuntMeta(huntName, configDir) {
|
|
|
1170
3476
|
};
|
|
1171
3477
|
}
|
|
1172
3478
|
function listHunts(configDir) {
|
|
1173
|
-
const huntsDir =
|
|
1174
|
-
if (!
|
|
3479
|
+
const huntsDir = import_node_path6.default.join(configDir, "hunts");
|
|
3480
|
+
if (!import_node_fs6.default.existsSync(huntsDir)) {
|
|
1175
3481
|
return [];
|
|
1176
3482
|
}
|
|
1177
|
-
const stats =
|
|
3483
|
+
const stats = import_node_fs6.default.statSync(huntsDir);
|
|
1178
3484
|
if (!stats.isDirectory()) {
|
|
1179
3485
|
throw new Error(`Hunts path is not a directory: ${huntsDir}`);
|
|
1180
3486
|
}
|
|
1181
3487
|
const results = [];
|
|
1182
3488
|
function scanDir(dir) {
|
|
1183
|
-
const entries =
|
|
3489
|
+
const entries = import_node_fs6.default.readdirSync(dir, { withFileTypes: true });
|
|
1184
3490
|
for (const entry of entries) {
|
|
1185
3491
|
if (entry.isFile() && entry.name.endsWith(".yml")) {
|
|
1186
|
-
const fullPath =
|
|
1187
|
-
const relative =
|
|
3492
|
+
const fullPath = import_node_path6.default.join(dir, entry.name);
|
|
3493
|
+
const relative = import_node_path6.default.relative(huntsDir, fullPath);
|
|
1188
3494
|
results.push(relative.replace(/\.yml$/, ""));
|
|
1189
3495
|
} else if (entry.isDirectory()) {
|
|
1190
|
-
scanDir(
|
|
3496
|
+
scanDir(import_node_path6.default.join(dir, entry.name));
|
|
1191
3497
|
}
|
|
1192
3498
|
}
|
|
1193
3499
|
}
|
|
@@ -1489,6 +3795,11 @@ function interpolateStep(step, vars, stepPath2, redacted) {
|
|
|
1489
3795
|
}
|
|
1490
3796
|
};
|
|
1491
3797
|
}
|
|
3798
|
+
if ("assertWithAI" in step) {
|
|
3799
|
+
return {
|
|
3800
|
+
assertWithAI: interpolateString(step.assertWithAI, vars).value
|
|
3801
|
+
};
|
|
3802
|
+
}
|
|
1492
3803
|
if ("copyText" in step) {
|
|
1493
3804
|
return {
|
|
1494
3805
|
copyText: {
|
|
@@ -1562,8 +3873,8 @@ function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
|
|
|
1562
3873
|
}
|
|
1563
3874
|
|
|
1564
3875
|
// src/browser/playwright-driver.ts
|
|
1565
|
-
var
|
|
1566
|
-
var
|
|
3876
|
+
var import_node_fs7 = __toESM(require("fs"), 1);
|
|
3877
|
+
var import_node_path7 = __toESM(require("path"), 1);
|
|
1567
3878
|
var import_playwright = require("playwright");
|
|
1568
3879
|
var ENGINES = { chromium: import_playwright.chromium, firefox: import_playwright.firefox, webkit: import_playwright.webkit };
|
|
1569
3880
|
async function launchBrowser(options) {
|
|
@@ -1585,14 +3896,14 @@ async function launchBrowser(options) {
|
|
|
1585
3896
|
contextOptions.viewport = options.viewport;
|
|
1586
3897
|
}
|
|
1587
3898
|
if (options.storageStatePath) {
|
|
1588
|
-
if (
|
|
3899
|
+
if (import_node_fs7.default.existsSync(options.storageStatePath)) {
|
|
1589
3900
|
contextOptions.storageState = options.storageStatePath;
|
|
1590
3901
|
} else {
|
|
1591
3902
|
console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
|
|
1592
3903
|
}
|
|
1593
3904
|
}
|
|
1594
3905
|
if (options.recordHar) {
|
|
1595
|
-
contextOptions.recordHar = { path:
|
|
3906
|
+
contextOptions.recordHar = { path: import_node_path7.default.join(options.runDir, "network.har") };
|
|
1596
3907
|
}
|
|
1597
3908
|
const context = await browser.newContext(contextOptions);
|
|
1598
3909
|
const page = await context.newPage();
|
|
@@ -1600,7 +3911,7 @@ async function launchBrowser(options) {
|
|
|
1600
3911
|
page.setDefaultNavigationTimeout(options.timeout);
|
|
1601
3912
|
let tracePath;
|
|
1602
3913
|
if (options.trace) {
|
|
1603
|
-
tracePath =
|
|
3914
|
+
tracePath = import_node_path7.default.join(options.runDir, "trace.zip");
|
|
1604
3915
|
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
1605
3916
|
}
|
|
1606
3917
|
return { browser, context, page, tracePath };
|
|
@@ -1775,8 +4086,8 @@ function createPlaywrightDriver(page) {
|
|
|
1775
4086
|
}
|
|
1776
4087
|
|
|
1777
4088
|
// src/runner/steps.ts
|
|
1778
|
-
var
|
|
1779
|
-
var
|
|
4089
|
+
var import_node_fs9 = __toESM(require("fs"), 1);
|
|
4090
|
+
var import_node_path9 = __toESM(require("path"), 1);
|
|
1780
4091
|
|
|
1781
4092
|
// src/runner/healing.ts
|
|
1782
4093
|
var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
|
|
@@ -1820,8 +4131,8 @@ async function healSelector(probe, selector, options) {
|
|
|
1820
4131
|
for (const candidate of buildHealCandidates(selector)) {
|
|
1821
4132
|
let count;
|
|
1822
4133
|
try {
|
|
1823
|
-
const
|
|
1824
|
-
count = await
|
|
4134
|
+
const locator2 = probe.locator(candidate.selector);
|
|
4135
|
+
count = await locator2.count();
|
|
1825
4136
|
} catch {
|
|
1826
4137
|
continue;
|
|
1827
4138
|
}
|
|
@@ -1920,14 +4231,254 @@ function createRunPolicy(driver, options) {
|
|
|
1920
4231
|
return { selector: healed.selector, healedFrom: healed.healedFrom };
|
|
1921
4232
|
}
|
|
1922
4233
|
return {
|
|
1923
|
-
assertWithinMaxSteps,
|
|
1924
|
-
ensureUrlAllowed,
|
|
1925
|
-
ensureAppAllowed,
|
|
1926
|
-
ensureLocationAllowed,
|
|
1927
|
-
assertAllowedSelector,
|
|
1928
|
-
resolveActionSelector
|
|
4234
|
+
assertWithinMaxSteps,
|
|
4235
|
+
ensureUrlAllowed,
|
|
4236
|
+
ensureAppAllowed,
|
|
4237
|
+
ensureLocationAllowed,
|
|
4238
|
+
assertAllowedSelector,
|
|
4239
|
+
resolveActionSelector
|
|
4240
|
+
};
|
|
4241
|
+
}
|
|
4242
|
+
|
|
4243
|
+
// src/generator/ai.ts
|
|
4244
|
+
var DEFAULT_BASE_URL = {
|
|
4245
|
+
anthropic: "https://api.anthropic.com",
|
|
4246
|
+
openai: "https://api.openai.com"
|
|
4247
|
+
};
|
|
4248
|
+
var AI_REQUEST_TIMEOUT_MS = 3e4;
|
|
4249
|
+
function apiRoot(config) {
|
|
4250
|
+
return config.baseUrl ?? DEFAULT_BASE_URL[config.provider];
|
|
4251
|
+
}
|
|
4252
|
+
function defaultModelFor(provider) {
|
|
4253
|
+
return provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
|
|
4254
|
+
}
|
|
4255
|
+
function resolveAiEnv() {
|
|
4256
|
+
const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
|
|
4257
|
+
if (provider !== "anthropic" && provider !== "openai") {
|
|
4258
|
+
throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
|
|
4259
|
+
}
|
|
4260
|
+
const model = process.env.PROWL_AI_MODEL ?? defaultModelFor(provider);
|
|
4261
|
+
const baseUrl = normalizeBaseUrl(process.env.PROWL_AI_BASE_URL) ?? DEFAULT_BASE_URL[provider];
|
|
4262
|
+
return { provider, model, baseUrl, apiKey: process.env.PROWL_AI_KEY };
|
|
4263
|
+
}
|
|
4264
|
+
function normalizeBaseUrl(value) {
|
|
4265
|
+
if (value === void 0) return void 0;
|
|
4266
|
+
const trimmed = value.trim().replace(/\/+$/, "");
|
|
4267
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
4268
|
+
}
|
|
4269
|
+
function resolveAiConfig() {
|
|
4270
|
+
const env = resolveAiEnv();
|
|
4271
|
+
if (!env.apiKey) {
|
|
4272
|
+
throw new Error(
|
|
4273
|
+
"PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
|
|
4274
|
+
);
|
|
4275
|
+
}
|
|
4276
|
+
return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
|
|
4277
|
+
}
|
|
4278
|
+
function tryResolveAiConfig() {
|
|
4279
|
+
const env = resolveAiEnv();
|
|
4280
|
+
if (!env.apiKey) {
|
|
4281
|
+
return null;
|
|
4282
|
+
}
|
|
4283
|
+
return { provider: env.provider, model: env.model, apiKey: env.apiKey, baseUrl: env.baseUrl };
|
|
4284
|
+
}
|
|
4285
|
+
async function generateWithAi(prompt, config) {
|
|
4286
|
+
if (config.provider === "anthropic") {
|
|
4287
|
+
return generateWithAnthropic(prompt, config);
|
|
4288
|
+
}
|
|
4289
|
+
return generateWithOpenAi(prompt, config);
|
|
4290
|
+
}
|
|
4291
|
+
async function generateWithAnthropic(prompt, config) {
|
|
4292
|
+
const data = await postJson(
|
|
4293
|
+
"Anthropic",
|
|
4294
|
+
`${apiRoot(config)}/v1/messages`,
|
|
4295
|
+
anthropicHeaders(config),
|
|
4296
|
+
{
|
|
4297
|
+
model: config.model,
|
|
4298
|
+
max_tokens: 4096,
|
|
4299
|
+
messages: [
|
|
4300
|
+
{ role: "user", content: prompt }
|
|
4301
|
+
]
|
|
4302
|
+
}
|
|
4303
|
+
);
|
|
4304
|
+
return extractAnthropicText(data);
|
|
4305
|
+
}
|
|
4306
|
+
async function generateWithOpenAi(prompt, config) {
|
|
4307
|
+
const data = await postJson(
|
|
4308
|
+
"OpenAI",
|
|
4309
|
+
`${apiRoot(config)}/v1/chat/completions`,
|
|
4310
|
+
openAiHeaders(config),
|
|
4311
|
+
{
|
|
4312
|
+
model: config.model,
|
|
4313
|
+
messages: [
|
|
4314
|
+
{ role: "user", content: prompt }
|
|
4315
|
+
],
|
|
4316
|
+
max_tokens: 4096
|
|
4317
|
+
}
|
|
4318
|
+
);
|
|
4319
|
+
return extractOpenAiText(data);
|
|
4320
|
+
}
|
|
4321
|
+
function buildVisionPrompt(assertion) {
|
|
4322
|
+
return [
|
|
4323
|
+
"You are a meticulous QA reviewer. You are given a screenshot of an application",
|
|
4324
|
+
"and a single assertion describing what should be true about it.",
|
|
4325
|
+
"",
|
|
4326
|
+
"Assertion:",
|
|
4327
|
+
assertion,
|
|
4328
|
+
"",
|
|
4329
|
+
"Decide whether the assertion holds for the screenshot. Judge ONLY what is",
|
|
4330
|
+
"visible; do not assume behavior you cannot see. Be strict: if the assertion",
|
|
4331
|
+
"is not clearly satisfied, it fails.",
|
|
4332
|
+
"",
|
|
4333
|
+
"Reply with ONLY a single JSON object on one line, no markdown, no code fences:",
|
|
4334
|
+
'{"pass": <true|false>, "reason": "<one concise sentence explaining the verdict>"}'
|
|
4335
|
+
].join("\n");
|
|
4336
|
+
}
|
|
4337
|
+
function parseVisionVerdict(raw) {
|
|
4338
|
+
const cleaned = stripCodeFences(raw).trim();
|
|
4339
|
+
const candidate = extractJsonObject(cleaned);
|
|
4340
|
+
if (candidate === null) {
|
|
4341
|
+
throw new Error(
|
|
4342
|
+
`Could not parse a JSON verdict from the AI response: ${truncateForError(raw)}`
|
|
4343
|
+
);
|
|
4344
|
+
}
|
|
4345
|
+
let parsed;
|
|
4346
|
+
try {
|
|
4347
|
+
parsed = JSON.parse(candidate);
|
|
4348
|
+
} catch {
|
|
4349
|
+
throw new Error(
|
|
4350
|
+
`AI verdict was not valid JSON: ${truncateForError(raw)}`
|
|
4351
|
+
);
|
|
4352
|
+
}
|
|
4353
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
4354
|
+
throw new Error(`AI verdict was not a JSON object: ${truncateForError(raw)}`);
|
|
4355
|
+
}
|
|
4356
|
+
const record = parsed;
|
|
4357
|
+
if (typeof record.pass !== "boolean") {
|
|
4358
|
+
throw new Error(
|
|
4359
|
+
`AI verdict is missing a boolean "pass" field: ${truncateForError(raw)}`
|
|
4360
|
+
);
|
|
4361
|
+
}
|
|
4362
|
+
const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : record.pass ? "Assertion satisfied." : "Assertion not satisfied.";
|
|
4363
|
+
return { pass: record.pass, reason };
|
|
4364
|
+
}
|
|
4365
|
+
function stripCodeFences(text) {
|
|
4366
|
+
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
4367
|
+
return fence ? fence[1] : text;
|
|
4368
|
+
}
|
|
4369
|
+
function extractJsonObject(text) {
|
|
4370
|
+
const trimmed = text.trim();
|
|
4371
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
4372
|
+
return trimmed;
|
|
4373
|
+
}
|
|
4374
|
+
const first = trimmed.indexOf("{");
|
|
4375
|
+
const last = trimmed.lastIndexOf("}");
|
|
4376
|
+
if (first === -1 || last === -1 || last <= first) {
|
|
4377
|
+
return null;
|
|
4378
|
+
}
|
|
4379
|
+
return trimmed.slice(first, last + 1);
|
|
4380
|
+
}
|
|
4381
|
+
function truncateForError(text) {
|
|
4382
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
4383
|
+
return collapsed.length > 200 ? `${collapsed.slice(0, 199)}\u2026` : collapsed;
|
|
4384
|
+
}
|
|
4385
|
+
async function assertWithAiVision(input, config) {
|
|
4386
|
+
const raw = config.provider === "anthropic" ? await visionWithAnthropic(input, config) : await visionWithOpenAi(input, config);
|
|
4387
|
+
return parseVisionVerdict(raw);
|
|
4388
|
+
}
|
|
4389
|
+
async function visionWithAnthropic(input, config) {
|
|
4390
|
+
const data = await postJson(
|
|
4391
|
+
"Anthropic",
|
|
4392
|
+
`${apiRoot(config)}/v1/messages`,
|
|
4393
|
+
anthropicHeaders(config),
|
|
4394
|
+
{
|
|
4395
|
+
model: config.model,
|
|
4396
|
+
max_tokens: 1024,
|
|
4397
|
+
temperature: 0,
|
|
4398
|
+
messages: [
|
|
4399
|
+
{
|
|
4400
|
+
role: "user",
|
|
4401
|
+
content: [
|
|
4402
|
+
{
|
|
4403
|
+
type: "image",
|
|
4404
|
+
source: {
|
|
4405
|
+
type: "base64",
|
|
4406
|
+
media_type: input.mediaType,
|
|
4407
|
+
data: input.imageBase64
|
|
4408
|
+
}
|
|
4409
|
+
},
|
|
4410
|
+
{ type: "text", text: buildVisionPrompt(input.assertion) }
|
|
4411
|
+
]
|
|
4412
|
+
}
|
|
4413
|
+
]
|
|
4414
|
+
}
|
|
4415
|
+
);
|
|
4416
|
+
return extractAnthropicText(data);
|
|
4417
|
+
}
|
|
4418
|
+
async function visionWithOpenAi(input, config) {
|
|
4419
|
+
const data = await postJson(
|
|
4420
|
+
"OpenAI",
|
|
4421
|
+
`${apiRoot(config)}/v1/chat/completions`,
|
|
4422
|
+
openAiHeaders(config),
|
|
4423
|
+
{
|
|
4424
|
+
model: config.model,
|
|
4425
|
+
max_tokens: 1024,
|
|
4426
|
+
temperature: 0,
|
|
4427
|
+
messages: [
|
|
4428
|
+
{
|
|
4429
|
+
role: "user",
|
|
4430
|
+
content: [
|
|
4431
|
+
{ type: "text", text: buildVisionPrompt(input.assertion) },
|
|
4432
|
+
{
|
|
4433
|
+
type: "image_url",
|
|
4434
|
+
image_url: { url: `data:${input.mediaType};base64,${input.imageBase64}` }
|
|
4435
|
+
}
|
|
4436
|
+
]
|
|
4437
|
+
}
|
|
4438
|
+
]
|
|
4439
|
+
}
|
|
4440
|
+
);
|
|
4441
|
+
return extractOpenAiText(data);
|
|
4442
|
+
}
|
|
4443
|
+
async function postJson(provider, url, headers, body) {
|
|
4444
|
+
const response = await fetch(url, {
|
|
4445
|
+
method: "POST",
|
|
4446
|
+
headers,
|
|
4447
|
+
body: JSON.stringify(body),
|
|
4448
|
+
signal: AbortSignal.timeout(AI_REQUEST_TIMEOUT_MS)
|
|
4449
|
+
});
|
|
4450
|
+
if (!response.ok) {
|
|
4451
|
+
const responseBody = await response.text();
|
|
4452
|
+
throw new Error(`${provider} API error (${response.status}): ${responseBody}`);
|
|
4453
|
+
}
|
|
4454
|
+
return await response.json();
|
|
4455
|
+
}
|
|
4456
|
+
function anthropicHeaders(config) {
|
|
4457
|
+
return {
|
|
4458
|
+
"Content-Type": "application/json",
|
|
4459
|
+
"x-api-key": config.apiKey,
|
|
4460
|
+
"anthropic-version": "2023-06-01"
|
|
4461
|
+
};
|
|
4462
|
+
}
|
|
4463
|
+
function openAiHeaders(config) {
|
|
4464
|
+
return {
|
|
4465
|
+
"Content-Type": "application/json",
|
|
4466
|
+
"Authorization": `Bearer ${config.apiKey}`
|
|
1929
4467
|
};
|
|
1930
4468
|
}
|
|
4469
|
+
function extractAnthropicText(data) {
|
|
4470
|
+
const textBlock = data.content?.find((c) => c.type === "text");
|
|
4471
|
+
if (!textBlock?.text) {
|
|
4472
|
+
throw new Error("Anthropic API returned no text content");
|
|
4473
|
+
}
|
|
4474
|
+
return textBlock.text;
|
|
4475
|
+
}
|
|
4476
|
+
function extractOpenAiText(data) {
|
|
4477
|
+
if (!data.choices?.[0]?.message?.content) {
|
|
4478
|
+
throw new Error("OpenAI API returned no content");
|
|
4479
|
+
}
|
|
4480
|
+
return data.choices[0].message.content;
|
|
4481
|
+
}
|
|
1931
4482
|
|
|
1932
4483
|
// src/runner/steps.ts
|
|
1933
4484
|
function getStepType(step) {
|
|
@@ -1957,6 +4508,7 @@ function getStepType(step) {
|
|
|
1957
4508
|
if ("evalScript" in step) return "evalScript";
|
|
1958
4509
|
if ("runScript" in step) return "runScript";
|
|
1959
4510
|
if ("assertScreenshot" in step) return "assertScreenshot";
|
|
4511
|
+
if ("assertWithAI" in step) return "assertWithAI";
|
|
1960
4512
|
if ("copyText" in step) return "copyText";
|
|
1961
4513
|
if ("waitForDownload" in step) return "waitForDownload";
|
|
1962
4514
|
return "step";
|
|
@@ -2016,6 +4568,9 @@ function applyRuntimeVars(step, vars) {
|
|
|
2016
4568
|
}
|
|
2017
4569
|
};
|
|
2018
4570
|
}
|
|
4571
|
+
if ("assertWithAI" in step) {
|
|
4572
|
+
return { assertWithAI: sub(step.assertWithAI) };
|
|
4573
|
+
}
|
|
2019
4574
|
if ("copyText" in step) {
|
|
2020
4575
|
return { copyText: { selector: sub(step.copyText.selector), as: step.copyText.as } };
|
|
2021
4576
|
}
|
|
@@ -2266,7 +4821,7 @@ async function runInlineAssert(driver, policy, assertion) {
|
|
|
2266
4821
|
throw new Error("assert step is missing an assertion type");
|
|
2267
4822
|
}
|
|
2268
4823
|
function screenshotPath(screenshotsDir, fileName) {
|
|
2269
|
-
return
|
|
4824
|
+
return import_node_path9.default.join(screenshotsDir, fileName);
|
|
2270
4825
|
}
|
|
2271
4826
|
function stepPath(prefix, index) {
|
|
2272
4827
|
return prefix ? `${prefix}.${index}` : `${index}`;
|
|
@@ -2283,12 +4838,12 @@ function validateDownloadFilename(suggestedFilename) {
|
|
|
2283
4838
|
const safeFilename = suggestedFilename.trim();
|
|
2284
4839
|
const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
|
|
2285
4840
|
const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
|
|
2286
|
-
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !==
|
|
4841
|
+
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path9.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
|
|
2287
4842
|
throw new Error(`Invalid download filename: "${suggestedFilename}"`);
|
|
2288
4843
|
}
|
|
2289
4844
|
return safeFilename;
|
|
2290
4845
|
}
|
|
2291
|
-
async function
|
|
4846
|
+
async function captureScreenshot2(taker, filePath) {
|
|
2292
4847
|
try {
|
|
2293
4848
|
await taker.screenshot({ path: filePath, fullPage: true });
|
|
2294
4849
|
} catch (error) {
|
|
@@ -2463,7 +5018,7 @@ var STEP_HANDLERS = {
|
|
|
2463
5018
|
if (!("setInputFiles" in h.step)) unknownStep();
|
|
2464
5019
|
const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
|
|
2465
5020
|
const rawFiles = h.step.setInputFiles.files;
|
|
2466
|
-
const resolveFile = (f) =>
|
|
5021
|
+
const resolveFile = (f) => import_node_path9.default.isAbsolute(f) ? f : import_node_path9.default.join(h.context.configDir, f);
|
|
2467
5022
|
const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
|
|
2468
5023
|
await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
|
|
2469
5024
|
h.policy.ensureLocationAllowed(h.driver);
|
|
@@ -2500,7 +5055,7 @@ var STEP_HANDLERS = {
|
|
|
2500
5055
|
redactedFillSteps: subRedacted,
|
|
2501
5056
|
randomVars
|
|
2502
5057
|
} = interpolateHunt(subHunt, process.env, h.context.randomVars);
|
|
2503
|
-
const subTargetType = h.driver.capabilities.has("navigate") ? "web" : "macos";
|
|
5058
|
+
const subTargetType = h.context.targetType ?? (h.driver.capabilities.has("navigate") ? "web" : "macos");
|
|
2504
5059
|
assertStepsSupportedByTarget(interpolatedSubHunt.steps, subTargetType);
|
|
2505
5060
|
assertHuntAssertionsSupportedByTarget(interpolatedSubHunt.assertions, subTargetType);
|
|
2506
5061
|
h.policy.assertWithinMaxSteps(interpolatedSubHunt.steps.length, huntName);
|
|
@@ -2825,15 +5380,15 @@ var STEP_HANDLERS = {
|
|
|
2825
5380
|
if (!responseFile) {
|
|
2826
5381
|
throw new Error("mock.response must include either body or file");
|
|
2827
5382
|
}
|
|
2828
|
-
const candidateFilePath =
|
|
2829
|
-
const resolvedConfigDir =
|
|
2830
|
-
const resolvedFilePath =
|
|
2831
|
-
const relativePath =
|
|
2832
|
-
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${
|
|
5383
|
+
const candidateFilePath = import_node_path9.default.isAbsolute(responseFile) ? responseFile : import_node_path9.default.join(h.context.configDir, responseFile);
|
|
5384
|
+
const resolvedConfigDir = import_node_path9.default.resolve(h.context.configDir);
|
|
5385
|
+
const resolvedFilePath = import_node_path9.default.resolve(candidateFilePath);
|
|
5386
|
+
const relativePath = import_node_path9.default.relative(resolvedConfigDir, resolvedFilePath);
|
|
5387
|
+
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path9.default.sep}`) && !import_node_path9.default.isAbsolute(relativePath);
|
|
2833
5388
|
if (!isWithinConfigDir) {
|
|
2834
5389
|
throw new Error("mock.response.file must resolve within config directory");
|
|
2835
5390
|
}
|
|
2836
|
-
responseBody = await
|
|
5391
|
+
responseBody = await import_node_fs9.default.promises.readFile(resolvedFilePath, "utf-8");
|
|
2837
5392
|
}
|
|
2838
5393
|
const contentType = mock.response.contentType ?? "application/json";
|
|
2839
5394
|
const status = mock.response.status;
|
|
@@ -2896,8 +5451,8 @@ var STEP_HANDLERS = {
|
|
|
2896
5451
|
capabilities: ["evaluate"],
|
|
2897
5452
|
run: async (h) => {
|
|
2898
5453
|
if (!("runScript" in h.step)) unknownStep();
|
|
2899
|
-
const filePath =
|
|
2900
|
-
const fileContents =
|
|
5454
|
+
const filePath = import_node_path9.default.isAbsolute(h.step.runScript.file) ? h.step.runScript.file : import_node_path9.default.join(h.context.configDir, h.step.runScript.file);
|
|
5455
|
+
const fileContents = import_node_fs9.default.readFileSync(filePath, "utf-8");
|
|
2901
5456
|
await h.driver.evaluate(fileContents);
|
|
2902
5457
|
return {
|
|
2903
5458
|
kind: "result",
|
|
@@ -2918,19 +5473,19 @@ var STEP_HANDLERS = {
|
|
|
2918
5473
|
const name = h.step.assertScreenshot.name;
|
|
2919
5474
|
const threshold = h.step.assertScreenshot.threshold ?? 0.1;
|
|
2920
5475
|
const baselineDir = ensureBaselineDir2(h.context.configDir);
|
|
2921
|
-
const baselinePath =
|
|
2922
|
-
const currentScreenshotPath =
|
|
2923
|
-
|
|
5476
|
+
const baselinePath = import_node_path9.default.join(baselineDir, `${name}.png`);
|
|
5477
|
+
const currentScreenshotPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
|
|
5478
|
+
import_node_fs9.default.mkdirSync(import_node_path9.default.dirname(currentScreenshotPath), { recursive: true });
|
|
2924
5479
|
await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
|
|
2925
|
-
h.screenshots.push(
|
|
2926
|
-
if (!
|
|
2927
|
-
|
|
5480
|
+
h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-current.png`));
|
|
5481
|
+
if (!import_node_fs9.default.existsSync(baselinePath)) {
|
|
5482
|
+
import_node_fs9.default.copyFileSync(currentScreenshotPath, baselinePath);
|
|
2928
5483
|
return {
|
|
2929
5484
|
kind: "result",
|
|
2930
5485
|
result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
|
|
2931
5486
|
};
|
|
2932
5487
|
}
|
|
2933
|
-
const diffPath =
|
|
5488
|
+
const diffPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
|
|
2934
5489
|
const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
|
|
2935
5490
|
if (comparison.match) {
|
|
2936
5491
|
return {
|
|
@@ -2943,12 +5498,54 @@ var STEP_HANDLERS = {
|
|
|
2943
5498
|
}
|
|
2944
5499
|
};
|
|
2945
5500
|
}
|
|
2946
|
-
h.screenshots.push(
|
|
5501
|
+
h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-diff.png`));
|
|
2947
5502
|
throw new Error(
|
|
2948
5503
|
`Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
|
|
2949
5504
|
);
|
|
2950
5505
|
}
|
|
2951
5506
|
},
|
|
5507
|
+
assertWithAI: {
|
|
5508
|
+
capabilities: ["screenshot"],
|
|
5509
|
+
run: async (h) => {
|
|
5510
|
+
if (!("assertWithAI" in h.step)) unknownStep();
|
|
5511
|
+
const assertion = h.step.assertWithAI;
|
|
5512
|
+
const resolve = h.context.resolveAiConfig ?? tryResolveAiConfig;
|
|
5513
|
+
const aiConfig = resolve();
|
|
5514
|
+
if (!aiConfig) {
|
|
5515
|
+
return {
|
|
5516
|
+
kind: "result",
|
|
5517
|
+
result: {
|
|
5518
|
+
type: "assertWithAI",
|
|
5519
|
+
status: "warn",
|
|
5520
|
+
durationMs: Date.now() - h.stepStart,
|
|
5521
|
+
value: `skipped: no AI provider configured (set PROWL_AI_KEY to enable) \u2014 "${assertion}"`
|
|
5522
|
+
}
|
|
5523
|
+
};
|
|
5524
|
+
}
|
|
5525
|
+
const fileName = `assertWithAI_step_${h.index + 1}.png`;
|
|
5526
|
+
const relative = await h.addScreenshot(fileName);
|
|
5527
|
+
const screenshotFullPath = import_node_path9.default.join(h.context.runDir, relative);
|
|
5528
|
+
const imageBase64 = import_node_fs9.default.readFileSync(screenshotFullPath).toString("base64");
|
|
5529
|
+
const assertVision = h.context.assertVision ?? assertWithAiVision;
|
|
5530
|
+
const verdict = await assertVision(
|
|
5531
|
+
{ imageBase64, mediaType: "image/png", assertion },
|
|
5532
|
+
aiConfig
|
|
5533
|
+
);
|
|
5534
|
+
if (verdict.pass) {
|
|
5535
|
+
return {
|
|
5536
|
+
kind: "result",
|
|
5537
|
+
result: {
|
|
5538
|
+
type: "assertWithAI",
|
|
5539
|
+
status: "pass",
|
|
5540
|
+
durationMs: Date.now() - h.stepStart,
|
|
5541
|
+
value: verdict.reason,
|
|
5542
|
+
screenshot: relative
|
|
5543
|
+
}
|
|
5544
|
+
};
|
|
5545
|
+
}
|
|
5546
|
+
throw new Error(`AI assertion failed: ${verdict.reason}`);
|
|
5547
|
+
}
|
|
5548
|
+
},
|
|
2952
5549
|
copyText: {
|
|
2953
5550
|
capabilities: ["query"],
|
|
2954
5551
|
run: async (h) => {
|
|
@@ -2985,7 +5582,7 @@ var STEP_HANDLERS = {
|
|
|
2985
5582
|
`Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
|
|
2986
5583
|
);
|
|
2987
5584
|
}
|
|
2988
|
-
const savePath =
|
|
5585
|
+
const savePath = import_node_path9.default.join(h.context.runDir, suggestedFilename);
|
|
2989
5586
|
await download.saveAs(savePath);
|
|
2990
5587
|
return {
|
|
2991
5588
|
kind: "result",
|
|
@@ -3014,8 +5611,8 @@ async function executeSteps(context) {
|
|
|
3014
5611
|
maxSteps: context.maxSteps,
|
|
3015
5612
|
selfHealing: context.selfHealing
|
|
3016
5613
|
});
|
|
3017
|
-
const screenshotsDir =
|
|
3018
|
-
|
|
5614
|
+
const screenshotsDir = import_node_path9.default.join(context.runDir, "screenshots");
|
|
5615
|
+
import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
|
|
3019
5616
|
const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
|
|
3020
5617
|
policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
|
|
3021
5618
|
const results = [];
|
|
@@ -3024,8 +5621,8 @@ async function executeSteps(context) {
|
|
|
3024
5621
|
context.runStartedAtMs = runStartedAtMs;
|
|
3025
5622
|
const addScreenshot = async (fileName) => {
|
|
3026
5623
|
const fullPath = screenshotPath(screenshotsDir, fileName);
|
|
3027
|
-
await
|
|
3028
|
-
const relative =
|
|
5624
|
+
await captureScreenshot2(driver, fullPath);
|
|
5625
|
+
const relative = import_node_path9.default.join("screenshots", fileName);
|
|
3029
5626
|
screenshots.push(relative);
|
|
3030
5627
|
return relative;
|
|
3031
5628
|
};
|
|
@@ -3113,12 +5710,12 @@ async function executeSteps(context) {
|
|
|
3113
5710
|
return { results, screenshots, failed: false };
|
|
3114
5711
|
}
|
|
3115
5712
|
async function captureFinalScreenshot(page, runDir) {
|
|
3116
|
-
const screenshotsDir =
|
|
3117
|
-
|
|
5713
|
+
const screenshotsDir = import_node_path9.default.join(runDir, "screenshots");
|
|
5714
|
+
import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
|
|
3118
5715
|
const fileName = "final.png";
|
|
3119
5716
|
const filePath = screenshotPath(screenshotsDir, fileName);
|
|
3120
|
-
await
|
|
3121
|
-
return
|
|
5717
|
+
await captureScreenshot2(page, filePath);
|
|
5718
|
+
return import_node_path9.default.join("screenshots", fileName);
|
|
3122
5719
|
}
|
|
3123
5720
|
|
|
3124
5721
|
// src/runner/assertions.ts
|
|
@@ -3278,18 +5875,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
|
|
|
3278
5875
|
}
|
|
3279
5876
|
|
|
3280
5877
|
// src/reporter/result.ts
|
|
3281
|
-
var
|
|
3282
|
-
var
|
|
5878
|
+
var import_node_fs10 = __toESM(require("fs"), 1);
|
|
5879
|
+
var import_node_path10 = __toESM(require("path"), 1);
|
|
3283
5880
|
function writeResult(runDir, result) {
|
|
3284
5881
|
const fileName = "result.json";
|
|
3285
|
-
const fullPath =
|
|
3286
|
-
|
|
5882
|
+
const fullPath = import_node_path10.default.join(runDir, fileName);
|
|
5883
|
+
import_node_fs10.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
|
|
3287
5884
|
return fileName;
|
|
3288
5885
|
}
|
|
3289
5886
|
|
|
3290
5887
|
// src/reporter/summary.ts
|
|
3291
|
-
var
|
|
3292
|
-
var
|
|
5888
|
+
var import_node_fs11 = __toESM(require("fs"), 1);
|
|
5889
|
+
var import_node_path11 = __toESM(require("path"), 1);
|
|
3293
5890
|
function escapeMd(text) {
|
|
3294
5891
|
return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
|
|
3295
5892
|
}
|
|
@@ -3367,15 +5964,15 @@ function writeSummary(runDir, result) {
|
|
|
3367
5964
|
}
|
|
3368
5965
|
}
|
|
3369
5966
|
const fileName = "summary.md";
|
|
3370
|
-
const fullPath =
|
|
3371
|
-
|
|
5967
|
+
const fullPath = import_node_path11.default.join(runDir, fileName);
|
|
5968
|
+
import_node_fs11.default.writeFileSync(fullPath, `${lines.join("\n")}
|
|
3372
5969
|
`);
|
|
3373
5970
|
return fileName;
|
|
3374
5971
|
}
|
|
3375
5972
|
|
|
3376
5973
|
// src/reporter/junit.ts
|
|
3377
|
-
var
|
|
3378
|
-
var
|
|
5974
|
+
var import_node_fs12 = __toESM(require("fs"), 1);
|
|
5975
|
+
var import_node_path12 = __toESM(require("path"), 1);
|
|
3379
5976
|
function escapeXml(text) {
|
|
3380
5977
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3381
5978
|
}
|
|
@@ -3419,8 +6016,8 @@ function writeJunit(runDir, result) {
|
|
|
3419
6016
|
lines.push(" </testsuite>");
|
|
3420
6017
|
lines.push("</testsuites>");
|
|
3421
6018
|
const fileName = "junit.xml";
|
|
3422
|
-
const fullPath =
|
|
3423
|
-
|
|
6019
|
+
const fullPath = import_node_path12.default.join(runDir, fileName);
|
|
6020
|
+
import_node_fs12.default.writeFileSync(fullPath, `${lines.join("\n")}
|
|
3424
6021
|
`);
|
|
3425
6022
|
return fileName;
|
|
3426
6023
|
}
|
|
@@ -3454,15 +6051,15 @@ function timestamp(prefix) {
|
|
|
3454
6051
|
}
|
|
3455
6052
|
|
|
3456
6053
|
// src/runner/history.ts
|
|
3457
|
-
var
|
|
3458
|
-
var
|
|
6054
|
+
var import_node_fs13 = __toESM(require("fs"), 1);
|
|
6055
|
+
var import_node_path13 = __toESM(require("path"), 1);
|
|
3459
6056
|
var HISTORY_FILE = "history.json";
|
|
3460
6057
|
var LOCK_FILE_SUFFIX = ".lock";
|
|
3461
6058
|
var LOCK_RETRY_MS = 10;
|
|
3462
6059
|
var LOCK_TIMEOUT_MS = 5e3;
|
|
3463
6060
|
var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
3464
6061
|
function historyPath(configDir) {
|
|
3465
|
-
return
|
|
6062
|
+
return import_node_path13.default.join(configDir, HISTORY_FILE);
|
|
3466
6063
|
}
|
|
3467
6064
|
function isHistoryEntry(value) {
|
|
3468
6065
|
if (!value || typeof value !== "object") {
|
|
@@ -3473,11 +6070,11 @@ function isHistoryEntry(value) {
|
|
|
3473
6070
|
}
|
|
3474
6071
|
function readHistory(configDir) {
|
|
3475
6072
|
const filePath = historyPath(configDir);
|
|
3476
|
-
if (!
|
|
6073
|
+
if (!import_node_fs13.default.existsSync(filePath)) {
|
|
3477
6074
|
return { entries: [] };
|
|
3478
6075
|
}
|
|
3479
6076
|
try {
|
|
3480
|
-
const raw =
|
|
6077
|
+
const raw = import_node_fs13.default.readFileSync(filePath, "utf-8");
|
|
3481
6078
|
const parsed = JSON.parse(raw);
|
|
3482
6079
|
if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
|
|
3483
6080
|
const validatedEntries = parsed.entries.filter(isHistoryEntry);
|
|
@@ -3516,12 +6113,12 @@ function sleepSync(ms) {
|
|
|
3516
6113
|
function withHistoryLock(configDir, fn) {
|
|
3517
6114
|
const filePath = historyPath(configDir);
|
|
3518
6115
|
const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
|
|
3519
|
-
|
|
6116
|
+
import_node_fs13.default.mkdirSync(import_node_path13.default.dirname(filePath), { recursive: true });
|
|
3520
6117
|
const startedAt = Date.now();
|
|
3521
6118
|
while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
|
|
3522
6119
|
let fd;
|
|
3523
6120
|
try {
|
|
3524
|
-
fd =
|
|
6121
|
+
fd = import_node_fs13.default.openSync(lockPath, "wx");
|
|
3525
6122
|
} catch (error) {
|
|
3526
6123
|
if (error.code === "EEXIST") {
|
|
3527
6124
|
sleepSync(LOCK_RETRY_MS);
|
|
@@ -3533,10 +6130,10 @@ function withHistoryLock(configDir, fn) {
|
|
|
3533
6130
|
return fn();
|
|
3534
6131
|
} finally {
|
|
3535
6132
|
try {
|
|
3536
|
-
|
|
6133
|
+
import_node_fs13.default.closeSync(fd);
|
|
3537
6134
|
} catch {
|
|
3538
6135
|
}
|
|
3539
|
-
|
|
6136
|
+
import_node_fs13.default.rmSync(lockPath, { force: true });
|
|
3540
6137
|
}
|
|
3541
6138
|
}
|
|
3542
6139
|
throw new Error(
|
|
@@ -3549,9 +6146,9 @@ function appendEntry(configDir, entry, maxRuns) {
|
|
|
3549
6146
|
const current = readHistory(configDir);
|
|
3550
6147
|
const next = pruneEntries([...current.entries, entry], maxRuns);
|
|
3551
6148
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
3552
|
-
|
|
6149
|
+
import_node_fs13.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
|
|
3553
6150
|
`);
|
|
3554
|
-
|
|
6151
|
+
import_node_fs13.default.renameSync(tempPath, filePath);
|
|
3555
6152
|
});
|
|
3556
6153
|
}
|
|
3557
6154
|
|
|
@@ -3564,11 +6161,11 @@ function parseViewportFlag(value) {
|
|
|
3564
6161
|
return value;
|
|
3565
6162
|
}
|
|
3566
6163
|
function resolvePath(configDir, inputPath) {
|
|
3567
|
-
if (
|
|
6164
|
+
if (import_node_path14.default.isAbsolute(inputPath)) {
|
|
3568
6165
|
return inputPath;
|
|
3569
6166
|
}
|
|
3570
|
-
const projectRoot =
|
|
3571
|
-
return
|
|
6167
|
+
const projectRoot = import_node_path14.default.dirname(configDir);
|
|
6168
|
+
return import_node_path14.default.join(projectRoot, inputPath);
|
|
3572
6169
|
}
|
|
3573
6170
|
function buildRunResult(options) {
|
|
3574
6171
|
return {
|
|
@@ -3587,12 +6184,12 @@ function buildRunResult(options) {
|
|
|
3587
6184
|
}
|
|
3588
6185
|
function writeConsoleLog(runDir, entries) {
|
|
3589
6186
|
const fileName = "console.log";
|
|
3590
|
-
const filePath =
|
|
6187
|
+
const filePath = import_node_path14.default.join(runDir, fileName);
|
|
3591
6188
|
const lines = entries.map((entry) => {
|
|
3592
6189
|
const location = entry.location ? ` (${entry.location})` : "";
|
|
3593
6190
|
return `[${entry.type}] ${entry.text}${location}`;
|
|
3594
6191
|
});
|
|
3595
|
-
|
|
6192
|
+
import_node_fs14.default.writeFileSync(filePath, `${lines.join("\n")}
|
|
3596
6193
|
`);
|
|
3597
6194
|
return fileName;
|
|
3598
6195
|
}
|
|
@@ -3600,8 +6197,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
3600
6197
|
const headless = options.headed ? false : config.browser.headless;
|
|
3601
6198
|
const slowMo = options.slowMo ?? config.browser.slowMo;
|
|
3602
6199
|
const maxSteps = config.guardrails.maxSteps;
|
|
3603
|
-
const runDir =
|
|
3604
|
-
|
|
6200
|
+
const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
|
|
6201
|
+
import_node_fs14.default.mkdirSync(runDir, { recursive: true });
|
|
3605
6202
|
const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
|
|
3606
6203
|
const engine = options.browser ?? config.browser.engine;
|
|
3607
6204
|
const channel = options.channel ?? config.browser.channel;
|
|
@@ -3718,7 +6315,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
3718
6315
|
}
|
|
3719
6316
|
return { result, runDir, steps: interpolatedHunt.steps };
|
|
3720
6317
|
}
|
|
3721
|
-
function
|
|
6318
|
+
function delay3(ms) {
|
|
3722
6319
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3723
6320
|
}
|
|
3724
6321
|
async function runHunt(options) {
|
|
@@ -3726,6 +6323,12 @@ async function runHunt(options) {
|
|
|
3726
6323
|
if (config.target.type === "macos") {
|
|
3727
6324
|
return runMacHunt(options, config, configDir, config.target);
|
|
3728
6325
|
}
|
|
6326
|
+
if (config.target.type === "android") {
|
|
6327
|
+
return runAndroidHunt(options, config, configDir, config.target);
|
|
6328
|
+
}
|
|
6329
|
+
if (config.target.type === "ios") {
|
|
6330
|
+
return runIosHunt(options, config, configDir, config.target);
|
|
6331
|
+
}
|
|
3729
6332
|
const hunt = loadHunt(options.huntName, configDir);
|
|
3730
6333
|
const {
|
|
3731
6334
|
hunt: interpolatedHunt,
|
|
@@ -3747,7 +6350,7 @@ async function runHunt(options) {
|
|
|
3747
6350
|
let lastResult;
|
|
3748
6351
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3749
6352
|
if (attempt > 0 && retryDelay > 0) {
|
|
3750
|
-
await
|
|
6353
|
+
await delay3(retryDelay);
|
|
3751
6354
|
}
|
|
3752
6355
|
lastResult = await executeHuntAttempt(
|
|
3753
6356
|
options,
|
|
@@ -3776,19 +6379,17 @@ async function runHunt(options) {
|
|
|
3776
6379
|
}
|
|
3777
6380
|
return lastResult;
|
|
3778
6381
|
}
|
|
3779
|
-
async function
|
|
6382
|
+
async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
|
|
3780
6383
|
const maxSteps = config.guardrails.maxSteps;
|
|
3781
|
-
const runDir =
|
|
3782
|
-
|
|
3783
|
-
const session = await
|
|
3784
|
-
app: target.app,
|
|
3785
|
-
timeoutMs: config.browser.timeout,
|
|
3786
|
-
clientFactory: options.macClientFactory
|
|
3787
|
-
});
|
|
6384
|
+
const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
|
|
6385
|
+
import_node_fs14.default.mkdirSync(runDir, { recursive: true });
|
|
6386
|
+
const session = await native.launchSession();
|
|
3788
6387
|
let result;
|
|
3789
6388
|
try {
|
|
3790
|
-
const
|
|
3791
|
-
const
|
|
6389
|
+
const driver = native.sessionDriver(session);
|
|
6390
|
+
const appIdentity = native.sessionAppIdentity(session);
|
|
6391
|
+
const targetLabel = `${native.targetType}:${appIdentity}`;
|
|
6392
|
+
const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
|
|
3792
6393
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3793
6394
|
const startTime = Date.now();
|
|
3794
6395
|
let stepResults = [];
|
|
@@ -3796,7 +6397,8 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
|
|
|
3796
6397
|
let stepFailed = false;
|
|
3797
6398
|
try {
|
|
3798
6399
|
const stepExecution = await executeSteps({
|
|
3799
|
-
driver
|
|
6400
|
+
driver,
|
|
6401
|
+
targetType: native.targetType,
|
|
3800
6402
|
steps: interpolatedHunt.steps,
|
|
3801
6403
|
targetUrl: targetLabel,
|
|
3802
6404
|
runDir,
|
|
@@ -3823,7 +6425,7 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
|
|
|
3823
6425
|
}
|
|
3824
6426
|
let finalScreenshot;
|
|
3825
6427
|
try {
|
|
3826
|
-
finalScreenshot = await captureFinalScreenshot(
|
|
6428
|
+
finalScreenshot = await captureFinalScreenshot(driver, runDir);
|
|
3827
6429
|
} catch {
|
|
3828
6430
|
finalScreenshot = void 0;
|
|
3829
6431
|
}
|
|
@@ -3844,16 +6446,116 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
|
|
|
3844
6446
|
});
|
|
3845
6447
|
result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
|
|
3846
6448
|
} finally {
|
|
3847
|
-
await
|
|
6449
|
+
await native.closeSession(session);
|
|
3848
6450
|
}
|
|
3849
6451
|
return { result, runDir, steps: interpolatedHunt.steps };
|
|
3850
6452
|
}
|
|
6453
|
+
async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
6454
|
+
return executeNativeHuntAttempt(
|
|
6455
|
+
options,
|
|
6456
|
+
config,
|
|
6457
|
+
configDir,
|
|
6458
|
+
interpolatedHunt,
|
|
6459
|
+
redactedFillSteps,
|
|
6460
|
+
randomVars,
|
|
6461
|
+
allowedApps,
|
|
6462
|
+
{
|
|
6463
|
+
targetType: "macos",
|
|
6464
|
+
targetApp: target.app,
|
|
6465
|
+
launchSession: () => launchMacSession({
|
|
6466
|
+
app: target.app,
|
|
6467
|
+
timeoutMs: config.browser.timeout,
|
|
6468
|
+
clientFactory: options.macClientFactory
|
|
6469
|
+
}),
|
|
6470
|
+
closeSession: closeMacSession,
|
|
6471
|
+
sessionDriver: (session) => session.driver,
|
|
6472
|
+
sessionAppIdentity: (session) => session.bundleId
|
|
6473
|
+
}
|
|
6474
|
+
);
|
|
6475
|
+
}
|
|
3851
6476
|
async function runMacHunt(options, config, configDir, target) {
|
|
6477
|
+
return runNativeHunt(options, config, configDir, target, {
|
|
6478
|
+
targetType: "macos",
|
|
6479
|
+
assertAppAllowed: (allowedApps, nativeTarget) => assertTargetAppAllowed(allowedApps, nativeTarget.app),
|
|
6480
|
+
attempt: executeMacHuntAttempt
|
|
6481
|
+
});
|
|
6482
|
+
}
|
|
6483
|
+
async function executeAndroidHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
6484
|
+
const launch = options.androidSessionFactory ?? launchAndroidSession;
|
|
6485
|
+
return executeNativeHuntAttempt(
|
|
6486
|
+
options,
|
|
6487
|
+
config,
|
|
6488
|
+
configDir,
|
|
6489
|
+
interpolatedHunt,
|
|
6490
|
+
redactedFillSteps,
|
|
6491
|
+
randomVars,
|
|
6492
|
+
allowedApps,
|
|
6493
|
+
{
|
|
6494
|
+
targetType: "android",
|
|
6495
|
+
targetApp: target.app,
|
|
6496
|
+
launchSession: () => launch({
|
|
6497
|
+
app: target.app,
|
|
6498
|
+
deviceSerial: target.deviceSerial,
|
|
6499
|
+
coldStart: target.coldStart,
|
|
6500
|
+
timeoutMs: config.browser.timeout,
|
|
6501
|
+
allowedApps
|
|
6502
|
+
}),
|
|
6503
|
+
closeSession: closeAndroidSession,
|
|
6504
|
+
sessionDriver: (session) => session.driver,
|
|
6505
|
+
sessionAppIdentity: (session) => session.package
|
|
6506
|
+
}
|
|
6507
|
+
);
|
|
6508
|
+
}
|
|
6509
|
+
async function runAndroidHunt(options, config, configDir, target) {
|
|
6510
|
+
return runNativeHunt(options, config, configDir, target, {
|
|
6511
|
+
targetType: "android",
|
|
6512
|
+
assertAppAllowed: (allowedApps, nativeTarget) => {
|
|
6513
|
+
if (!nativeTarget.app.toLowerCase().endsWith(".apk")) {
|
|
6514
|
+
assertAndroidAppAllowed(allowedApps, nativeTarget.app);
|
|
6515
|
+
}
|
|
6516
|
+
},
|
|
6517
|
+
attempt: executeAndroidHuntAttempt
|
|
6518
|
+
});
|
|
6519
|
+
}
|
|
6520
|
+
async function executeIosHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
6521
|
+
const launch = options.iosSessionFactory ?? launchIosSession;
|
|
6522
|
+
return executeNativeHuntAttempt(
|
|
6523
|
+
options,
|
|
6524
|
+
config,
|
|
6525
|
+
configDir,
|
|
6526
|
+
interpolatedHunt,
|
|
6527
|
+
redactedFillSteps,
|
|
6528
|
+
randomVars,
|
|
6529
|
+
allowedApps,
|
|
6530
|
+
{
|
|
6531
|
+
targetType: "ios",
|
|
6532
|
+
targetApp: target.app,
|
|
6533
|
+
launchSession: () => launch({
|
|
6534
|
+
app: target.app,
|
|
6535
|
+
udid: target.udid,
|
|
6536
|
+
coldStart: target.coldStart,
|
|
6537
|
+
timeoutMs: config.browser.timeout,
|
|
6538
|
+
allowedApps
|
|
6539
|
+
}),
|
|
6540
|
+
closeSession: closeIosSession,
|
|
6541
|
+
sessionDriver: (session) => session.driver,
|
|
6542
|
+
sessionAppIdentity: (session) => session.bundleId
|
|
6543
|
+
}
|
|
6544
|
+
);
|
|
6545
|
+
}
|
|
6546
|
+
async function runIosHunt(options, config, configDir, target) {
|
|
6547
|
+
return runNativeHunt(options, config, configDir, target, {
|
|
6548
|
+
targetType: "ios",
|
|
6549
|
+
assertAppAllowed: (allowedApps, nativeTarget) => assertIosAppAllowed(allowedApps, nativeTarget.app),
|
|
6550
|
+
attempt: executeIosHuntAttempt
|
|
6551
|
+
});
|
|
6552
|
+
}
|
|
6553
|
+
async function runNativeHunt(options, config, configDir, target, native) {
|
|
3852
6554
|
const hunt = loadHunt(options.huntName, configDir);
|
|
3853
6555
|
const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
|
|
3854
|
-
assertStepsSupportedByTarget(interpolatedHunt.steps,
|
|
3855
|
-
assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions,
|
|
3856
|
-
|
|
6556
|
+
assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
|
|
6557
|
+
assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
|
|
6558
|
+
native.assertAppAllowed(config.guardrails.allowedApps, target);
|
|
3857
6559
|
const maxSteps = config.guardrails.maxSteps;
|
|
3858
6560
|
if (interpolatedHunt.steps.length > maxSteps) {
|
|
3859
6561
|
throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
|
|
@@ -3863,9 +6565,9 @@ async function runMacHunt(options, config, configDir, target) {
|
|
|
3863
6565
|
let lastResult;
|
|
3864
6566
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3865
6567
|
if (attempt > 0 && retryDelay > 0) {
|
|
3866
|
-
await
|
|
6568
|
+
await delay3(retryDelay);
|
|
3867
6569
|
}
|
|
3868
|
-
lastResult = await
|
|
6570
|
+
lastResult = await native.attempt(
|
|
3869
6571
|
options,
|
|
3870
6572
|
config,
|
|
3871
6573
|
configDir,
|
|
@@ -3893,7 +6595,7 @@ async function runMacHunt(options, config, configDir, target) {
|
|
|
3893
6595
|
}
|
|
3894
6596
|
function recordHistory(configDir, outcome, maxRuns) {
|
|
3895
6597
|
try {
|
|
3896
|
-
const relativeRunDir =
|
|
6598
|
+
const relativeRunDir = import_node_path14.default.relative(configDir, outcome.runDir);
|
|
3897
6599
|
appendEntry(
|
|
3898
6600
|
configDir,
|
|
3899
6601
|
{
|
|
@@ -3910,11 +6612,11 @@ function recordHistory(configDir, outcome, maxRuns) {
|
|
|
3910
6612
|
}
|
|
3911
6613
|
|
|
3912
6614
|
// src/runner/suite.ts
|
|
3913
|
-
var
|
|
6615
|
+
var import_node_path17 = __toESM(require("path"), 1);
|
|
3914
6616
|
|
|
3915
6617
|
// src/reporter/ci-summary.ts
|
|
3916
|
-
var
|
|
3917
|
-
var
|
|
6618
|
+
var import_node_fs15 = __toESM(require("fs"), 1);
|
|
6619
|
+
var import_node_path15 = __toESM(require("path"), 1);
|
|
3918
6620
|
var import_chalk = __toESM(require("chalk"), 1);
|
|
3919
6621
|
function countCiResults(results) {
|
|
3920
6622
|
return {
|
|
@@ -3944,9 +6646,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
|
|
|
3944
6646
|
...flaky.length > 0 ? { flaky } : {},
|
|
3945
6647
|
...clusters.length > 0 ? { clusters } : {}
|
|
3946
6648
|
};
|
|
3947
|
-
|
|
3948
|
-
const filePath =
|
|
3949
|
-
|
|
6649
|
+
import_node_fs15.default.mkdirSync(ciRunDir, { recursive: true });
|
|
6650
|
+
const filePath = import_node_path15.default.join(ciRunDir, "ci-result.json");
|
|
6651
|
+
import_node_fs15.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
|
|
3950
6652
|
return filePath;
|
|
3951
6653
|
}
|
|
3952
6654
|
|
|
@@ -4080,8 +6782,8 @@ function clusterFailures(failures) {
|
|
|
4080
6782
|
}
|
|
4081
6783
|
|
|
4082
6784
|
// src/backlog/index.ts
|
|
4083
|
-
var
|
|
4084
|
-
var
|
|
6785
|
+
var import_node_fs16 = __toESM(require("fs"), 1);
|
|
6786
|
+
var import_node_path16 = __toESM(require("path"), 1);
|
|
4085
6787
|
|
|
4086
6788
|
// src/backlog/parse.ts
|
|
4087
6789
|
var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
|
|
@@ -4171,7 +6873,7 @@ ${after}`;
|
|
|
4171
6873
|
// src/backlog/index.ts
|
|
4172
6874
|
function readFileOrEmpty(filePath) {
|
|
4173
6875
|
try {
|
|
4174
|
-
return
|
|
6876
|
+
return import_node_fs16.default.readFileSync(filePath, "utf-8");
|
|
4175
6877
|
} catch (error) {
|
|
4176
6878
|
const err = error;
|
|
4177
6879
|
if (err.code === "ENOENT") return "";
|
|
@@ -4187,7 +6889,7 @@ function buildFailure(hunt) {
|
|
|
4187
6889
|
if (!hunt.runDir) return failure;
|
|
4188
6890
|
let run;
|
|
4189
6891
|
try {
|
|
4190
|
-
const resultJson = readFileOrEmpty(
|
|
6892
|
+
const resultJson = readFileOrEmpty(import_node_path16.default.join(hunt.runDir, "result.json"));
|
|
4191
6893
|
if (!resultJson) return failure;
|
|
4192
6894
|
run = JSON.parse(resultJson);
|
|
4193
6895
|
} catch (error) {
|
|
@@ -4216,8 +6918,8 @@ function extractFailures(suiteResult) {
|
|
|
4216
6918
|
}
|
|
4217
6919
|
function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
4218
6920
|
const projectRoot = options.projectRoot ?? process.cwd();
|
|
4219
|
-
const backlogPath = options.backlogPath ??
|
|
4220
|
-
const resolvedPath = options.resolvedPath ??
|
|
6921
|
+
const backlogPath = options.backlogPath ?? import_node_path16.default.join(projectRoot, "docs", "backlog.md");
|
|
6922
|
+
const resolvedPath = options.resolvedPath ?? import_node_path16.default.join(projectRoot, "docs", "resolved.md");
|
|
4221
6923
|
const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
4222
6924
|
const summary = { created: [], regressions: [], skipped: [], backlogPath };
|
|
4223
6925
|
const failures = extractFailures(suiteResult);
|
|
@@ -4249,8 +6951,8 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
|
4249
6951
|
}
|
|
4250
6952
|
}
|
|
4251
6953
|
if (ticketsToAdd.length > 0) {
|
|
4252
|
-
|
|
4253
|
-
|
|
6954
|
+
import_node_fs16.default.mkdirSync(import_node_path16.default.dirname(backlogPath), { recursive: true });
|
|
6955
|
+
import_node_fs16.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
|
|
4254
6956
|
}
|
|
4255
6957
|
return summary;
|
|
4256
6958
|
}
|
|
@@ -4411,7 +7113,7 @@ async function runSuite(options = {}) {
|
|
|
4411
7113
|
const clusters = clusterFailures(
|
|
4412
7114
|
extractFailures({ result: { hunts: results }, resultPath: null })
|
|
4413
7115
|
).filter((cluster) => cluster.count > 1);
|
|
4414
|
-
const ciRunDir =
|
|
7116
|
+
const ciRunDir = import_node_path17.default.join(configDir, "runs", timestamp("ci"));
|
|
4415
7117
|
const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
|
|
4416
7118
|
const { passed, failed, skipped } = countCiResults(results);
|
|
4417
7119
|
return {
|
|
@@ -4546,6 +7248,401 @@ async function analyzePage(page) {
|
|
|
4546
7248
|
};
|
|
4547
7249
|
}
|
|
4548
7250
|
|
|
7251
|
+
// src/analyzer/mac.ts
|
|
7252
|
+
var INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
|
|
7253
|
+
"AXButton",
|
|
7254
|
+
"AXTextField",
|
|
7255
|
+
"AXSecureTextField",
|
|
7256
|
+
"AXTextArea",
|
|
7257
|
+
"AXCheckBox",
|
|
7258
|
+
"AXRadioButton",
|
|
7259
|
+
"AXPopUpButton",
|
|
7260
|
+
"AXMenuButton",
|
|
7261
|
+
"AXLink",
|
|
7262
|
+
"AXMenuItem",
|
|
7263
|
+
"AXComboBox",
|
|
7264
|
+
"AXSlider",
|
|
7265
|
+
"AXDisclosureTriangle"
|
|
7266
|
+
]);
|
|
7267
|
+
var DEFAULT_ANALYZE_TREE_DEPTH = 20;
|
|
7268
|
+
function str(value) {
|
|
7269
|
+
if (typeof value !== "string") {
|
|
7270
|
+
return void 0;
|
|
7271
|
+
}
|
|
7272
|
+
const trimmed = value.trim();
|
|
7273
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
7274
|
+
}
|
|
7275
|
+
function toNode(raw) {
|
|
7276
|
+
const node = raw ?? {};
|
|
7277
|
+
const children = Array.isArray(node.children) ? node.children.map(toNode) : void 0;
|
|
7278
|
+
return {
|
|
7279
|
+
role: str(node.role),
|
|
7280
|
+
title: str(node.title),
|
|
7281
|
+
description: str(node.description),
|
|
7282
|
+
value: str(node.value),
|
|
7283
|
+
identifier: str(node.identifier),
|
|
7284
|
+
enabled: typeof node.enabled === "boolean" ? node.enabled : void 0,
|
|
7285
|
+
...children ? { children } : {}
|
|
7286
|
+
};
|
|
7287
|
+
}
|
|
7288
|
+
function quote(value) {
|
|
7289
|
+
return `"${value}"`;
|
|
7290
|
+
}
|
|
7291
|
+
function rankMacSelectors(node) {
|
|
7292
|
+
const selectors = [];
|
|
7293
|
+
const identifier = node.identifier;
|
|
7294
|
+
const exactLabel = node.title ?? node.description;
|
|
7295
|
+
const name = node.title ?? node.description ?? node.value;
|
|
7296
|
+
if (identifier) {
|
|
7297
|
+
selectors.push(`id=${identifier}`);
|
|
7298
|
+
}
|
|
7299
|
+
if (exactLabel) {
|
|
7300
|
+
selectors.push(`label=${quote(exactLabel)}`);
|
|
7301
|
+
}
|
|
7302
|
+
if (node.role && name) {
|
|
7303
|
+
selectors.push(`role=${node.role}[name=${quote(name)}]`);
|
|
7304
|
+
}
|
|
7305
|
+
if (name) {
|
|
7306
|
+
selectors.push(`text=${quote(name)}`);
|
|
7307
|
+
}
|
|
7308
|
+
if (selectors.length === 0 && node.role) {
|
|
7309
|
+
selectors.push(`role=${node.role}`);
|
|
7310
|
+
}
|
|
7311
|
+
return selectors;
|
|
7312
|
+
}
|
|
7313
|
+
function toElement(node, source) {
|
|
7314
|
+
return {
|
|
7315
|
+
role: node.role ?? "?",
|
|
7316
|
+
...node.title ? { title: node.title } : {},
|
|
7317
|
+
...node.description ? { description: node.description } : {},
|
|
7318
|
+
...node.value ? { value: node.value } : {},
|
|
7319
|
+
...node.identifier ? { identifier: node.identifier } : {},
|
|
7320
|
+
...node.enabled !== void 0 ? { enabled: node.enabled } : {},
|
|
7321
|
+
source,
|
|
7322
|
+
selectors: rankMacSelectors(node)
|
|
7323
|
+
};
|
|
7324
|
+
}
|
|
7325
|
+
function collectInteractive(root) {
|
|
7326
|
+
const out = [];
|
|
7327
|
+
const visit = (node) => {
|
|
7328
|
+
if (node.role && INTERACTIVE_ROLES.has(node.role)) {
|
|
7329
|
+
out.push(toElement(node, "window"));
|
|
7330
|
+
}
|
|
7331
|
+
for (const child of node.children ?? []) {
|
|
7332
|
+
visit(child);
|
|
7333
|
+
}
|
|
7334
|
+
};
|
|
7335
|
+
visit(root);
|
|
7336
|
+
return out;
|
|
7337
|
+
}
|
|
7338
|
+
function toWindow(node) {
|
|
7339
|
+
const [best] = rankMacSelectors(node);
|
|
7340
|
+
return {
|
|
7341
|
+
...node.title ? { title: node.title } : {},
|
|
7342
|
+
...node.identifier ? { identifier: node.identifier } : {},
|
|
7343
|
+
selector: best ?? "role=AXWindow"
|
|
7344
|
+
};
|
|
7345
|
+
}
|
|
7346
|
+
async function analyzeMacApp(client, options) {
|
|
7347
|
+
const depth = options.treeDepth ?? DEFAULT_ANALYZE_TREE_DEPTH;
|
|
7348
|
+
const treeResult = await client.request("tree", { depth });
|
|
7349
|
+
const elements = collectInteractive(toNode(treeResult.tree));
|
|
7350
|
+
const windowsResult = await client.request("windows");
|
|
7351
|
+
const rawWindows = Array.isArray(windowsResult.windows) ? windowsResult.windows : [];
|
|
7352
|
+
const windows = rawWindows.map((raw) => toWindow(toNode(raw)));
|
|
7353
|
+
const menuItems = await readStatusMenu(client, options.menuTimeoutSeconds);
|
|
7354
|
+
return { app: options.app, elements, windows, menuItems };
|
|
7355
|
+
}
|
|
7356
|
+
async function readStatusMenu(client, menuTimeoutSeconds) {
|
|
7357
|
+
let statusItems;
|
|
7358
|
+
try {
|
|
7359
|
+
const status = await client.request("statusItems");
|
|
7360
|
+
statusItems = Array.isArray(status.items) ? status.items : [];
|
|
7361
|
+
} catch {
|
|
7362
|
+
return [];
|
|
7363
|
+
}
|
|
7364
|
+
if (statusItems.length === 0) {
|
|
7365
|
+
return [];
|
|
7366
|
+
}
|
|
7367
|
+
const params = menuTimeoutSeconds !== void 0 ? { timeout: menuTimeoutSeconds } : {};
|
|
7368
|
+
try {
|
|
7369
|
+
const menu = await client.request("openMenu", params);
|
|
7370
|
+
const rawItems = Array.isArray(menu.items) ? menu.items : [];
|
|
7371
|
+
return rawItems.map((raw) => toNode(raw)).filter((node) => node.role !== "AXMenuItem" || node.title || node.identifier || node.description).map((node) => toElement(node, "menu"));
|
|
7372
|
+
} catch {
|
|
7373
|
+
return [];
|
|
7374
|
+
} finally {
|
|
7375
|
+
await client.request("closeMenu").catch(() => void 0);
|
|
7376
|
+
}
|
|
7377
|
+
}
|
|
7378
|
+
|
|
7379
|
+
// src/analyzer/android.ts
|
|
7380
|
+
var ANDROID_INTERACTIVE_CLASSES = /* @__PURE__ */ new Set([
|
|
7381
|
+
"android.widget.Button",
|
|
7382
|
+
"android.widget.ImageButton",
|
|
7383
|
+
"android.widget.EditText",
|
|
7384
|
+
"android.widget.CheckBox",
|
|
7385
|
+
"android.widget.RadioButton",
|
|
7386
|
+
"android.widget.Switch",
|
|
7387
|
+
"android.widget.ToggleButton",
|
|
7388
|
+
"android.widget.Spinner",
|
|
7389
|
+
"android.widget.SeekBar",
|
|
7390
|
+
"android.widget.RatingBar",
|
|
7391
|
+
"android.widget.CompoundButton",
|
|
7392
|
+
"android.widget.AutoCompleteTextView",
|
|
7393
|
+
"android.widget.MultiAutoCompleteTextView",
|
|
7394
|
+
"android.widget.CheckedTextView",
|
|
7395
|
+
"androidx.appcompat.widget.SwitchCompat",
|
|
7396
|
+
"androidx.appcompat.widget.AppCompatButton",
|
|
7397
|
+
"androidx.appcompat.widget.AppCompatEditText"
|
|
7398
|
+
]);
|
|
7399
|
+
function str2(value) {
|
|
7400
|
+
if (value === void 0) {
|
|
7401
|
+
return void 0;
|
|
7402
|
+
}
|
|
7403
|
+
const trimmed = value.trim();
|
|
7404
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
7405
|
+
}
|
|
7406
|
+
function bool(value) {
|
|
7407
|
+
if (value === void 0) {
|
|
7408
|
+
return void 0;
|
|
7409
|
+
}
|
|
7410
|
+
return value === "true";
|
|
7411
|
+
}
|
|
7412
|
+
function toAndroidNode(element) {
|
|
7413
|
+
const a = element.attrs;
|
|
7414
|
+
return {
|
|
7415
|
+
className: str2(a["class"]),
|
|
7416
|
+
resourceId: str2(a["resource-id"]),
|
|
7417
|
+
contentDesc: str2(a["content-desc"]),
|
|
7418
|
+
text: str2(a.text),
|
|
7419
|
+
package: str2(a.package),
|
|
7420
|
+
clickable: bool(a.clickable),
|
|
7421
|
+
longClickable: bool(a["long-clickable"]),
|
|
7422
|
+
checkable: bool(a.checkable),
|
|
7423
|
+
checked: bool(a.checked),
|
|
7424
|
+
scrollable: bool(a.scrollable),
|
|
7425
|
+
focusable: bool(a.focusable),
|
|
7426
|
+
focused: bool(a.focused),
|
|
7427
|
+
enabled: bool(a.enabled),
|
|
7428
|
+
children: element.children.map(toAndroidNode)
|
|
7429
|
+
};
|
|
7430
|
+
}
|
|
7431
|
+
function parseAndroidHierarchy(xml) {
|
|
7432
|
+
const root = parseXml(xml);
|
|
7433
|
+
return root ? toAndroidNode(root) : null;
|
|
7434
|
+
}
|
|
7435
|
+
function isAndroidInteractive(node) {
|
|
7436
|
+
if (node.clickable || node.longClickable || node.checkable || node.scrollable) {
|
|
7437
|
+
return true;
|
|
7438
|
+
}
|
|
7439
|
+
return node.className !== void 0 && ANDROID_INTERACTIVE_CLASSES.has(node.className);
|
|
7440
|
+
}
|
|
7441
|
+
function rankAndroidSelectors(node) {
|
|
7442
|
+
return rankNativeSelectors({
|
|
7443
|
+
id: node.resourceId,
|
|
7444
|
+
label: node.contentDesc,
|
|
7445
|
+
role: node.className,
|
|
7446
|
+
name: node.text
|
|
7447
|
+
});
|
|
7448
|
+
}
|
|
7449
|
+
function androidNodeToNative(node) {
|
|
7450
|
+
return {
|
|
7451
|
+
...node.resourceId !== void 0 ? { id: node.resourceId } : {},
|
|
7452
|
+
...node.contentDesc !== void 0 ? { label: node.contentDesc } : {},
|
|
7453
|
+
...node.className !== void 0 ? { role: node.className } : {},
|
|
7454
|
+
textValues: node.text !== void 0 ? [node.text] : [],
|
|
7455
|
+
...node.focused !== void 0 ? { focused: node.focused } : {}
|
|
7456
|
+
};
|
|
7457
|
+
}
|
|
7458
|
+
function matchAndroidSelector(xml, selector, options = {}) {
|
|
7459
|
+
const parsedSelector = parseNativeSelector(selector);
|
|
7460
|
+
const root = parseAndroidHierarchy(xml);
|
|
7461
|
+
if (!root) {
|
|
7462
|
+
return [];
|
|
7463
|
+
}
|
|
7464
|
+
return matchNativeTree(
|
|
7465
|
+
ANDROID_MATCH_DIALECT,
|
|
7466
|
+
parsedSelector,
|
|
7467
|
+
root,
|
|
7468
|
+
androidNodeToNative,
|
|
7469
|
+
(node) => node.children,
|
|
7470
|
+
options
|
|
7471
|
+
);
|
|
7472
|
+
}
|
|
7473
|
+
function toElement2(node) {
|
|
7474
|
+
return {
|
|
7475
|
+
className: node.className ?? "?",
|
|
7476
|
+
...node.resourceId ? { resourceId: node.resourceId } : {},
|
|
7477
|
+
...node.contentDesc ? { contentDesc: node.contentDesc } : {},
|
|
7478
|
+
...node.text ? { text: node.text } : {},
|
|
7479
|
+
...node.clickable !== void 0 ? { clickable: node.clickable } : {},
|
|
7480
|
+
...node.checkable !== void 0 ? { checkable: node.checkable } : {},
|
|
7481
|
+
...node.scrollable !== void 0 ? { scrollable: node.scrollable } : {},
|
|
7482
|
+
...node.enabled !== void 0 ? { enabled: node.enabled } : {},
|
|
7483
|
+
selectors: rankAndroidSelectors(node)
|
|
7484
|
+
};
|
|
7485
|
+
}
|
|
7486
|
+
function collectInteractive2(root) {
|
|
7487
|
+
const out = [];
|
|
7488
|
+
const visit = (node) => {
|
|
7489
|
+
if (isAndroidInteractive(node)) {
|
|
7490
|
+
out.push(toElement2(node));
|
|
7491
|
+
}
|
|
7492
|
+
for (const child of node.children) {
|
|
7493
|
+
visit(child);
|
|
7494
|
+
}
|
|
7495
|
+
};
|
|
7496
|
+
visit(root);
|
|
7497
|
+
return out;
|
|
7498
|
+
}
|
|
7499
|
+
async function analyzeAndroidApp(client, options) {
|
|
7500
|
+
const xml = await client.source();
|
|
7501
|
+
const root = parseAndroidHierarchy(xml);
|
|
7502
|
+
const elements = root ? collectInteractive2(root) : [];
|
|
7503
|
+
return { app: options.app, elements };
|
|
7504
|
+
}
|
|
7505
|
+
|
|
7506
|
+
// src/analyzer/ios.ts
|
|
7507
|
+
var IOS_INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
|
|
7508
|
+
"XCUIElementTypeButton",
|
|
7509
|
+
"XCUIElementTypeCell",
|
|
7510
|
+
"XCUIElementTypeTextField",
|
|
7511
|
+
"XCUIElementTypeSecureTextField",
|
|
7512
|
+
"XCUIElementTypeSearchField",
|
|
7513
|
+
"XCUIElementTypeSwitch",
|
|
7514
|
+
"XCUIElementTypeToggle",
|
|
7515
|
+
"XCUIElementTypeLink",
|
|
7516
|
+
"XCUIElementTypeMenuItem",
|
|
7517
|
+
"XCUIElementTypeSlider",
|
|
7518
|
+
"XCUIElementTypeStepper",
|
|
7519
|
+
"XCUIElementTypeTextView",
|
|
7520
|
+
"XCUIElementTypePickerWheel",
|
|
7521
|
+
"XCUIElementTypeTab",
|
|
7522
|
+
"XCUIElementTypeSegmentedControl",
|
|
7523
|
+
"XCUIElementTypeCheckBox",
|
|
7524
|
+
"XCUIElementTypeRadioButton",
|
|
7525
|
+
"XCUIElementTypeKey"
|
|
7526
|
+
]);
|
|
7527
|
+
var IOS_WINDOW_TYPE = "XCUIElementTypeWindow";
|
|
7528
|
+
function str3(value) {
|
|
7529
|
+
if (value === void 0) {
|
|
7530
|
+
return void 0;
|
|
7531
|
+
}
|
|
7532
|
+
const trimmed = value.trim();
|
|
7533
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
7534
|
+
}
|
|
7535
|
+
function bool2(value) {
|
|
7536
|
+
if (value === void 0) {
|
|
7537
|
+
return void 0;
|
|
7538
|
+
}
|
|
7539
|
+
return value === "true" || value === "1";
|
|
7540
|
+
}
|
|
7541
|
+
function toIosNode(element) {
|
|
7542
|
+
const a = element.attrs;
|
|
7543
|
+
return {
|
|
7544
|
+
type: str3(a.type) ?? str3(element.tag),
|
|
7545
|
+
name: str3(a.name),
|
|
7546
|
+
label: str3(a.label),
|
|
7547
|
+
value: str3(a.value),
|
|
7548
|
+
enabled: bool2(a.enabled),
|
|
7549
|
+
visible: bool2(a.visible),
|
|
7550
|
+
children: element.children.map(toIosNode)
|
|
7551
|
+
};
|
|
7552
|
+
}
|
|
7553
|
+
function parseIosHierarchy(xml) {
|
|
7554
|
+
const root = parseXml(xml);
|
|
7555
|
+
return root ? toIosNode(root) : null;
|
|
7556
|
+
}
|
|
7557
|
+
function hasAccessibilityId(node) {
|
|
7558
|
+
return node.name !== void 0 && node.name !== node.label;
|
|
7559
|
+
}
|
|
7560
|
+
function rankIosSelectors(node) {
|
|
7561
|
+
return rankNativeSelectors({
|
|
7562
|
+
id: hasAccessibilityId(node) ? node.name : void 0,
|
|
7563
|
+
label: node.label,
|
|
7564
|
+
role: node.type ? shortIosType(node.type) : void 0,
|
|
7565
|
+
name: node.label ?? node.value
|
|
7566
|
+
});
|
|
7567
|
+
}
|
|
7568
|
+
function iosNodeToNative(node) {
|
|
7569
|
+
const textValues = [];
|
|
7570
|
+
if (node.label !== void 0) {
|
|
7571
|
+
textValues.push(node.label);
|
|
7572
|
+
}
|
|
7573
|
+
if (node.value !== void 0) {
|
|
7574
|
+
textValues.push(node.value);
|
|
7575
|
+
}
|
|
7576
|
+
return {
|
|
7577
|
+
...node.name !== void 0 ? { id: node.name } : {},
|
|
7578
|
+
...node.label !== void 0 ? { label: node.label } : {},
|
|
7579
|
+
...node.type !== void 0 ? { role: node.type } : {},
|
|
7580
|
+
textValues
|
|
7581
|
+
};
|
|
7582
|
+
}
|
|
7583
|
+
function matchIosSelector(xml, selector) {
|
|
7584
|
+
const parsedSelector = parseNativeSelector(selector);
|
|
7585
|
+
const root = parseIosHierarchy(xml);
|
|
7586
|
+
if (!root) {
|
|
7587
|
+
return [];
|
|
7588
|
+
}
|
|
7589
|
+
return matchNativeTree(
|
|
7590
|
+
IOS_MATCH_DIALECT,
|
|
7591
|
+
parsedSelector,
|
|
7592
|
+
root,
|
|
7593
|
+
iosNodeToNative,
|
|
7594
|
+
(node) => node.children
|
|
7595
|
+
);
|
|
7596
|
+
}
|
|
7597
|
+
function toElement3(node) {
|
|
7598
|
+
return {
|
|
7599
|
+
type: node.type ?? "?",
|
|
7600
|
+
...node.name ? { name: node.name } : {},
|
|
7601
|
+
...node.label ? { label: node.label } : {},
|
|
7602
|
+
...node.value ? { value: node.value } : {},
|
|
7603
|
+
...node.enabled !== void 0 ? { enabled: node.enabled } : {},
|
|
7604
|
+
...node.visible !== void 0 ? { visible: node.visible } : {},
|
|
7605
|
+
selectors: rankIosSelectors(node)
|
|
7606
|
+
};
|
|
7607
|
+
}
|
|
7608
|
+
function toWindow2(node) {
|
|
7609
|
+
const [best] = rankIosSelectors(node);
|
|
7610
|
+
return {
|
|
7611
|
+
...node.name ? { name: node.name } : {},
|
|
7612
|
+
...node.label ? { label: node.label } : {},
|
|
7613
|
+
selector: best ?? `role=${shortIosType(IOS_WINDOW_TYPE)}`
|
|
7614
|
+
};
|
|
7615
|
+
}
|
|
7616
|
+
function isIosInteractive(node) {
|
|
7617
|
+
return node.type !== void 0 && IOS_INTERACTIVE_TYPES.has(node.type);
|
|
7618
|
+
}
|
|
7619
|
+
function collect(root) {
|
|
7620
|
+
const elements = [];
|
|
7621
|
+
const windows = [];
|
|
7622
|
+
const visit = (node) => {
|
|
7623
|
+
if (node.type === IOS_WINDOW_TYPE) {
|
|
7624
|
+
windows.push(toWindow2(node));
|
|
7625
|
+
}
|
|
7626
|
+
if (isIosInteractive(node)) {
|
|
7627
|
+
elements.push(toElement3(node));
|
|
7628
|
+
}
|
|
7629
|
+
for (const child of node.children) {
|
|
7630
|
+
visit(child);
|
|
7631
|
+
}
|
|
7632
|
+
};
|
|
7633
|
+
visit(root);
|
|
7634
|
+
return { elements, windows };
|
|
7635
|
+
}
|
|
7636
|
+
async function analyzeIosApp(client, options) {
|
|
7637
|
+
const xml = await client.source();
|
|
7638
|
+
const root = parseIosHierarchy(xml);
|
|
7639
|
+
if (!root) {
|
|
7640
|
+
return { app: options.app, elements: [], windows: [] };
|
|
7641
|
+
}
|
|
7642
|
+
const { elements, windows } = collect(root);
|
|
7643
|
+
return { app: options.app, elements, windows };
|
|
7644
|
+
}
|
|
7645
|
+
|
|
4549
7646
|
// src/generator/index.ts
|
|
4550
7647
|
var import_yaml2 = __toESM(require("yaml"), 1);
|
|
4551
7648
|
|
|
@@ -4606,6 +7703,7 @@ var STEP_REFERENCE = `
|
|
|
4606
7703
|
|
|
4607
7704
|
### Visual Regression
|
|
4608
7705
|
- assertScreenshot: { name: "baseline-name", threshold: 0.1 }
|
|
7706
|
+
- assertWithAI: "The login form should show email and password fields" \u2014 AI checks the screenshot against the claim
|
|
4609
7707
|
|
|
4610
7708
|
### Control Flow
|
|
4611
7709
|
- if: { visible: ".banner", then: [steps...] }
|
|
@@ -4647,81 +7745,6 @@ function extractYamlFromResponse(response) {
|
|
|
4647
7745
|
return response.trim();
|
|
4648
7746
|
}
|
|
4649
7747
|
|
|
4650
|
-
// src/generator/ai.ts
|
|
4651
|
-
function resolveAiConfig() {
|
|
4652
|
-
const provider = process.env.PROWL_AI_PROVIDER ?? "anthropic";
|
|
4653
|
-
if (provider !== "anthropic" && provider !== "openai") {
|
|
4654
|
-
throw new Error(`Unsupported AI provider: ${provider}. Use "anthropic" or "openai".`);
|
|
4655
|
-
}
|
|
4656
|
-
const apiKey = process.env.PROWL_AI_KEY;
|
|
4657
|
-
if (!apiKey) {
|
|
4658
|
-
throw new Error(
|
|
4659
|
-
"PROWL_AI_KEY environment variable is required. Set it to your Anthropic or OpenAI API key."
|
|
4660
|
-
);
|
|
4661
|
-
}
|
|
4662
|
-
const defaultModel = provider === "anthropic" ? "claude-sonnet-4-5-20250929" : "gpt-4o";
|
|
4663
|
-
const model = process.env.PROWL_AI_MODEL ?? defaultModel;
|
|
4664
|
-
return { provider, model, apiKey };
|
|
4665
|
-
}
|
|
4666
|
-
async function generateWithAi(prompt, config) {
|
|
4667
|
-
if (config.provider === "anthropic") {
|
|
4668
|
-
return generateWithAnthropic(prompt, config);
|
|
4669
|
-
}
|
|
4670
|
-
return generateWithOpenAi(prompt, config);
|
|
4671
|
-
}
|
|
4672
|
-
async function generateWithAnthropic(prompt, config) {
|
|
4673
|
-
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
4674
|
-
method: "POST",
|
|
4675
|
-
headers: {
|
|
4676
|
-
"Content-Type": "application/json",
|
|
4677
|
-
"x-api-key": config.apiKey,
|
|
4678
|
-
"anthropic-version": "2023-06-01"
|
|
4679
|
-
},
|
|
4680
|
-
body: JSON.stringify({
|
|
4681
|
-
model: config.model,
|
|
4682
|
-
max_tokens: 4096,
|
|
4683
|
-
messages: [
|
|
4684
|
-
{ role: "user", content: prompt }
|
|
4685
|
-
]
|
|
4686
|
-
})
|
|
4687
|
-
});
|
|
4688
|
-
if (!response.ok) {
|
|
4689
|
-
const body = await response.text();
|
|
4690
|
-
throw new Error(`Anthropic API error (${response.status}): ${body}`);
|
|
4691
|
-
}
|
|
4692
|
-
const data = await response.json();
|
|
4693
|
-
const textBlock = data.content.find((c) => c.type === "text");
|
|
4694
|
-
if (!textBlock?.text) {
|
|
4695
|
-
throw new Error("Anthropic API returned no text content");
|
|
4696
|
-
}
|
|
4697
|
-
return textBlock.text;
|
|
4698
|
-
}
|
|
4699
|
-
async function generateWithOpenAi(prompt, config) {
|
|
4700
|
-
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
4701
|
-
method: "POST",
|
|
4702
|
-
headers: {
|
|
4703
|
-
"Content-Type": "application/json",
|
|
4704
|
-
"Authorization": `Bearer ${config.apiKey}`
|
|
4705
|
-
},
|
|
4706
|
-
body: JSON.stringify({
|
|
4707
|
-
model: config.model,
|
|
4708
|
-
messages: [
|
|
4709
|
-
{ role: "user", content: prompt }
|
|
4710
|
-
],
|
|
4711
|
-
max_tokens: 4096
|
|
4712
|
-
})
|
|
4713
|
-
});
|
|
4714
|
-
if (!response.ok) {
|
|
4715
|
-
const body = await response.text();
|
|
4716
|
-
throw new Error(`OpenAI API error (${response.status}): ${body}`);
|
|
4717
|
-
}
|
|
4718
|
-
const data = await response.json();
|
|
4719
|
-
if (!data.choices?.[0]?.message?.content) {
|
|
4720
|
-
throw new Error("OpenAI API returned no content");
|
|
4721
|
-
}
|
|
4722
|
-
return data.choices[0].message.content;
|
|
4723
|
-
}
|
|
4724
|
-
|
|
4725
7748
|
// src/generator/index.ts
|
|
4726
7749
|
function parseViewportFlag2(value) {
|
|
4727
7750
|
const match = /^(\d+)x(\d+)$/i.exec(value);
|
|
@@ -4766,41 +7789,137 @@ async function generateHunt(options) {
|
|
|
4766
7789
|
}
|
|
4767
7790
|
// Annotate the CommonJS export names for ESM import in node:
|
|
4768
7791
|
0 && (module.exports = {
|
|
7792
|
+
ANDROID_INTERACTIVE_CLASSES,
|
|
7793
|
+
ANDROID_KEYCODES,
|
|
7794
|
+
ANDROID_MATCH_DIALECT,
|
|
7795
|
+
DEFAULT_AGENT_REQUEST_TIMEOUT_MS,
|
|
7796
|
+
DEFAULT_ANALYZE_TREE_DEPTH,
|
|
4769
7797
|
DEFAULT_FLAKY_THRESHOLD,
|
|
4770
7798
|
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
7799
|
+
DEFAULT_SIMULATOR_LOCK_ROOT,
|
|
7800
|
+
DEFAULT_WDA_REQUEST_TIMEOUT_MS,
|
|
7801
|
+
INTERACTIVE_ROLES,
|
|
7802
|
+
IOS_INTERACTIVE_TYPES,
|
|
7803
|
+
IOS_MATCH_DIALECT,
|
|
7804
|
+
IOS_PRESS_KEYS,
|
|
7805
|
+
IOS_WINDOW_TYPE,
|
|
7806
|
+
MACOS_MATCH_DIALECT,
|
|
7807
|
+
NATIVE_ATTRIBUTE_MAP,
|
|
4771
7808
|
SpawnMacHelperClient,
|
|
7809
|
+
UIA2_REMOTE_PORT,
|
|
7810
|
+
Uia2HttpError,
|
|
7811
|
+
Uia2Transport,
|
|
7812
|
+
WDA_RUNNER_BUNDLE_ID,
|
|
7813
|
+
WDA_USE_PORT_ENV,
|
|
4772
7814
|
WEB_ONLY_STEP_TYPES,
|
|
7815
|
+
WdaHttpError,
|
|
7816
|
+
WdaTransport,
|
|
7817
|
+
analyzeAndroidApp,
|
|
7818
|
+
analyzeIosApp,
|
|
7819
|
+
analyzeMacApp,
|
|
4773
7820
|
analyzePage,
|
|
7821
|
+
androidAppAllowedIdentities,
|
|
7822
|
+
androidNodeToNative,
|
|
7823
|
+
androidQueryToLocator,
|
|
7824
|
+
assertAndroidAppAllowed,
|
|
7825
|
+
assertIosAppAllowed,
|
|
4774
7826
|
assertStepsSupportedByTarget,
|
|
4775
7827
|
assertTargetAppAllowed,
|
|
7828
|
+
bootedDevices,
|
|
7829
|
+
bootedSimulators,
|
|
4776
7830
|
buildHealCandidates,
|
|
7831
|
+
closeAndroidSession,
|
|
7832
|
+
closeIosSession,
|
|
4777
7833
|
closeMacSession,
|
|
4778
7834
|
clusterFailures,
|
|
4779
7835
|
computeFlakeScore,
|
|
4780
7836
|
configSchema,
|
|
7837
|
+
createAndroidDriver,
|
|
7838
|
+
createIosDriver,
|
|
4781
7839
|
createMacDriver,
|
|
7840
|
+
createUia2AgentClient,
|
|
7841
|
+
createUia2Session,
|
|
7842
|
+
createWdaAgentClient,
|
|
7843
|
+
createWdaSession,
|
|
7844
|
+
decodeXmlEntities,
|
|
7845
|
+
defaultAgentConnector,
|
|
7846
|
+
defaultIosAgentConnector,
|
|
7847
|
+
defaultWdaTestRunPreparer,
|
|
7848
|
+
escapePredicateArg,
|
|
7849
|
+
escapeUiSelectorArg,
|
|
7850
|
+
extractElementId,
|
|
4782
7851
|
extractFailures,
|
|
4783
7852
|
extractSelectorIntent,
|
|
7853
|
+
findFreePort,
|
|
4784
7854
|
generateHunt,
|
|
4785
7855
|
healSelector,
|
|
4786
7856
|
huntSchema,
|
|
7857
|
+
injectUsePortIntoXctestrun,
|
|
4787
7858
|
interpolateHunt,
|
|
7859
|
+
iosAppAllowedIdentities,
|
|
7860
|
+
iosNodeToNative,
|
|
7861
|
+
iosQueryToLocator,
|
|
7862
|
+
isAndroidInteractive,
|
|
7863
|
+
isIosInteractive,
|
|
7864
|
+
launchAndroidSession,
|
|
7865
|
+
launchIosSession,
|
|
4788
7866
|
launchMacSession,
|
|
7867
|
+
listDevices,
|
|
4789
7868
|
listHunts,
|
|
7869
|
+
listSimulators,
|
|
4790
7870
|
loadConfig,
|
|
4791
7871
|
loadHunt,
|
|
4792
7872
|
loadHuntMeta,
|
|
4793
7873
|
loadHuntTags,
|
|
4794
7874
|
macdriverBuildInstructions,
|
|
7875
|
+
matchAndroidSelector,
|
|
7876
|
+
matchIosSelector,
|
|
7877
|
+
matchNativeTree,
|
|
7878
|
+
nodeMatchesSelector,
|
|
7879
|
+
normalizeXcuiClassName,
|
|
7880
|
+
parseAaptPackage,
|
|
7881
|
+
parseAdbDevices,
|
|
7882
|
+
parseAndroidHierarchy,
|
|
7883
|
+
parseAndroidSelector,
|
|
7884
|
+
parseForwardPort,
|
|
7885
|
+
parseIosHierarchy,
|
|
7886
|
+
parseIosSelector,
|
|
4795
7887
|
parseMacSelector,
|
|
7888
|
+
parseNativeSelector,
|
|
7889
|
+
parseSimctlDevices,
|
|
7890
|
+
parseSnapshot,
|
|
7891
|
+
parseXcodeVersion,
|
|
7892
|
+
parseXml,
|
|
7893
|
+
qualifyResourceId,
|
|
7894
|
+
quoteSelectorValue,
|
|
7895
|
+
rankAndroidSelectors,
|
|
4796
7896
|
rankFlaky,
|
|
7897
|
+
rankIosSelectors,
|
|
7898
|
+
rankMacSelectors,
|
|
7899
|
+
rankNativeSelectors,
|
|
4797
7900
|
readHistory,
|
|
4798
7901
|
readHuntHistory,
|
|
7902
|
+
readIosBundleIdentifier,
|
|
7903
|
+
reserveSimulatorUdid,
|
|
7904
|
+
resolveAgentApks,
|
|
4799
7905
|
resolveHelperBinary,
|
|
7906
|
+
resolveWdaProject,
|
|
7907
|
+
resolveWdaTestRun,
|
|
4800
7908
|
runHunt,
|
|
4801
7909
|
runSuite,
|
|
7910
|
+
selectDeviceSerial,
|
|
7911
|
+
selectSimulatorUdid,
|
|
7912
|
+
shortIosType,
|
|
4802
7913
|
stepSchema,
|
|
7914
|
+
unquoteSelectorValue,
|
|
7915
|
+
unwrapAndroidTextSelector,
|
|
7916
|
+
unwrapIosTextSelector,
|
|
7917
|
+
unwrapNativeTextSelector,
|
|
4803
7918
|
updateBacklogFromSuite,
|
|
7919
|
+
waitForAgentReady,
|
|
7920
|
+
waitForWdaReady,
|
|
7921
|
+
wdaCacheDir,
|
|
7922
|
+
wdaTestRunArgs,
|
|
4804
7923
|
webOnlyReason
|
|
4805
7924
|
});
|
|
4806
7925
|
//# sourceMappingURL=lib.cjs.map
|