prowl-tools 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +10 -0
- package/README.md +277 -2
- package/dist/{chunk-ZEFVTKQT.js → chunk-2KD2XCTH.js} +2240 -213
- package/dist/chunk-2KD2XCTH.js.map +1 -0
- package/dist/{chunk-MBAIGNVO.js → chunk-ITOSUJCN.js} +39 -3
- package/dist/chunk-ITOSUJCN.js.map +1 -0
- package/dist/index.cjs +2411 -313
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +162 -67
- package/dist/index.js.map +1 -1
- package/dist/lib.cjs +2375 -244
- package/dist/lib.cjs.map +1 -1
- package/dist/lib.d.cts +742 -4
- package/dist/lib.d.ts +742 -4
- package/dist/lib.js +126 -2
- package/dist/{loader-XMTDB6OL.js → loader-FCXPARP7.js} +2 -2
- package/package.json +5 -1
- package/dist/chunk-MBAIGNVO.js.map +0 -1
- package/dist/chunk-ZEFVTKQT.js.map +0 -1
- /package/dist/{loader-XMTDB6OL.js.map → loader-FCXPARP7.js.map} +0 -0
package/dist/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,103 @@ var init_visual = __esm({
|
|
|
86
86
|
// src/index.ts
|
|
87
87
|
var src_exports = {};
|
|
88
88
|
__export(src_exports, {
|
|
89
|
+
ANDROID_KEYCODES: () => ANDROID_KEYCODES,
|
|
90
|
+
DEFAULT_AGENT_REQUEST_TIMEOUT_MS: () => DEFAULT_AGENT_REQUEST_TIMEOUT_MS,
|
|
91
|
+
DEFAULT_ANALYZE_TREE_DEPTH: () => DEFAULT_ANALYZE_TREE_DEPTH,
|
|
89
92
|
DEFAULT_FLAKY_THRESHOLD: () => DEFAULT_FLAKY_THRESHOLD,
|
|
90
93
|
DEFAULT_REQUEST_TIMEOUT_MS: () => DEFAULT_REQUEST_TIMEOUT_MS,
|
|
94
|
+
DEFAULT_SIMULATOR_LOCK_ROOT: () => DEFAULT_SIMULATOR_LOCK_ROOT,
|
|
95
|
+
DEFAULT_WDA_REQUEST_TIMEOUT_MS: () => DEFAULT_WDA_REQUEST_TIMEOUT_MS,
|
|
96
|
+
INTERACTIVE_ROLES: () => INTERACTIVE_ROLES,
|
|
97
|
+
IOS_PRESS_KEYS: () => IOS_PRESS_KEYS,
|
|
91
98
|
SpawnMacHelperClient: () => SpawnMacHelperClient,
|
|
99
|
+
UIA2_REMOTE_PORT: () => UIA2_REMOTE_PORT,
|
|
100
|
+
Uia2HttpError: () => Uia2HttpError,
|
|
101
|
+
Uia2Transport: () => Uia2Transport,
|
|
102
|
+
WDA_RUNNER_APP_NAME: () => WDA_RUNNER_APP_NAME,
|
|
103
|
+
WDA_RUNNER_BUNDLE_ID: () => WDA_RUNNER_BUNDLE_ID,
|
|
92
104
|
WEB_ONLY_STEP_TYPES: () => WEB_ONLY_STEP_TYPES,
|
|
105
|
+
WdaHttpError: () => WdaHttpError,
|
|
106
|
+
WdaTransport: () => WdaTransport,
|
|
107
|
+
analyzeMacApp: () => analyzeMacApp,
|
|
93
108
|
analyzePage: () => analyzePage,
|
|
109
|
+
androidAppAllowedIdentities: () => androidAppAllowedIdentities,
|
|
110
|
+
androidQueryToLocator: () => androidQueryToLocator,
|
|
111
|
+
assertAndroidAppAllowed: () => assertAndroidAppAllowed,
|
|
112
|
+
assertIosAppAllowed: () => assertIosAppAllowed,
|
|
94
113
|
assertStepsSupportedByTarget: () => assertStepsSupportedByTarget,
|
|
95
114
|
assertTargetAppAllowed: () => assertTargetAppAllowed,
|
|
115
|
+
bootedDevices: () => bootedDevices,
|
|
116
|
+
bootedSimulators: () => bootedSimulators,
|
|
96
117
|
buildHealCandidates: () => buildHealCandidates,
|
|
118
|
+
closeAndroidSession: () => closeAndroidSession,
|
|
119
|
+
closeIosSession: () => closeIosSession,
|
|
97
120
|
closeMacSession: () => closeMacSession,
|
|
98
121
|
clusterFailures: () => clusterFailures,
|
|
99
122
|
computeFlakeScore: () => computeFlakeScore,
|
|
100
123
|
configSchema: () => configSchema,
|
|
124
|
+
createAndroidDriver: () => createAndroidDriver,
|
|
125
|
+
createIosDriver: () => createIosDriver,
|
|
101
126
|
createMacDriver: () => createMacDriver,
|
|
127
|
+
createUia2AgentClient: () => createUia2AgentClient,
|
|
128
|
+
createUia2Session: () => createUia2Session,
|
|
129
|
+
createWdaAgentClient: () => createWdaAgentClient,
|
|
130
|
+
createWdaSession: () => createWdaSession,
|
|
131
|
+
defaultAgentConnector: () => defaultAgentConnector,
|
|
132
|
+
defaultIosAgentConnector: () => defaultIosAgentConnector,
|
|
133
|
+
escapePredicateArg: () => escapePredicateArg,
|
|
134
|
+
escapeUiSelectorArg: () => escapeUiSelectorArg,
|
|
135
|
+
extractElementId: () => extractElementId,
|
|
102
136
|
extractFailures: () => extractFailures,
|
|
103
137
|
extractSelectorIntent: () => extractSelectorIntent,
|
|
138
|
+
findFreePort: () => findFreePort,
|
|
104
139
|
generateHunt: () => generateHunt,
|
|
105
140
|
healSelector: () => healSelector,
|
|
106
141
|
huntSchema: () => huntSchema,
|
|
107
142
|
interpolateHunt: () => interpolateHunt,
|
|
143
|
+
iosAppAllowedIdentities: () => iosAppAllowedIdentities,
|
|
144
|
+
iosQueryToLocator: () => iosQueryToLocator,
|
|
145
|
+
launchAndroidSession: () => launchAndroidSession,
|
|
146
|
+
launchIosSession: () => launchIosSession,
|
|
108
147
|
launchMacSession: () => launchMacSession,
|
|
148
|
+
listDevices: () => listDevices,
|
|
109
149
|
listHunts: () => listHunts,
|
|
150
|
+
listSimulators: () => listSimulators,
|
|
110
151
|
loadConfig: () => loadConfig,
|
|
111
152
|
loadHunt: () => loadHunt,
|
|
112
153
|
loadHuntMeta: () => loadHuntMeta,
|
|
113
154
|
loadHuntTags: () => loadHuntTags,
|
|
114
155
|
macdriverBuildInstructions: () => macdriverBuildInstructions,
|
|
156
|
+
normalizeXcuiClassName: () => normalizeXcuiClassName,
|
|
157
|
+
parseAaptPackage: () => parseAaptPackage,
|
|
158
|
+
parseAdbDevices: () => parseAdbDevices,
|
|
159
|
+
parseAndroidSelector: () => parseAndroidSelector,
|
|
160
|
+
parseForwardPort: () => parseForwardPort,
|
|
161
|
+
parseIosSelector: () => parseIosSelector,
|
|
115
162
|
parseMacSelector: () => parseMacSelector,
|
|
163
|
+
parseSimctlDevices: () => parseSimctlDevices,
|
|
164
|
+
parseXcodeVersion: () => parseXcodeVersion,
|
|
116
165
|
rankFlaky: () => rankFlaky,
|
|
166
|
+
rankMacSelectors: () => rankMacSelectors,
|
|
117
167
|
readHistory: () => readHistory,
|
|
118
168
|
readHuntHistory: () => readHuntHistory,
|
|
169
|
+
readIosBundleIdentifier: () => readIosBundleIdentifier,
|
|
170
|
+
reserveSimulatorUdid: () => reserveSimulatorUdid,
|
|
171
|
+
resolveAgentApks: () => resolveAgentApks,
|
|
119
172
|
resolveHelperBinary: () => resolveHelperBinary,
|
|
173
|
+
resolveWdaProject: () => resolveWdaProject,
|
|
174
|
+
resolveWdaRunner: () => resolveWdaRunner,
|
|
120
175
|
runHunt: () => runHunt,
|
|
121
176
|
runSuite: () => runSuite,
|
|
177
|
+
selectDeviceSerial: () => selectDeviceSerial,
|
|
178
|
+
selectSimulatorUdid: () => selectSimulatorUdid,
|
|
122
179
|
stepSchema: () => stepSchema,
|
|
180
|
+
unwrapAndroidTextSelector: () => unwrapAndroidTextSelector,
|
|
181
|
+
unwrapIosTextSelector: () => unwrapIosTextSelector,
|
|
123
182
|
updateBacklogFromSuite: () => updateBacklogFromSuite,
|
|
183
|
+
waitForAgentReady: () => waitForAgentReady,
|
|
184
|
+
waitForWdaReady: () => waitForWdaReady,
|
|
185
|
+
wdaCacheDir: () => wdaCacheDir,
|
|
124
186
|
webOnlyReason: () => webOnlyReason
|
|
125
187
|
});
|
|
126
188
|
module.exports = __toCommonJS(src_exports);
|
|
@@ -273,7 +335,11 @@ function createMacDriver(client, options = {}) {
|
|
|
273
335
|
return rejectUnsupported("evalScript");
|
|
274
336
|
},
|
|
275
337
|
async screenshot(screenshotOptions) {
|
|
276
|
-
await client.request("screenshot", { path: screenshotOptions.path });
|
|
338
|
+
const result = await client.request("screenshot", { path: screenshotOptions.path });
|
|
339
|
+
const warning = result.warning;
|
|
340
|
+
if (typeof warning === "string" && warning.length > 0) {
|
|
341
|
+
console.warn(`macOS screenshot fell back to full screen: ${warning}`);
|
|
342
|
+
}
|
|
277
343
|
},
|
|
278
344
|
// network / dialogs / downloads (all web-only) -------------------------
|
|
279
345
|
onResponse(_handler) {
|
|
@@ -504,147 +570,1881 @@ async function closeMacSession(session) {
|
|
|
504
570
|
}
|
|
505
571
|
}
|
|
506
572
|
|
|
507
|
-
// src/
|
|
508
|
-
var import_node_child_process2 = require("child_process");
|
|
573
|
+
// src/browser/android-driver.ts
|
|
509
574
|
var import_node_fs2 = __toESM(require("fs"), 1);
|
|
510
|
-
var
|
|
511
|
-
|
|
512
|
-
"
|
|
513
|
-
"
|
|
514
|
-
"
|
|
515
|
-
"mockRoute",
|
|
516
|
-
"unmockRoute",
|
|
517
|
-
"evalScript",
|
|
518
|
-
"runScript",
|
|
519
|
-
"onDialog",
|
|
520
|
-
"select",
|
|
521
|
-
"selectOption",
|
|
522
|
-
"setInputFiles",
|
|
523
|
-
"waitForDownload",
|
|
524
|
-
"scroll"
|
|
525
|
-
// directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
|
|
575
|
+
var ANDROID_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
576
|
+
"query",
|
|
577
|
+
"interact",
|
|
578
|
+
"wait",
|
|
579
|
+
"screenshot"
|
|
526
580
|
]);
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
581
|
+
var WAIT_POLL_INTERVAL_MS = 250;
|
|
582
|
+
var DEFAULT_WAIT_TIMEOUT_MS = 5e3;
|
|
583
|
+
var ANDROID_KEYCODES = {
|
|
584
|
+
enter: 66,
|
|
585
|
+
return: 66,
|
|
586
|
+
tab: 61,
|
|
587
|
+
space: 62,
|
|
588
|
+
backspace: 67,
|
|
589
|
+
delete: 67,
|
|
590
|
+
del: 67,
|
|
591
|
+
escape: 111,
|
|
592
|
+
esc: 111,
|
|
593
|
+
back: 4,
|
|
594
|
+
home: 3,
|
|
595
|
+
menu: 82,
|
|
596
|
+
search: 84,
|
|
597
|
+
up: 19,
|
|
598
|
+
arrowup: 19,
|
|
599
|
+
down: 20,
|
|
600
|
+
arrowdown: 20,
|
|
601
|
+
left: 21,
|
|
602
|
+
arrowleft: 21,
|
|
603
|
+
right: 22,
|
|
604
|
+
arrowright: 22,
|
|
605
|
+
pageup: 92,
|
|
606
|
+
pagedown: 93
|
|
607
|
+
};
|
|
608
|
+
function unquote2(value) {
|
|
609
|
+
const trimmed = value.trim();
|
|
610
|
+
const first = trimmed[0];
|
|
611
|
+
if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
|
|
612
|
+
return trimmed.slice(1, -1);
|
|
613
|
+
}
|
|
614
|
+
return trimmed;
|
|
615
|
+
}
|
|
616
|
+
function escapeUiSelectorArg(value) {
|
|
617
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
618
|
+
}
|
|
619
|
+
function parseAndroidSelector(selector) {
|
|
620
|
+
const trimmed = selector.trim();
|
|
621
|
+
if (trimmed === ":focus") {
|
|
622
|
+
return { by: "focused" };
|
|
623
|
+
}
|
|
624
|
+
const idMatch = /^id=(.+)$/s.exec(trimmed);
|
|
625
|
+
if (idMatch) {
|
|
626
|
+
return { by: "id", value: unquote2(idMatch[1]) };
|
|
627
|
+
}
|
|
628
|
+
const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
|
|
629
|
+
if (roleMatch) {
|
|
630
|
+
const name = roleMatch[2] !== void 0 ? unquote2(roleMatch[2]) : void 0;
|
|
631
|
+
return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
|
|
632
|
+
}
|
|
633
|
+
const labelMatch = /^label=(.+)$/s.exec(trimmed);
|
|
634
|
+
if (labelMatch) {
|
|
635
|
+
return { by: "accessibilityId", value: unquote2(labelMatch[1]) };
|
|
636
|
+
}
|
|
637
|
+
const textMatch = /^text=(.+)$/s.exec(trimmed);
|
|
638
|
+
if (textMatch) {
|
|
639
|
+
return { by: "text", value: unquote2(textMatch[1]) };
|
|
640
|
+
}
|
|
641
|
+
return { by: "text", value: trimmed };
|
|
642
|
+
}
|
|
643
|
+
function locator(strategy, selector) {
|
|
644
|
+
return { strategy, selector, context: "" };
|
|
645
|
+
}
|
|
646
|
+
function qualifyResourceId(value, appPackage) {
|
|
647
|
+
if (value.includes(":") || !appPackage) {
|
|
648
|
+
return value;
|
|
649
|
+
}
|
|
650
|
+
return `${appPackage}:id/${value}`;
|
|
651
|
+
}
|
|
652
|
+
function androidQueryToLocator(query, options = {}) {
|
|
653
|
+
switch (query.by) {
|
|
654
|
+
case "id":
|
|
655
|
+
return locator("id", qualifyResourceId(query.value, options.appPackage));
|
|
656
|
+
case "accessibilityId":
|
|
657
|
+
return locator("accessibility id", query.value);
|
|
658
|
+
case "text":
|
|
659
|
+
return locator(
|
|
660
|
+
"-android uiautomator",
|
|
661
|
+
`new UiSelector().textContains("${escapeUiSelectorArg(query.value)}")`
|
|
662
|
+
);
|
|
663
|
+
case "focused":
|
|
664
|
+
return locator("-android uiautomator", "new UiSelector().focused(true)");
|
|
665
|
+
case "role": {
|
|
666
|
+
const className = escapeUiSelectorArg(query.role);
|
|
667
|
+
if (query.name === void 0 || query.name.length === 0) {
|
|
668
|
+
return locator("class name", query.role);
|
|
669
|
+
}
|
|
670
|
+
return locator(
|
|
671
|
+
"-android uiautomator",
|
|
672
|
+
`new UiSelector().className("${className}").textContains("${escapeUiSelectorArg(query.name)}")`
|
|
673
|
+
);
|
|
531
674
|
}
|
|
532
675
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
676
|
+
}
|
|
677
|
+
function unwrapAndroidTextSelector(selector) {
|
|
678
|
+
const trimmed = selector.trim();
|
|
679
|
+
if (!trimmed.startsWith("text=")) {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
return unquote2(trimmed.slice(5));
|
|
683
|
+
}
|
|
684
|
+
function keyCodeFor(key) {
|
|
685
|
+
const code = ANDROID_KEYCODES[key.trim().toLowerCase()];
|
|
686
|
+
if (code === void 0) {
|
|
687
|
+
throw new Error(
|
|
688
|
+
`Unsupported key "${key}" for the Android target. Supported keys: ${Object.keys(ANDROID_KEYCODES).sort().join(", ")}.`
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
return code;
|
|
692
|
+
}
|
|
693
|
+
function delay(ms) {
|
|
694
|
+
return new Promise((resolve) => {
|
|
695
|
+
setTimeout(resolve, ms);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
function createAndroidDriver(client, options = {}) {
|
|
699
|
+
const unsupported = (verb) => new Error(`${verb} is not supported by the Android target`);
|
|
700
|
+
const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
|
|
701
|
+
async function resolveOne(selector) {
|
|
702
|
+
const id = await client.findElement(parseAndroidSelector(selector));
|
|
703
|
+
if (id === null) {
|
|
704
|
+
throw new Error(`No element matched selector: ${selector}`);
|
|
537
705
|
}
|
|
706
|
+
return id;
|
|
538
707
|
}
|
|
539
|
-
|
|
708
|
+
async function clickSelector(selector) {
|
|
709
|
+
await client.click(await resolveOne(selector));
|
|
710
|
+
}
|
|
711
|
+
async function fillSelector(selector, value) {
|
|
712
|
+
await client.setValue(await resolveOne(selector), value);
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
capabilities: ANDROID_CAPABILITIES,
|
|
716
|
+
// navigation -----------------------------------------------------------
|
|
717
|
+
goto(_url, _options) {
|
|
718
|
+
return rejectUnsupported("navigate");
|
|
719
|
+
},
|
|
720
|
+
currentUrl() {
|
|
721
|
+
return `android:${options.appLabel ?? ""}`;
|
|
722
|
+
},
|
|
723
|
+
// queries --------------------------------------------------------------
|
|
724
|
+
async count(selector) {
|
|
725
|
+
return (await client.findElements(parseAndroidSelector(selector))).length;
|
|
726
|
+
},
|
|
727
|
+
async textContent(selector) {
|
|
728
|
+
const id = await client.findElement(parseAndroidSelector(selector));
|
|
729
|
+
if (id === null) {
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
return client.getText(id);
|
|
733
|
+
},
|
|
734
|
+
// interactions ---------------------------------------------------------
|
|
735
|
+
click: clickSelector,
|
|
736
|
+
clickFirst: clickSelector,
|
|
737
|
+
fill: fillSelector,
|
|
738
|
+
fillFirst: fillSelector,
|
|
739
|
+
async press(_selector, key) {
|
|
740
|
+
await client.pressKeyCode(keyCodeFor(key));
|
|
741
|
+
},
|
|
742
|
+
selectOption() {
|
|
743
|
+
return rejectUnsupported("select");
|
|
744
|
+
},
|
|
745
|
+
selectOptionFirst() {
|
|
746
|
+
return rejectUnsupported("select");
|
|
747
|
+
},
|
|
748
|
+
hover() {
|
|
749
|
+
return rejectUnsupported("hover");
|
|
750
|
+
},
|
|
751
|
+
scrollIntoView() {
|
|
752
|
+
return rejectUnsupported("scrollTo");
|
|
753
|
+
},
|
|
754
|
+
setInputFiles() {
|
|
755
|
+
return rejectUnsupported("setInputFiles");
|
|
756
|
+
},
|
|
757
|
+
// semantic locators ----------------------------------------------------
|
|
758
|
+
async countByRole(role, name) {
|
|
759
|
+
return (await client.findElements({ by: "role", role, name })).length;
|
|
760
|
+
},
|
|
761
|
+
async clickFirstByRole(role, name) {
|
|
762
|
+
const id = await client.findElement({ by: "role", role, name });
|
|
763
|
+
if (id === null) {
|
|
764
|
+
throw new Error(`No element matched role=${role}[name="${name}"]`);
|
|
765
|
+
}
|
|
766
|
+
await client.click(id);
|
|
767
|
+
},
|
|
768
|
+
async countByLabel(label) {
|
|
769
|
+
return (await client.findElements({ by: "accessibilityId", value: label })).length;
|
|
770
|
+
},
|
|
771
|
+
async fillFirstByLabel(label, value) {
|
|
772
|
+
const id = await client.findElement({ by: "accessibilityId", value: label });
|
|
773
|
+
if (id === null) {
|
|
774
|
+
throw new Error(`No element matched label="${label}"`);
|
|
775
|
+
}
|
|
776
|
+
await client.setValue(id, value);
|
|
777
|
+
},
|
|
778
|
+
selectOptionFirstByLabel() {
|
|
779
|
+
return rejectUnsupported("select");
|
|
780
|
+
},
|
|
781
|
+
// waiting --------------------------------------------------------------
|
|
782
|
+
async waitForSelector(selector, waitOptions) {
|
|
783
|
+
const query = parseAndroidSelector(selector);
|
|
784
|
+
const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
785
|
+
const deadline = Date.now() + timeoutMs;
|
|
786
|
+
for (; ; ) {
|
|
787
|
+
if ((await client.findElements(query)).length > 0) {
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (Date.now() >= deadline) {
|
|
791
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
|
|
792
|
+
}
|
|
793
|
+
await delay(Math.min(WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
|
|
794
|
+
}
|
|
795
|
+
},
|
|
796
|
+
waitForUrl() {
|
|
797
|
+
return rejectUnsupported("waitForUrl");
|
|
798
|
+
},
|
|
799
|
+
waitForNetworkIdle() {
|
|
800
|
+
return rejectUnsupported("waitForNetworkIdle");
|
|
801
|
+
},
|
|
802
|
+
// scripting & artifacts ------------------------------------------------
|
|
803
|
+
evaluate() {
|
|
804
|
+
return rejectUnsupported("evalScript");
|
|
805
|
+
},
|
|
806
|
+
async screenshot(screenshotOptions) {
|
|
807
|
+
const png = await client.screenshotPng();
|
|
808
|
+
import_node_fs2.default.writeFileSync(screenshotOptions.path, png);
|
|
809
|
+
},
|
|
810
|
+
// network / dialogs / downloads (all web-only) -------------------------
|
|
811
|
+
onResponse(_handler) {
|
|
812
|
+
throw unsupported("onResponse");
|
|
813
|
+
},
|
|
814
|
+
route(_url, _handler) {
|
|
815
|
+
return rejectUnsupported("mockRoute");
|
|
816
|
+
},
|
|
817
|
+
unroute() {
|
|
818
|
+
return rejectUnsupported("unmockRoute");
|
|
819
|
+
},
|
|
820
|
+
onDialog(_action) {
|
|
821
|
+
throw unsupported("onDialog");
|
|
822
|
+
},
|
|
823
|
+
waitForDownloadEvent() {
|
|
824
|
+
return rejectUnsupported("waitForDownload");
|
|
825
|
+
},
|
|
826
|
+
parseTextSelector(selector) {
|
|
827
|
+
return unwrapAndroidTextSelector(selector);
|
|
828
|
+
}
|
|
829
|
+
};
|
|
540
830
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
831
|
+
|
|
832
|
+
// src/browser/android-helper.ts
|
|
833
|
+
var import_node_module = require("module");
|
|
834
|
+
var import_node_child_process4 = require("child_process");
|
|
835
|
+
var import_node_fs4 = __toESM(require("fs"), 1);
|
|
836
|
+
var import_node_path3 = __toESM(require("path"), 1);
|
|
837
|
+
|
|
838
|
+
// src/browser/android-adb.ts
|
|
839
|
+
var import_node_child_process2 = require("child_process");
|
|
840
|
+
var execFileAdbRunner = (args, options) => new Promise((resolve) => {
|
|
841
|
+
(0, import_node_child_process2.execFile)(
|
|
842
|
+
"adb",
|
|
843
|
+
args,
|
|
844
|
+
{ encoding: "utf-8", timeout: options?.timeoutMs, maxBuffer: 16 * 1024 * 1024 },
|
|
845
|
+
(error, stdout, stderr) => {
|
|
846
|
+
const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
|
|
847
|
+
const capturedStderr = stderr ?? "";
|
|
848
|
+
resolve({
|
|
849
|
+
stdout: stdout ?? "",
|
|
850
|
+
stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
|
|
851
|
+
code
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
);
|
|
855
|
+
});
|
|
856
|
+
var spawnAdbProcess = (args) => {
|
|
857
|
+
const child = (0, import_node_child_process2.spawn)("adb", args, { stdio: "ignore" });
|
|
858
|
+
child.on("error", () => {
|
|
859
|
+
});
|
|
860
|
+
return { kill: () => child.kill() };
|
|
861
|
+
};
|
|
862
|
+
function withSerial(serial, args) {
|
|
863
|
+
return serial ? ["-s", serial, ...args] : args;
|
|
864
|
+
}
|
|
865
|
+
function parseAdbDevices(stdout) {
|
|
866
|
+
const devices = [];
|
|
867
|
+
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
868
|
+
const line = rawLine.trim();
|
|
869
|
+
if (!line || /^list of devices attached/i.test(line)) {
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
const [serial, state, ...rest] = line.split(/\s+/);
|
|
873
|
+
if (!serial || !state) {
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
const description = {};
|
|
877
|
+
for (const token of rest) {
|
|
878
|
+
const eq = token.indexOf(":");
|
|
879
|
+
if (eq > 0) {
|
|
880
|
+
description[token.slice(0, eq)] = token.slice(eq + 1);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
devices.push({ serial, state, description });
|
|
544
884
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
885
|
+
return devices;
|
|
886
|
+
}
|
|
887
|
+
function bootedDevices(devices) {
|
|
888
|
+
return devices.filter((device) => device.state === "device");
|
|
889
|
+
}
|
|
890
|
+
function selectDeviceSerial(devices, requested) {
|
|
891
|
+
const booted = bootedDevices(devices);
|
|
892
|
+
if (requested) {
|
|
893
|
+
const match = devices.find((device) => device.serial === requested);
|
|
894
|
+
if (!match) {
|
|
895
|
+
const attached = devices.length > 0 ? devices.map((d) => d.serial).join(", ") : "none";
|
|
548
896
|
throw new Error(
|
|
549
|
-
`
|
|
897
|
+
`Android device "${requested}" is not attached. Attached devices: ${attached}. Check \`adb devices -l\`.`
|
|
550
898
|
);
|
|
551
899
|
}
|
|
552
|
-
if ("
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
900
|
+
if (match.state !== "device") {
|
|
901
|
+
throw new Error(
|
|
902
|
+
`Android device "${requested}" is present but not ready (state: ${match.state}). Boot or authorize it, then retry.`
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
return requested;
|
|
906
|
+
}
|
|
907
|
+
if (booted.length === 0) {
|
|
908
|
+
throw new Error(
|
|
909
|
+
"No booted Android device found. Start an emulator or connect a device with USB debugging, then confirm it appears in `adb devices -l`."
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
if (booted.length > 1) {
|
|
913
|
+
throw new Error(
|
|
914
|
+
`Multiple Android devices attached (${booted.map((d) => d.serial).join(", ")}). Set target.deviceSerial to pick one.`
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
return booted[0].serial;
|
|
918
|
+
}
|
|
919
|
+
async function listDevices(runner) {
|
|
920
|
+
const result = await runner(["devices", "-l"], { timeoutMs: 1e4 });
|
|
921
|
+
if (result.code !== 0) {
|
|
922
|
+
throw new Error(
|
|
923
|
+
`\`adb devices\` failed (exit ${result.code}). Is adb on PATH and the server running? ` + (result.stderr.trim() || "").slice(0, 400)
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
return parseAdbDevices(result.stdout);
|
|
927
|
+
}
|
|
928
|
+
function parseForwardPort(stdout) {
|
|
929
|
+
const port = Number.parseInt(stdout.trim(), 10);
|
|
930
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
931
|
+
throw new Error(`Could not parse a forwarded port from adb output: "${stdout.trim()}"`);
|
|
932
|
+
}
|
|
933
|
+
return port;
|
|
934
|
+
}
|
|
935
|
+
function parseAaptPackage(stdout) {
|
|
936
|
+
const match = /package:\s*name='([^']+)'/.exec(stdout);
|
|
937
|
+
return match?.[1] ?? null;
|
|
938
|
+
}
|
|
939
|
+
async function forwardDynamicPort(runner, serial, remotePort) {
|
|
940
|
+
const result = await runner(withSerial(serial, ["forward", "tcp:0", `tcp:${remotePort}`]), {
|
|
941
|
+
timeoutMs: 1e4
|
|
942
|
+
});
|
|
943
|
+
if (result.code !== 0) {
|
|
944
|
+
throw new Error(`adb forward failed (exit ${result.code}): ${result.stderr.trim()}`);
|
|
945
|
+
}
|
|
946
|
+
return parseForwardPort(result.stdout);
|
|
947
|
+
}
|
|
948
|
+
async function removeForward(runner, serial, localPort) {
|
|
949
|
+
await runner(withSerial(serial, ["forward", "--remove", `tcp:${localPort}`]), { timeoutMs: 1e4 }).catch(
|
|
950
|
+
() => void 0
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
async function installApk(runner, serial, apkPath) {
|
|
954
|
+
const result = await runner(withSerial(serial, ["install", "-r", "-t", "-g", apkPath]), {
|
|
955
|
+
timeoutMs: 12e4
|
|
956
|
+
});
|
|
957
|
+
if (result.code !== 0 || /failure/i.test(result.stdout)) {
|
|
958
|
+
throw new Error(
|
|
959
|
+
`Failed to install APK "${apkPath}" (exit ${result.code}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
async function launchPackage(runner, serial, pkg) {
|
|
964
|
+
const result = await runner(
|
|
965
|
+
withSerial(serial, [
|
|
966
|
+
"shell",
|
|
967
|
+
"monkey",
|
|
968
|
+
"-p",
|
|
969
|
+
pkg,
|
|
970
|
+
"-c",
|
|
971
|
+
"android.intent.category.LAUNCHER",
|
|
972
|
+
"1"
|
|
973
|
+
]),
|
|
974
|
+
{ timeoutMs: 3e4 }
|
|
975
|
+
);
|
|
976
|
+
if (result.code !== 0 || /no activities found|aborted|cannot launch/i.test(result.stdout + result.stderr)) {
|
|
977
|
+
throw new Error(
|
|
978
|
+
`Failed to launch Android package "${pkg}" (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 400)}. Is it installed?`
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
async function forceStop(runner, serial, pkg) {
|
|
983
|
+
await runner(withSerial(serial, ["shell", "am", "force-stop", pkg]), { timeoutMs: 3e4 });
|
|
984
|
+
}
|
|
985
|
+
async function clearPackage(runner, serial, pkg) {
|
|
986
|
+
const result = await runner(withSerial(serial, ["shell", "pm", "clear", pkg]), { timeoutMs: 3e4 });
|
|
987
|
+
if (result.code !== 0 || !/success/i.test(result.stdout)) {
|
|
988
|
+
throw new Error(
|
|
989
|
+
`Failed to clear Android package "${pkg}" for cold start (exit ${result.code}): ${(result.stdout + result.stderr).trim().slice(0, 300)}`
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
function startInstrumentation(spawner, serial) {
|
|
994
|
+
return spawner(
|
|
995
|
+
withSerial(serial, [
|
|
996
|
+
"shell",
|
|
997
|
+
"am",
|
|
998
|
+
"instrument",
|
|
999
|
+
"-w",
|
|
1000
|
+
"-e",
|
|
1001
|
+
"disableAnalytics",
|
|
1002
|
+
"true",
|
|
1003
|
+
"io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner"
|
|
1004
|
+
])
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// src/browser/android-agent.ts
|
|
1009
|
+
var DEFAULT_AGENT_REQUEST_TIMEOUT_MS = 3e4;
|
|
1010
|
+
var W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
|
|
1011
|
+
var Uia2HttpError = class extends Error {
|
|
1012
|
+
constructor(message, status, webdriverError) {
|
|
1013
|
+
super(message);
|
|
1014
|
+
this.status = status;
|
|
1015
|
+
this.webdriverError = webdriverError;
|
|
1016
|
+
this.name = "Uia2HttpError";
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
var Uia2Transport = class {
|
|
1020
|
+
baseUrl;
|
|
1021
|
+
requestTimeoutMs;
|
|
1022
|
+
fetchImpl;
|
|
1023
|
+
constructor(options) {
|
|
1024
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
1025
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_AGENT_REQUEST_TIMEOUT_MS;
|
|
1026
|
+
const injected = options.fetchImpl;
|
|
1027
|
+
if (injected) {
|
|
1028
|
+
this.fetchImpl = injected;
|
|
1029
|
+
} else if (typeof fetch === "function") {
|
|
1030
|
+
this.fetchImpl = (url, init) => fetch(url, init);
|
|
1031
|
+
} else {
|
|
1032
|
+
throw new Error("global fetch is unavailable; Node 20+ is required for the Android target");
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Send one request and return the parsed `value` field. Rejects with a
|
|
1037
|
+
* {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
|
|
1038
|
+
* per-request deadline elapses.
|
|
1039
|
+
*/
|
|
1040
|
+
async request(method, path18, body, timeoutMs) {
|
|
1041
|
+
const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
|
|
1042
|
+
const controller = new AbortController();
|
|
1043
|
+
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
1044
|
+
timer.unref?.();
|
|
1045
|
+
const url = `${this.baseUrl}${path18}`;
|
|
1046
|
+
let response;
|
|
1047
|
+
try {
|
|
1048
|
+
response = await this.fetchImpl(url, {
|
|
1049
|
+
method,
|
|
1050
|
+
signal: controller.signal,
|
|
1051
|
+
headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
|
|
1052
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
1053
|
+
});
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
if (controller.signal.aborted) {
|
|
1056
|
+
const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
|
|
1057
|
+
throw new Error(`uiautomator2 request ${method} ${path18} timed out after ${shown}`);
|
|
1058
|
+
}
|
|
1059
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
1060
|
+
} finally {
|
|
1061
|
+
clearTimeout(timer);
|
|
1062
|
+
}
|
|
1063
|
+
const text = await response.text();
|
|
1064
|
+
const parsed = parseJson(text);
|
|
1065
|
+
if (!response.ok) {
|
|
1066
|
+
const wdError = extractWebdriverError(parsed);
|
|
1067
|
+
throw new Uia2HttpError(
|
|
1068
|
+
`uiautomator2 ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
|
|
1069
|
+
response.status,
|
|
1070
|
+
wdError
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
return parsed?.value;
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
function parseJson(text) {
|
|
1077
|
+
if (!text) {
|
|
1078
|
+
return void 0;
|
|
1079
|
+
}
|
|
1080
|
+
try {
|
|
1081
|
+
return JSON.parse(text);
|
|
1082
|
+
} catch {
|
|
1083
|
+
return void 0;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
function extractWebdriverError(parsed) {
|
|
1087
|
+
const value = parsed?.value;
|
|
1088
|
+
if (value && typeof value === "object") {
|
|
1089
|
+
const record = value;
|
|
1090
|
+
const error = typeof record.error === "string" ? record.error : void 0;
|
|
1091
|
+
const message = typeof record.message === "string" ? record.message : void 0;
|
|
1092
|
+
return error ?? message;
|
|
1093
|
+
}
|
|
1094
|
+
return void 0;
|
|
1095
|
+
}
|
|
1096
|
+
function extractElementId(value) {
|
|
1097
|
+
if (!value || typeof value !== "object") {
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
const record = value;
|
|
1101
|
+
const id = record[W3C_ELEMENT_KEY] ?? record.ELEMENT;
|
|
1102
|
+
return typeof id === "string" ? id : null;
|
|
1103
|
+
}
|
|
1104
|
+
function isNoSuchElement(error) {
|
|
1105
|
+
if (error instanceof Uia2HttpError) {
|
|
1106
|
+
return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
|
|
1107
|
+
}
|
|
1108
|
+
return false;
|
|
1109
|
+
}
|
|
1110
|
+
async function createUia2Session(transport) {
|
|
1111
|
+
const value = await transport.request("POST", "/session", {
|
|
1112
|
+
capabilities: { alwaysMatch: {}, firstMatch: [{}] }
|
|
1113
|
+
});
|
|
1114
|
+
const record = value ?? {};
|
|
1115
|
+
const sessionId = record.sessionId;
|
|
1116
|
+
if (typeof sessionId === "string" && sessionId.length > 0) {
|
|
1117
|
+
return sessionId;
|
|
1118
|
+
}
|
|
1119
|
+
throw new Error("uiautomator2 did not return a session id");
|
|
1120
|
+
}
|
|
1121
|
+
async function waitForAgentReady(transport, options = { deadlineMs: 3e4 }) {
|
|
1122
|
+
const interval = options.intervalMs ?? 300;
|
|
1123
|
+
const deadline = Date.now() + options.deadlineMs;
|
|
1124
|
+
let lastError;
|
|
1125
|
+
for (; ; ) {
|
|
1126
|
+
try {
|
|
1127
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
1128
|
+
const value = await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
|
|
1129
|
+
const ready = value?.ready;
|
|
1130
|
+
if (ready === void 0 || ready === true) {
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
} catch (error) {
|
|
1134
|
+
lastError = error;
|
|
1135
|
+
}
|
|
1136
|
+
if (Date.now() >= deadline) {
|
|
1137
|
+
const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
|
|
1138
|
+
throw new Error(`uiautomator2 agent did not become ready within ${options.deadlineMs}ms${detail}`);
|
|
1139
|
+
}
|
|
1140
|
+
await sleep(Math.min(interval, Math.max(0, deadline - Date.now())));
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
function sleep(ms) {
|
|
1144
|
+
return new Promise((resolve) => {
|
|
1145
|
+
const timer = setTimeout(resolve, ms);
|
|
1146
|
+
timer.unref?.();
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
function createUia2AgentClient(transport, sessionId, options = {}) {
|
|
1150
|
+
const base = `/session/${sessionId}`;
|
|
1151
|
+
async function locate(query, path18) {
|
|
1152
|
+
return transport.request(
|
|
1153
|
+
"POST",
|
|
1154
|
+
`${base}${path18}`,
|
|
1155
|
+
androidQueryToLocator(query, { appPackage: options.appPackage })
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
return {
|
|
1159
|
+
async findElement(query) {
|
|
1160
|
+
try {
|
|
1161
|
+
return extractElementId(await locate(query, "/element"));
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
if (isNoSuchElement(error)) {
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
throw error;
|
|
1167
|
+
}
|
|
1168
|
+
},
|
|
1169
|
+
async findElements(query) {
|
|
1170
|
+
const value = await locate(query, "/elements");
|
|
1171
|
+
if (!Array.isArray(value)) {
|
|
1172
|
+
return [];
|
|
1173
|
+
}
|
|
1174
|
+
return value.map((entry) => extractElementId(entry)).filter((id) => id !== null);
|
|
1175
|
+
},
|
|
1176
|
+
async click(elementId) {
|
|
1177
|
+
await transport.request("POST", `${base}/element/${elementId}/click`, {});
|
|
1178
|
+
},
|
|
1179
|
+
async setValue(elementId, text) {
|
|
1180
|
+
await transport.request("POST", `${base}/element/${elementId}/value`, { text });
|
|
1181
|
+
},
|
|
1182
|
+
async getText(elementId) {
|
|
1183
|
+
const value = await transport.request("GET", `${base}/element/${elementId}/text`);
|
|
1184
|
+
return typeof value === "string" ? value : value == null ? null : String(value);
|
|
1185
|
+
},
|
|
1186
|
+
async pressKeyCode(keyCode) {
|
|
1187
|
+
await transport.request("POST", `${base}/appium/device/press_keycode`, { keycode: keyCode });
|
|
1188
|
+
},
|
|
1189
|
+
async screenshotPng() {
|
|
1190
|
+
const value = await transport.request("GET", `${base}/screenshot`);
|
|
1191
|
+
if (typeof value !== "string") {
|
|
1192
|
+
throw new Error("uiautomator2 screenshot did not return base64 data");
|
|
1193
|
+
}
|
|
1194
|
+
return Buffer.from(value, "base64");
|
|
1195
|
+
},
|
|
1196
|
+
async close() {
|
|
1197
|
+
await transport.request("DELETE", base).catch(() => void 0);
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// src/config/target.ts
|
|
1203
|
+
var import_node_child_process3 = require("child_process");
|
|
1204
|
+
var import_node_fs3 = __toESM(require("fs"), 1);
|
|
1205
|
+
var import_node_path2 = __toESM(require("path"), 1);
|
|
1206
|
+
var WEB_ONLY_STEP_TYPES = /* @__PURE__ */ new Set([
|
|
1207
|
+
"navigate",
|
|
1208
|
+
"waitForUrl",
|
|
1209
|
+
"waitForNetworkIdle",
|
|
1210
|
+
"mockRoute",
|
|
1211
|
+
"unmockRoute",
|
|
1212
|
+
"evalScript",
|
|
1213
|
+
"runScript",
|
|
1214
|
+
"onDialog",
|
|
1215
|
+
"select",
|
|
1216
|
+
"selectOption",
|
|
1217
|
+
"setInputFiles",
|
|
1218
|
+
"waitForDownload",
|
|
1219
|
+
"scroll"
|
|
1220
|
+
// directional scroll runs window.scrollBy (evaluate) — use scrollTo instead
|
|
1221
|
+
]);
|
|
1222
|
+
function webOnlyReason(step) {
|
|
1223
|
+
for (const type of WEB_ONLY_STEP_TYPES) {
|
|
1224
|
+
if (type in step) {
|
|
1225
|
+
return type;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
if ("assert" in step) {
|
|
1229
|
+
const assertion = step.assert;
|
|
1230
|
+
if (assertion.urlIncludes !== void 0 || assertion.urlEquals !== void 0) {
|
|
1231
|
+
return "assert (url)";
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
function nativeTargetLabel(target) {
|
|
1237
|
+
if (target === "android") {
|
|
1238
|
+
return "Android";
|
|
1239
|
+
}
|
|
1240
|
+
if (target === "ios") {
|
|
1241
|
+
return "iOS";
|
|
1242
|
+
}
|
|
1243
|
+
return "macOS";
|
|
1244
|
+
}
|
|
1245
|
+
function assertStepsSupportedByTarget(steps, target) {
|
|
1246
|
+
if (target === "web") {
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
const label = nativeTargetLabel(target);
|
|
1250
|
+
for (const step of steps) {
|
|
1251
|
+
const reason = webOnlyReason(step);
|
|
1252
|
+
if (reason) {
|
|
1253
|
+
throw new Error(
|
|
1254
|
+
`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.).`
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
if ("if" in step) {
|
|
1258
|
+
assertStepsSupportedByTarget(step.if.then, target);
|
|
1259
|
+
if (step.if.else) {
|
|
1260
|
+
assertStepsSupportedByTarget(step.if.else, target);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
if ("repeat" in step) {
|
|
1264
|
+
assertStepsSupportedByTarget(step.repeat.steps, target);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
function assertHuntAssertionsSupportedByTarget(assertions, target) {
|
|
1269
|
+
if (target === "web" || !assertions || assertions.length === 0) {
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
throw new Error(
|
|
1273
|
+
`Hunt-level assertions are not supported by the ${nativeTargetLabel(target)} target. Use inline assert visible/notVisible steps instead.`
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
function trimTrailingPathSeparators(value) {
|
|
1277
|
+
return value.replace(/[\\/]+$/g, "");
|
|
1278
|
+
}
|
|
1279
|
+
function looksLikeAppBundlePath(app) {
|
|
1280
|
+
const trimmed = trimTrailingPathSeparators(app);
|
|
1281
|
+
return trimmed.includes("/") || trimmed.toLowerCase().endsWith(".app");
|
|
1282
|
+
}
|
|
1283
|
+
function looksLikeIosAppPath(app) {
|
|
1284
|
+
const trimmed = trimTrailingPathSeparators(app);
|
|
1285
|
+
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
|
1286
|
+
return true;
|
|
1287
|
+
}
|
|
1288
|
+
if (trimmed.toLowerCase().endsWith(".app")) {
|
|
1289
|
+
try {
|
|
1290
|
+
return import_node_fs3.default.statSync(normalizeAppPath(app)).isDirectory();
|
|
1291
|
+
} catch {
|
|
1292
|
+
return false;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
return false;
|
|
1296
|
+
}
|
|
1297
|
+
function normalizeAppPath(app) {
|
|
1298
|
+
return import_node_path2.default.resolve(trimTrailingPathSeparators(app));
|
|
1299
|
+
}
|
|
1300
|
+
function parseBundleIdentifier(plist) {
|
|
1301
|
+
const match = /<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>\s*([^<]+?)\s*<\/string>/s.exec(plist);
|
|
1302
|
+
return match?.[1]?.trim() || null;
|
|
1303
|
+
}
|
|
1304
|
+
function readBundleIdentifier(appPath, ...plistSubPath) {
|
|
1305
|
+
const infoPlistPath = import_node_path2.default.join(normalizeAppPath(appPath), ...plistSubPath);
|
|
1306
|
+
if (!import_node_fs3.default.existsSync(infoPlistPath)) {
|
|
1307
|
+
return null;
|
|
1308
|
+
}
|
|
1309
|
+
try {
|
|
1310
|
+
const parsed = parseBundleIdentifier(import_node_fs3.default.readFileSync(infoPlistPath, "utf-8"));
|
|
1311
|
+
if (parsed) {
|
|
1312
|
+
return parsed;
|
|
1313
|
+
}
|
|
1314
|
+
} catch {
|
|
1315
|
+
}
|
|
1316
|
+
if (process.platform !== "darwin" || !import_node_fs3.default.existsSync("/usr/bin/plutil")) {
|
|
1317
|
+
return null;
|
|
1318
|
+
}
|
|
1319
|
+
try {
|
|
1320
|
+
const output = (0, import_node_child_process3.execFileSync)(
|
|
1321
|
+
"/usr/bin/plutil",
|
|
1322
|
+
["-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlistPath],
|
|
1323
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1e3 }
|
|
1324
|
+
);
|
|
1325
|
+
return output.trim() || null;
|
|
1326
|
+
} catch {
|
|
1327
|
+
return null;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
function readMacosBundleIdentifier(appPath) {
|
|
1331
|
+
return readBundleIdentifier(appPath, "Contents", "Info.plist");
|
|
1332
|
+
}
|
|
1333
|
+
function readIosBundleIdentifier(appPath) {
|
|
1334
|
+
return readBundleIdentifier(appPath, "Info.plist");
|
|
1335
|
+
}
|
|
1336
|
+
function appBundleAllowedIdentities(app, readBundleId, isAppPath = looksLikeAppBundlePath) {
|
|
1337
|
+
const identities = /* @__PURE__ */ new Set([app]);
|
|
1338
|
+
if (isAppPath(app)) {
|
|
1339
|
+
const normalizedPath = normalizeAppPath(app);
|
|
1340
|
+
identities.add(trimTrailingPathSeparators(app));
|
|
1341
|
+
identities.add(normalizedPath);
|
|
1342
|
+
const bundleName = import_node_path2.default.basename(normalizedPath).replace(/\.app$/i, "");
|
|
1343
|
+
if (bundleName) {
|
|
1344
|
+
identities.add(bundleName);
|
|
1345
|
+
}
|
|
1346
|
+
const bundleId = readBundleId(app);
|
|
1347
|
+
if (bundleId) {
|
|
1348
|
+
identities.add(bundleId);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
return [...identities];
|
|
1352
|
+
}
|
|
1353
|
+
function macosAppAllowedIdentities(app) {
|
|
1354
|
+
return appBundleAllowedIdentities(app, readMacosBundleIdentifier);
|
|
1355
|
+
}
|
|
1356
|
+
function iosAppAllowedIdentities(app) {
|
|
1357
|
+
return appBundleAllowedIdentities(app, readIosBundleIdentifier, looksLikeIosAppPath);
|
|
1358
|
+
}
|
|
1359
|
+
function assertTargetAppAllowed(allowedApps, app) {
|
|
1360
|
+
assertNativeAppAllowed(allowedApps, app, macosAppAllowedIdentities);
|
|
1361
|
+
}
|
|
1362
|
+
function assertIosAppAllowed(allowedApps, app) {
|
|
1363
|
+
assertNativeAppAllowed(allowedApps, app, iosAppAllowedIdentities);
|
|
1364
|
+
}
|
|
1365
|
+
function looksLikeApkPath(app) {
|
|
1366
|
+
const trimmed = trimTrailingPathSeparators(app);
|
|
1367
|
+
return trimmed.includes("/") || trimmed.includes("\\") || trimmed.toLowerCase().endsWith(".apk");
|
|
1368
|
+
}
|
|
1369
|
+
function normalizeAndroidApkPath(app) {
|
|
1370
|
+
const resolved = import_node_path2.default.resolve(trimTrailingPathSeparators(app));
|
|
1371
|
+
try {
|
|
1372
|
+
return import_node_fs3.default.realpathSync.native(resolved);
|
|
1373
|
+
} catch {
|
|
1374
|
+
return resolved;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
function androidAppAllowedIdentities(app, resolvedPackage) {
|
|
1378
|
+
if (looksLikeApkPath(app)) {
|
|
1379
|
+
return resolvedPackage ? [normalizeAndroidApkPath(app), resolvedPackage] : [normalizeAndroidApkPath(app)];
|
|
1380
|
+
}
|
|
1381
|
+
return [app];
|
|
1382
|
+
}
|
|
1383
|
+
function assertAndroidAppAllowed(allowedApps, app, resolvedPackage) {
|
|
1384
|
+
assertNativeAppAllowed(
|
|
1385
|
+
allowedApps,
|
|
1386
|
+
app,
|
|
1387
|
+
(value) => androidAppAllowedIdentities(value, value === app ? resolvedPackage : void 0)
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
function assertNativeAppAllowed(allowedApps, app, resolveIdentities) {
|
|
1391
|
+
if (allowedApps.length === 0) {
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
const allowedIdentities = new Set(allowedApps.flatMap((allowedApp) => resolveIdentities(allowedApp)));
|
|
1395
|
+
if (resolveIdentities(app).some((identity) => allowedIdentities.has(identity))) {
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
throw new Error(
|
|
1399
|
+
`Target app "${app}" is not in guardrails.allowedApps (${allowedApps.join(", ")}).`
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// src/browser/android-helper.ts
|
|
1404
|
+
var import_meta2 = {};
|
|
1405
|
+
var UIA2_REMOTE_PORT = 6790;
|
|
1406
|
+
function resolveAgentApks(requireFn = (0, import_node_module.createRequire)(import_meta2.url)) {
|
|
1407
|
+
let pkgJsonPath;
|
|
1408
|
+
try {
|
|
1409
|
+
pkgJsonPath = requireFn.resolve("appium-uiautomator2-server/package.json");
|
|
1410
|
+
} catch {
|
|
1411
|
+
throw new Error(
|
|
1412
|
+
"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"
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
const pkgDir = import_node_path3.default.dirname(pkgJsonPath);
|
|
1416
|
+
const version = requireFn(pkgJsonPath).version;
|
|
1417
|
+
const serverApk = import_node_path3.default.join(pkgDir, "apks", `appium-uiautomator2-server-v${version}.apk`);
|
|
1418
|
+
const testApk = import_node_path3.default.join(pkgDir, "apks", "appium-uiautomator2-server-debug-androidTest.apk");
|
|
1419
|
+
for (const apk of [serverApk, testApk]) {
|
|
1420
|
+
if (!import_node_fs4.default.existsSync(apk)) {
|
|
1421
|
+
throw new Error(`Expected uiautomator2 agent APK is missing: ${apk}. Reinstall dependencies.`);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
return { serverApk, testApk };
|
|
1425
|
+
}
|
|
1426
|
+
function looksLikeApk(app) {
|
|
1427
|
+
return app.toLowerCase().endsWith(".apk");
|
|
1428
|
+
}
|
|
1429
|
+
var execFileAaptResolver = async (apkPath) => {
|
|
1430
|
+
for (const tool of ["aapt", "aapt2"]) {
|
|
1431
|
+
const output = await new Promise((resolve) => {
|
|
1432
|
+
(0, import_node_child_process4.execFile)(
|
|
1433
|
+
tool,
|
|
1434
|
+
["dump", "badging", apkPath],
|
|
1435
|
+
{ encoding: "utf-8", timeout: 2e4, maxBuffer: 8 * 1024 * 1024 },
|
|
1436
|
+
(error, stdout) => resolve(error ? null : stdout)
|
|
1437
|
+
);
|
|
1438
|
+
});
|
|
1439
|
+
const pkg = output ? parseAaptPackage(output) : null;
|
|
1440
|
+
if (pkg) {
|
|
1441
|
+
return pkg;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
return null;
|
|
1445
|
+
};
|
|
1446
|
+
var defaultAgentConnector = async ({
|
|
1447
|
+
host,
|
|
1448
|
+
port,
|
|
1449
|
+
requestTimeoutMs,
|
|
1450
|
+
readyDeadlineMs,
|
|
1451
|
+
appPackage
|
|
1452
|
+
}) => {
|
|
1453
|
+
const transport = new Uia2Transport({
|
|
1454
|
+
baseUrl: `http://${host}:${port}/wd/hub`,
|
|
1455
|
+
requestTimeoutMs
|
|
1456
|
+
});
|
|
1457
|
+
await waitForAgentReady(transport, { deadlineMs: readyDeadlineMs });
|
|
1458
|
+
const sessionId = await createUia2Session(transport);
|
|
1459
|
+
return createUia2AgentClient(transport, sessionId, { appPackage });
|
|
1460
|
+
};
|
|
1461
|
+
async function resolvePackage(app, runner, serial, aaptResolver, allowedApps) {
|
|
1462
|
+
if (!looksLikeApk(app)) {
|
|
1463
|
+
assertAndroidAppAllowed(allowedApps, app);
|
|
1464
|
+
return app;
|
|
1465
|
+
}
|
|
1466
|
+
const apkPath = import_node_path3.default.resolve(app);
|
|
1467
|
+
if (!import_node_fs4.default.existsSync(apkPath)) {
|
|
1468
|
+
throw new Error(`APK not found: ${apkPath}`);
|
|
1469
|
+
}
|
|
1470
|
+
const pkg = await aaptResolver(apkPath);
|
|
1471
|
+
if (!pkg) {
|
|
1472
|
+
throw new Error(
|
|
1473
|
+
`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.`
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
assertAndroidAppAllowed(allowedApps, apkPath, pkg);
|
|
1477
|
+
await installApk(runner, serial, apkPath);
|
|
1478
|
+
return pkg;
|
|
1479
|
+
}
|
|
1480
|
+
async function launchAndroidSession(options) {
|
|
1481
|
+
const runner = options.runner ?? execFileAdbRunner;
|
|
1482
|
+
const spawner = options.spawner ?? spawnAdbProcess;
|
|
1483
|
+
const connector = options.agentConnector ?? defaultAgentConnector;
|
|
1484
|
+
const aaptResolver = options.aaptResolver ?? execFileAaptResolver;
|
|
1485
|
+
const apks = options.apks ?? resolveAgentApks();
|
|
1486
|
+
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS) + 5e3;
|
|
1487
|
+
const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_AGENT_REQUEST_TIMEOUT_MS);
|
|
1488
|
+
const devices = await listDevices(runner);
|
|
1489
|
+
const serial = selectDeviceSerial(devices, options.deviceSerial);
|
|
1490
|
+
const pkg = await resolvePackage(options.app, runner, serial, aaptResolver, options.allowedApps ?? []);
|
|
1491
|
+
await installApk(runner, serial, apks.serverApk);
|
|
1492
|
+
await installApk(runner, serial, apks.testApk);
|
|
1493
|
+
if (options.coldStart) {
|
|
1494
|
+
await clearPackage(runner, serial, pkg);
|
|
1495
|
+
}
|
|
1496
|
+
await launchPackage(runner, serial, pkg);
|
|
1497
|
+
let instrumentation;
|
|
1498
|
+
let localPort;
|
|
1499
|
+
let client;
|
|
1500
|
+
let tornDown = false;
|
|
1501
|
+
const teardown = async () => {
|
|
1502
|
+
if (tornDown) {
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
tornDown = true;
|
|
1506
|
+
if (client) {
|
|
1507
|
+
await client.close().catch(() => void 0);
|
|
1508
|
+
}
|
|
1509
|
+
instrumentation?.kill();
|
|
1510
|
+
const forwardedPort = localPort;
|
|
1511
|
+
localPort = void 0;
|
|
1512
|
+
if (forwardedPort !== void 0) {
|
|
1513
|
+
await removeForward(runner, serial, forwardedPort);
|
|
1514
|
+
}
|
|
1515
|
+
await forceStop(runner, serial, pkg).catch(() => void 0);
|
|
1516
|
+
};
|
|
1517
|
+
try {
|
|
1518
|
+
instrumentation = startInstrumentation(spawner, serial);
|
|
1519
|
+
localPort = await forwardDynamicPort(runner, serial, UIA2_REMOTE_PORT);
|
|
1520
|
+
client = await connector({
|
|
1521
|
+
host: "127.0.0.1",
|
|
1522
|
+
port: localPort,
|
|
1523
|
+
requestTimeoutMs,
|
|
1524
|
+
readyDeadlineMs,
|
|
1525
|
+
appPackage: pkg
|
|
1526
|
+
});
|
|
1527
|
+
const driver = createAndroidDriver(client, { appLabel: pkg });
|
|
1528
|
+
return { client, driver, package: pkg, serial, teardown };
|
|
1529
|
+
} catch (error) {
|
|
1530
|
+
await teardown();
|
|
1531
|
+
throw error;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
async function closeAndroidSession(session) {
|
|
1535
|
+
await session.teardown();
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// src/browser/ios-driver.ts
|
|
1539
|
+
var IOS_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
1540
|
+
"query",
|
|
1541
|
+
"interact",
|
|
1542
|
+
"wait",
|
|
1543
|
+
"screenshot"
|
|
1544
|
+
]);
|
|
1545
|
+
var WAIT_POLL_INTERVAL_MS2 = 250;
|
|
1546
|
+
var DEFAULT_WAIT_TIMEOUT_MS2 = 5e3;
|
|
1547
|
+
var IOS_PRESS_KEYS = ["backspace", "del", "delete", "enter", "home", "return"];
|
|
1548
|
+
function unquote3(value) {
|
|
1549
|
+
const trimmed = value.trim();
|
|
1550
|
+
const first = trimmed[0];
|
|
1551
|
+
if ((first === '"' || first === "'") && trimmed.endsWith(first) && trimmed.length >= 2) {
|
|
1552
|
+
return trimmed.slice(1, -1);
|
|
1553
|
+
}
|
|
1554
|
+
return trimmed;
|
|
1555
|
+
}
|
|
1556
|
+
function escapePredicateArg(value) {
|
|
1557
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1558
|
+
}
|
|
1559
|
+
function normalizeXcuiClassName(role) {
|
|
1560
|
+
return role.startsWith("XCUIElementType") ? role : `XCUIElementType${role}`;
|
|
1561
|
+
}
|
|
1562
|
+
function parseIosSelector(selector) {
|
|
1563
|
+
const trimmed = selector.trim();
|
|
1564
|
+
if (trimmed === ":focus") {
|
|
1565
|
+
return { by: "focused" };
|
|
1566
|
+
}
|
|
1567
|
+
const idMatch = /^id=(.+)$/s.exec(trimmed);
|
|
1568
|
+
if (idMatch) {
|
|
1569
|
+
return { by: "accessibilityId", value: unquote3(idMatch[1]) };
|
|
1570
|
+
}
|
|
1571
|
+
const roleMatch = /^role=([A-Za-z][\w.$-]*)(?:\[name=(.+)\])?$/s.exec(trimmed);
|
|
1572
|
+
if (roleMatch) {
|
|
1573
|
+
const name = roleMatch[2] !== void 0 ? unquote3(roleMatch[2]) : void 0;
|
|
1574
|
+
return name !== void 0 && name.length > 0 ? { by: "role", role: roleMatch[1], name } : { by: "role", role: roleMatch[1] };
|
|
1575
|
+
}
|
|
1576
|
+
const labelMatch = /^label=(.+)$/s.exec(trimmed);
|
|
1577
|
+
if (labelMatch) {
|
|
1578
|
+
return { by: "label", value: unquote3(labelMatch[1]) };
|
|
1579
|
+
}
|
|
1580
|
+
const textMatch = /^text=(.+)$/s.exec(trimmed);
|
|
1581
|
+
if (textMatch) {
|
|
1582
|
+
return { by: "text", value: unquote3(textMatch[1]) };
|
|
1583
|
+
}
|
|
1584
|
+
return { by: "text", value: trimmed };
|
|
1585
|
+
}
|
|
1586
|
+
function iosQueryToLocator(query) {
|
|
1587
|
+
switch (query.by) {
|
|
1588
|
+
case "accessibilityId":
|
|
1589
|
+
return { using: "accessibility id", value: query.value };
|
|
1590
|
+
case "label":
|
|
1591
|
+
return { using: "predicate string", value: `label == "${escapePredicateArg(query.value)}"` };
|
|
1592
|
+
case "text": {
|
|
1593
|
+
const escaped = escapePredicateArg(query.value);
|
|
1594
|
+
return {
|
|
1595
|
+
using: "predicate string",
|
|
1596
|
+
value: `label CONTAINS "${escaped}" OR value CONTAINS "${escaped}"`
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
case "focused":
|
|
1600
|
+
return { using: "predicate string", value: "hasKeyboardFocus == 1" };
|
|
1601
|
+
case "role": {
|
|
1602
|
+
const className = normalizeXcuiClassName(query.role);
|
|
1603
|
+
if (query.name === void 0 || query.name.length === 0) {
|
|
1604
|
+
return { using: "class name", value: className };
|
|
1605
|
+
}
|
|
1606
|
+
const escapedClass = escapePredicateArg(className);
|
|
1607
|
+
const escapedName = escapePredicateArg(query.name);
|
|
1608
|
+
return {
|
|
1609
|
+
using: "predicate string",
|
|
1610
|
+
value: `type == "${escapedClass}" AND (label CONTAINS "${escapedName}" OR value CONTAINS "${escapedName}")`
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
function unwrapIosTextSelector(selector) {
|
|
1616
|
+
const trimmed = selector.trim();
|
|
1617
|
+
if (!trimmed.startsWith("text=")) {
|
|
1618
|
+
return null;
|
|
1619
|
+
}
|
|
1620
|
+
return unquote3(trimmed.slice(5));
|
|
1621
|
+
}
|
|
1622
|
+
function delay2(ms) {
|
|
1623
|
+
return new Promise((resolve) => {
|
|
1624
|
+
setTimeout(resolve, ms);
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
function createIosDriver(client, options) {
|
|
1628
|
+
const unsupported = (verb) => new Error(`${verb} is not supported by the iOS target`);
|
|
1629
|
+
const rejectUnsupported = (verb) => Promise.reject(unsupported(verb));
|
|
1630
|
+
async function resolveOne(selector) {
|
|
1631
|
+
const id = await client.findElement(parseIosSelector(selector));
|
|
1632
|
+
if (id === null) {
|
|
1633
|
+
throw new Error(`No element matched selector: ${selector}`);
|
|
1634
|
+
}
|
|
1635
|
+
return id;
|
|
1636
|
+
}
|
|
1637
|
+
async function clickSelector(selector) {
|
|
1638
|
+
await client.click(await resolveOne(selector));
|
|
1639
|
+
}
|
|
1640
|
+
async function fillSelector(selector, value) {
|
|
1641
|
+
await client.setValue(await resolveOne(selector), value);
|
|
1642
|
+
}
|
|
1643
|
+
async function pressKey(key) {
|
|
1644
|
+
const name = key.trim().toLowerCase();
|
|
1645
|
+
if (name === "enter" || name === "return") {
|
|
1646
|
+
await client.sendKeys(["\n"]);
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
if (name === "delete" || name === "backspace" || name === "del") {
|
|
1650
|
+
await client.sendKeys(["\b"]);
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
if (name === "home") {
|
|
1654
|
+
await client.homescreen();
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
throw new Error(
|
|
1658
|
+
`Unsupported key "${key}" for the iOS target. Supported keys: ${IOS_PRESS_KEYS.join(", ")}.`
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
return {
|
|
1662
|
+
capabilities: IOS_CAPABILITIES,
|
|
1663
|
+
// navigation -----------------------------------------------------------
|
|
1664
|
+
goto(_url, _options) {
|
|
1665
|
+
return rejectUnsupported("navigate");
|
|
1666
|
+
},
|
|
1667
|
+
currentUrl() {
|
|
1668
|
+
return `ios:${options.appLabel ?? ""}`;
|
|
1669
|
+
},
|
|
1670
|
+
// queries --------------------------------------------------------------
|
|
1671
|
+
async count(selector) {
|
|
1672
|
+
return (await client.findElements(parseIosSelector(selector))).length;
|
|
1673
|
+
},
|
|
1674
|
+
async textContent(selector) {
|
|
1675
|
+
const id = await client.findElement(parseIosSelector(selector));
|
|
1676
|
+
if (id === null) {
|
|
1677
|
+
return null;
|
|
1678
|
+
}
|
|
1679
|
+
return client.getText(id);
|
|
1680
|
+
},
|
|
1681
|
+
// interactions ---------------------------------------------------------
|
|
1682
|
+
click: clickSelector,
|
|
1683
|
+
clickFirst: clickSelector,
|
|
1684
|
+
fill: fillSelector,
|
|
1685
|
+
fillFirst: fillSelector,
|
|
1686
|
+
async press(_selector, key) {
|
|
1687
|
+
await pressKey(key);
|
|
1688
|
+
},
|
|
1689
|
+
selectOption() {
|
|
1690
|
+
return rejectUnsupported("select");
|
|
1691
|
+
},
|
|
1692
|
+
selectOptionFirst() {
|
|
1693
|
+
return rejectUnsupported("select");
|
|
1694
|
+
},
|
|
1695
|
+
hover() {
|
|
1696
|
+
return rejectUnsupported("hover");
|
|
1697
|
+
},
|
|
1698
|
+
scrollIntoView() {
|
|
1699
|
+
return rejectUnsupported("scrollTo");
|
|
1700
|
+
},
|
|
1701
|
+
setInputFiles() {
|
|
1702
|
+
return rejectUnsupported("setInputFiles");
|
|
1703
|
+
},
|
|
1704
|
+
// semantic locators ----------------------------------------------------
|
|
1705
|
+
async countByRole(role, name) {
|
|
1706
|
+
return (await client.findElements({ by: "role", role, name })).length;
|
|
1707
|
+
},
|
|
1708
|
+
async clickFirstByRole(role, name) {
|
|
1709
|
+
const id = await client.findElement({ by: "role", role, name });
|
|
1710
|
+
if (id === null) {
|
|
1711
|
+
throw new Error(`No element matched role=${role}[name="${name}"]`);
|
|
1712
|
+
}
|
|
1713
|
+
await client.click(id);
|
|
1714
|
+
},
|
|
1715
|
+
async countByLabel(label) {
|
|
1716
|
+
return (await client.findElements({ by: "label", value: label })).length;
|
|
1717
|
+
},
|
|
1718
|
+
async fillFirstByLabel(label, value) {
|
|
1719
|
+
const id = await client.findElement({ by: "label", value: label });
|
|
1720
|
+
if (id === null) {
|
|
1721
|
+
throw new Error(`No element matched label="${label}"`);
|
|
1722
|
+
}
|
|
1723
|
+
await client.setValue(id, value);
|
|
1724
|
+
},
|
|
1725
|
+
selectOptionFirstByLabel() {
|
|
1726
|
+
return rejectUnsupported("select");
|
|
1727
|
+
},
|
|
1728
|
+
// waiting --------------------------------------------------------------
|
|
1729
|
+
async waitForSelector(selector, waitOptions) {
|
|
1730
|
+
const query = parseIosSelector(selector);
|
|
1731
|
+
const timeoutMs = waitOptions?.timeout ?? DEFAULT_WAIT_TIMEOUT_MS2;
|
|
1732
|
+
const deadline = Date.now() + timeoutMs;
|
|
1733
|
+
for (; ; ) {
|
|
1734
|
+
if ((await client.findElements(query)).length > 0) {
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
if (Date.now() >= deadline) {
|
|
1738
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for selector: ${selector}`);
|
|
1739
|
+
}
|
|
1740
|
+
await delay2(Math.min(WAIT_POLL_INTERVAL_MS2, Math.max(0, deadline - Date.now())));
|
|
1741
|
+
}
|
|
1742
|
+
},
|
|
1743
|
+
waitForUrl() {
|
|
1744
|
+
return rejectUnsupported("waitForUrl");
|
|
1745
|
+
},
|
|
1746
|
+
waitForNetworkIdle() {
|
|
1747
|
+
return rejectUnsupported("waitForNetworkIdle");
|
|
1748
|
+
},
|
|
1749
|
+
// scripting & artifacts ------------------------------------------------
|
|
1750
|
+
evaluate() {
|
|
1751
|
+
return rejectUnsupported("evalScript");
|
|
1752
|
+
},
|
|
1753
|
+
async screenshot(screenshotOptions) {
|
|
1754
|
+
await options.captureScreenshot(screenshotOptions.path);
|
|
1755
|
+
},
|
|
1756
|
+
// network / dialogs / downloads (all web-only) -------------------------
|
|
1757
|
+
onResponse(_handler) {
|
|
1758
|
+
throw unsupported("onResponse");
|
|
1759
|
+
},
|
|
1760
|
+
route(_url, _handler) {
|
|
1761
|
+
return rejectUnsupported("mockRoute");
|
|
1762
|
+
},
|
|
1763
|
+
unroute() {
|
|
1764
|
+
return rejectUnsupported("unmockRoute");
|
|
1765
|
+
},
|
|
1766
|
+
onDialog(_action) {
|
|
1767
|
+
throw unsupported("onDialog");
|
|
1768
|
+
},
|
|
1769
|
+
waitForDownloadEvent() {
|
|
1770
|
+
return rejectUnsupported("waitForDownload");
|
|
1771
|
+
},
|
|
1772
|
+
parseTextSelector(selector) {
|
|
1773
|
+
return unwrapIosTextSelector(selector);
|
|
1774
|
+
}
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// src/browser/ios-helper.ts
|
|
1779
|
+
var import_node_module2 = require("module");
|
|
1780
|
+
var import_node_fs5 = __toESM(require("fs"), 1);
|
|
1781
|
+
var import_node_os2 = __toESM(require("os"), 1);
|
|
1782
|
+
var import_node_path5 = __toESM(require("path"), 1);
|
|
1783
|
+
|
|
1784
|
+
// src/browser/ios-simctl.ts
|
|
1785
|
+
var import_node_child_process5 = require("child_process");
|
|
1786
|
+
var import_promises = require("fs/promises");
|
|
1787
|
+
var import_node_net = __toESM(require("net"), 1);
|
|
1788
|
+
var import_node_os = __toESM(require("os"), 1);
|
|
1789
|
+
var import_node_path4 = __toESM(require("path"), 1);
|
|
1790
|
+
var DEFAULT_SIMULATOR_LOCK_ROOT = import_node_path4.default.join(import_node_os.default.tmpdir(), "prowl-ios-simulator-locks");
|
|
1791
|
+
var SIMULATOR_LOCK_OWNER_FILE = "owner.json";
|
|
1792
|
+
var execFileXcrunRunner = (args, options) => new Promise((resolve) => {
|
|
1793
|
+
(0, import_node_child_process5.execFile)(
|
|
1794
|
+
"xcrun",
|
|
1795
|
+
args,
|
|
1796
|
+
{
|
|
1797
|
+
encoding: "utf-8",
|
|
1798
|
+
timeout: options?.timeoutMs,
|
|
1799
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
1800
|
+
env: options?.env ? { ...process.env, ...options.env } : process.env
|
|
1801
|
+
},
|
|
1802
|
+
(error, stdout, stderr) => {
|
|
1803
|
+
const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
|
|
1804
|
+
const capturedStderr = stderr ?? "";
|
|
1805
|
+
resolve({
|
|
1806
|
+
stdout: stdout ?? "",
|
|
1807
|
+
stderr: capturedStderr.trim() ? capturedStderr : error?.message ?? "",
|
|
1808
|
+
code
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
);
|
|
1812
|
+
});
|
|
1813
|
+
function simulatorLockName(udid) {
|
|
1814
|
+
const safe = udid.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
1815
|
+
return `${safe || "simulator"}.lock`;
|
|
1816
|
+
}
|
|
1817
|
+
function isErrno(error, code) {
|
|
1818
|
+
return error?.code === code;
|
|
1819
|
+
}
|
|
1820
|
+
function isProcessAlive(pid) {
|
|
1821
|
+
try {
|
|
1822
|
+
process.kill(pid, 0);
|
|
1823
|
+
return true;
|
|
1824
|
+
} catch (error) {
|
|
1825
|
+
return isErrno(error, "EPERM");
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
async function removeStaleSimulatorLock(lockPath) {
|
|
1829
|
+
try {
|
|
1830
|
+
const ownerText = await (0, import_promises.readFile)(import_node_path4.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE), "utf8");
|
|
1831
|
+
const owner = JSON.parse(ownerText);
|
|
1832
|
+
if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0 && !isProcessAlive(owner.pid)) {
|
|
1833
|
+
await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
|
|
1834
|
+
return true;
|
|
1835
|
+
}
|
|
1836
|
+
} catch {
|
|
1837
|
+
return false;
|
|
1838
|
+
}
|
|
1839
|
+
return false;
|
|
1840
|
+
}
|
|
1841
|
+
function simulatorReservedError(udid) {
|
|
1842
|
+
return new Error(
|
|
1843
|
+
`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.`
|
|
1844
|
+
);
|
|
1845
|
+
}
|
|
1846
|
+
async function reserveSimulatorUdid(udid, options = {}) {
|
|
1847
|
+
const lockRoot = options.lockRoot ?? DEFAULT_SIMULATOR_LOCK_ROOT;
|
|
1848
|
+
const lockPath = import_node_path4.default.join(lockRoot, simulatorLockName(udid));
|
|
1849
|
+
await (0, import_promises.mkdir)(lockRoot, { recursive: true });
|
|
1850
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1851
|
+
try {
|
|
1852
|
+
await (0, import_promises.mkdir)(lockPath);
|
|
1853
|
+
} catch (error) {
|
|
1854
|
+
if (!isErrno(error, "EEXIST")) {
|
|
1855
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
1856
|
+
}
|
|
1857
|
+
if (attempt === 0 && await removeStaleSimulatorLock(lockPath)) {
|
|
1858
|
+
continue;
|
|
1859
|
+
}
|
|
1860
|
+
throw simulatorReservedError(udid);
|
|
1861
|
+
}
|
|
1862
|
+
let released = false;
|
|
1863
|
+
const release = async () => {
|
|
1864
|
+
if (released) {
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
released = true;
|
|
1868
|
+
await (0, import_promises.rm)(lockPath, { recursive: true, force: true });
|
|
1869
|
+
};
|
|
1870
|
+
try {
|
|
1871
|
+
await (0, import_promises.writeFile)(
|
|
1872
|
+
import_node_path4.default.join(lockPath, SIMULATOR_LOCK_OWNER_FILE),
|
|
1873
|
+
`${JSON.stringify({ pid: process.pid, udid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
|
|
1874
|
+
`,
|
|
1875
|
+
{ flag: "wx" }
|
|
1876
|
+
);
|
|
1877
|
+
} catch (error) {
|
|
1878
|
+
await release().catch(() => void 0);
|
|
1879
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
1880
|
+
}
|
|
1881
|
+
return { udid, release };
|
|
1882
|
+
}
|
|
1883
|
+
throw simulatorReservedError(udid);
|
|
1884
|
+
}
|
|
1885
|
+
function findFreePort() {
|
|
1886
|
+
return new Promise((resolve, reject) => {
|
|
1887
|
+
const server = import_node_net.default.createServer();
|
|
1888
|
+
server.on("error", reject);
|
|
1889
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1890
|
+
const address = server.address();
|
|
1891
|
+
if (address && typeof address === "object") {
|
|
1892
|
+
const { port } = address;
|
|
1893
|
+
server.close(() => resolve(port));
|
|
1894
|
+
} else {
|
|
1895
|
+
server.close(() => reject(new Error("Could not allocate a local port")));
|
|
1896
|
+
}
|
|
1897
|
+
});
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
function parseSimctlDevices(json) {
|
|
1901
|
+
let parsed;
|
|
1902
|
+
try {
|
|
1903
|
+
parsed = JSON.parse(json);
|
|
1904
|
+
} catch {
|
|
1905
|
+
throw new Error("Could not parse `simctl list devices --json` output.");
|
|
1906
|
+
}
|
|
1907
|
+
const byRuntime = parsed?.devices;
|
|
1908
|
+
if (!byRuntime || typeof byRuntime !== "object") {
|
|
1909
|
+
return [];
|
|
1910
|
+
}
|
|
1911
|
+
const devices = [];
|
|
1912
|
+
for (const [runtime, entries] of Object.entries(byRuntime)) {
|
|
1913
|
+
if (!Array.isArray(entries)) {
|
|
1914
|
+
continue;
|
|
1915
|
+
}
|
|
1916
|
+
for (const entry of entries) {
|
|
1917
|
+
if (!entry || typeof entry !== "object") {
|
|
1918
|
+
continue;
|
|
1919
|
+
}
|
|
1920
|
+
const record = entry;
|
|
1921
|
+
const udid = typeof record.udid === "string" ? record.udid : void 0;
|
|
1922
|
+
const name = typeof record.name === "string" ? record.name : void 0;
|
|
1923
|
+
const state = typeof record.state === "string" ? record.state : "Unknown";
|
|
1924
|
+
if (!udid || !name) {
|
|
1925
|
+
continue;
|
|
1926
|
+
}
|
|
1927
|
+
devices.push({
|
|
1928
|
+
udid,
|
|
1929
|
+
name,
|
|
1930
|
+
state,
|
|
1931
|
+
runtime,
|
|
1932
|
+
isAvailable: record.isAvailable !== false
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
return devices;
|
|
1937
|
+
}
|
|
1938
|
+
function bootedSimulators(devices) {
|
|
1939
|
+
return devices.filter((device) => device.state === "Booted");
|
|
1940
|
+
}
|
|
1941
|
+
function describeSimulator(device) {
|
|
1942
|
+
const runtime = device.runtime.replace(/^com\.apple\.CoreSimulator\.SimRuntime\./, "");
|
|
1943
|
+
return `${device.name} [${runtime}] (${device.udid})`;
|
|
1944
|
+
}
|
|
1945
|
+
function selectSimulatorUdid(devices, requested) {
|
|
1946
|
+
const booted = bootedSimulators(devices);
|
|
1947
|
+
if (requested) {
|
|
1948
|
+
const match = devices.find((device) => device.udid === requested);
|
|
1949
|
+
if (!match) {
|
|
1950
|
+
const known = devices.length > 0 ? devices.map((d) => d.udid).join(", ") : "none";
|
|
1951
|
+
throw new Error(
|
|
1952
|
+
`iOS simulator "${requested}" was not found. Known simulators: ${known}. Check \`xcrun simctl list devices\`.`
|
|
1953
|
+
);
|
|
1954
|
+
}
|
|
1955
|
+
if (match.state !== "Booted") {
|
|
1956
|
+
throw new Error(
|
|
1957
|
+
`iOS simulator "${requested}" is not booted (state: ${match.state}). Boot it with \`xcrun simctl boot ${requested}\`, then retry.`
|
|
1958
|
+
);
|
|
1959
|
+
}
|
|
1960
|
+
return requested;
|
|
1961
|
+
}
|
|
1962
|
+
if (booted.length === 0) {
|
|
1963
|
+
throw new Error(
|
|
1964
|
+
"No booted iOS simulator found. Boot one from Xcode or with `xcrun simctl boot <udid>` (see `xcrun simctl list devices available`), then retry."
|
|
1965
|
+
);
|
|
1966
|
+
}
|
|
1967
|
+
if (booted.length > 1) {
|
|
1968
|
+
throw new Error(
|
|
1969
|
+
`Multiple iOS simulators are booted (${booted.map(describeSimulator).join("; ")}). Set target.udid to pick one.`
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
return booted[0].udid;
|
|
1973
|
+
}
|
|
1974
|
+
async function listSimulators(runner) {
|
|
1975
|
+
const result = await runner(["simctl", "list", "devices", "--json"], { timeoutMs: 3e4 });
|
|
1976
|
+
if (result.code !== 0) {
|
|
1977
|
+
throw new Error(
|
|
1978
|
+
`\`xcrun simctl list devices\` failed. Is Xcode installed and are the command-line tools selected (\`xcode-select -p\`)? ${(result.stderr.trim() || "").slice(0, 400)}`
|
|
1979
|
+
);
|
|
1980
|
+
}
|
|
1981
|
+
return parseSimctlDevices(result.stdout);
|
|
1982
|
+
}
|
|
1983
|
+
async function installApp(runner, udid, appPath) {
|
|
1984
|
+
const result = await runner(["simctl", "install", udid, appPath], { timeoutMs: 12e4 });
|
|
1985
|
+
if (result.code !== 0) {
|
|
1986
|
+
throw new Error(
|
|
1987
|
+
`Failed to install "${appPath}" onto simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}`
|
|
1988
|
+
);
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
async function uninstallApp(runner, udid, bundleId) {
|
|
1992
|
+
await runner(["simctl", "uninstall", udid, bundleId], { timeoutMs: 6e4 }).catch(() => void 0);
|
|
1993
|
+
}
|
|
1994
|
+
async function launchApp(runner, udid, bundleId, childEnv = {}) {
|
|
1995
|
+
const env = {};
|
|
1996
|
+
for (const [key, value] of Object.entries(childEnv)) {
|
|
1997
|
+
env[`SIMCTL_CHILD_${key}`] = value;
|
|
1998
|
+
}
|
|
1999
|
+
const result = await runner(["simctl", "launch", udid, bundleId], {
|
|
2000
|
+
timeoutMs: 6e4,
|
|
2001
|
+
env: Object.keys(env).length > 0 ? env : void 0
|
|
2002
|
+
});
|
|
2003
|
+
if (result.code !== 0) {
|
|
2004
|
+
throw new Error(
|
|
2005
|
+
`Failed to launch "${bundleId}" on simulator ${udid}: ${(result.stderr.trim() || result.stdout.trim()).slice(0, 400)}. Is it installed?`
|
|
2006
|
+
);
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
async function terminateApp(runner, udid, bundleId) {
|
|
2010
|
+
await runner(["simctl", "terminate", udid, bundleId], { timeoutMs: 3e4 }).catch(() => void 0);
|
|
2011
|
+
}
|
|
2012
|
+
async function captureScreenshot(runner, udid, outPath) {
|
|
2013
|
+
const result = await runner(["simctl", "io", udid, "screenshot", outPath], { timeoutMs: 3e4 });
|
|
2014
|
+
if (result.code !== 0) {
|
|
2015
|
+
throw new Error(
|
|
2016
|
+
`Failed to capture a simulator screenshot (${udid}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 300)}`
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
function parseXcodeVersion(stdout) {
|
|
2021
|
+
const match = /Xcode\s+([\d.]+)/i.exec(stdout);
|
|
2022
|
+
return match?.[1] ?? null;
|
|
2023
|
+
}
|
|
2024
|
+
async function xcodeVersion(runner) {
|
|
2025
|
+
const result = await runner(["xcodebuild", "-version"], { timeoutMs: 3e4 });
|
|
2026
|
+
if (result.code !== 0) {
|
|
2027
|
+
return null;
|
|
2028
|
+
}
|
|
2029
|
+
return parseXcodeVersion(result.stdout);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// src/browser/ios-agent.ts
|
|
2033
|
+
var DEFAULT_WDA_REQUEST_TIMEOUT_MS = 3e4;
|
|
2034
|
+
var W3C_ELEMENT_KEY2 = "element-6066-11e4-a52e-4f735466cecf";
|
|
2035
|
+
var WdaHttpError = class extends Error {
|
|
2036
|
+
constructor(message, status, webdriverError) {
|
|
2037
|
+
super(message);
|
|
2038
|
+
this.status = status;
|
|
2039
|
+
this.webdriverError = webdriverError;
|
|
2040
|
+
this.name = "WdaHttpError";
|
|
2041
|
+
}
|
|
2042
|
+
};
|
|
2043
|
+
var WdaTransport = class {
|
|
2044
|
+
baseUrl;
|
|
2045
|
+
requestTimeoutMs;
|
|
2046
|
+
fetchImpl;
|
|
2047
|
+
constructor(options) {
|
|
2048
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
2049
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_WDA_REQUEST_TIMEOUT_MS;
|
|
2050
|
+
const injected = options.fetchImpl;
|
|
2051
|
+
if (injected) {
|
|
2052
|
+
this.fetchImpl = injected;
|
|
2053
|
+
} else if (typeof fetch === "function") {
|
|
2054
|
+
this.fetchImpl = (url, init) => fetch(url, init);
|
|
2055
|
+
} else {
|
|
2056
|
+
throw new Error("global fetch is unavailable; Node 20+ is required for the iOS target");
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
/**
|
|
2060
|
+
* Send one request and return the full parsed JSON body. Rejects with a
|
|
2061
|
+
* {@link WdaHttpError} on a non-2xx response, or a timeout error when the
|
|
2062
|
+
* per-request deadline elapses.
|
|
2063
|
+
*/
|
|
2064
|
+
async requestFull(method, path18, body, timeoutMs) {
|
|
2065
|
+
const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
|
|
2066
|
+
const controller = new AbortController();
|
|
2067
|
+
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
2068
|
+
timer.unref?.();
|
|
2069
|
+
const url = `${this.baseUrl}${path18}`;
|
|
2070
|
+
const timeoutError = () => {
|
|
2071
|
+
const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
|
|
2072
|
+
return new Error(`WebDriverAgent request ${method} ${path18} timed out after ${shown}`);
|
|
2073
|
+
};
|
|
2074
|
+
let response;
|
|
2075
|
+
try {
|
|
2076
|
+
response = await this.fetchImpl(url, {
|
|
2077
|
+
method,
|
|
2078
|
+
signal: controller.signal,
|
|
2079
|
+
headers: body !== void 0 ? { "content-type": "application/json" } : void 0,
|
|
2080
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
2081
|
+
});
|
|
2082
|
+
} catch (error) {
|
|
2083
|
+
clearTimeout(timer);
|
|
2084
|
+
if (controller.signal.aborted) {
|
|
2085
|
+
throw timeoutError();
|
|
556
2086
|
}
|
|
2087
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
557
2088
|
}
|
|
558
|
-
|
|
559
|
-
|
|
2089
|
+
let text;
|
|
2090
|
+
try {
|
|
2091
|
+
text = await response.text();
|
|
2092
|
+
} catch (error) {
|
|
2093
|
+
if (controller.signal.aborted) {
|
|
2094
|
+
throw timeoutError();
|
|
2095
|
+
}
|
|
2096
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
2097
|
+
} finally {
|
|
2098
|
+
clearTimeout(timer);
|
|
2099
|
+
}
|
|
2100
|
+
const parsed = parseJson2(text);
|
|
2101
|
+
if (!response.ok) {
|
|
2102
|
+
const wdError = extractWebdriverError2(parsed);
|
|
2103
|
+
throw new WdaHttpError(
|
|
2104
|
+
`WebDriverAgent ${method} ${path18} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
|
|
2105
|
+
response.status,
|
|
2106
|
+
wdError
|
|
2107
|
+
);
|
|
560
2108
|
}
|
|
2109
|
+
return parsed;
|
|
2110
|
+
}
|
|
2111
|
+
/** Like {@link requestFull} but returns just the `value` field. */
|
|
2112
|
+
async request(method, path18, body, timeoutMs) {
|
|
2113
|
+
const parsed = await this.requestFull(method, path18, body, timeoutMs);
|
|
2114
|
+
return parsed?.value;
|
|
2115
|
+
}
|
|
2116
|
+
};
|
|
2117
|
+
function parseJson2(text) {
|
|
2118
|
+
if (!text) {
|
|
2119
|
+
return void 0;
|
|
2120
|
+
}
|
|
2121
|
+
try {
|
|
2122
|
+
return JSON.parse(text);
|
|
2123
|
+
} catch {
|
|
2124
|
+
return void 0;
|
|
561
2125
|
}
|
|
562
2126
|
}
|
|
563
|
-
function
|
|
564
|
-
|
|
565
|
-
|
|
2127
|
+
function extractWebdriverError2(parsed) {
|
|
2128
|
+
const value = parsed?.value;
|
|
2129
|
+
if (value && typeof value === "object") {
|
|
2130
|
+
const record = value;
|
|
2131
|
+
const error = typeof record.error === "string" ? record.error : void 0;
|
|
2132
|
+
const message = typeof record.message === "string" ? record.message : void 0;
|
|
2133
|
+
return error ?? message;
|
|
566
2134
|
}
|
|
567
|
-
|
|
568
|
-
"Hunt-level assertions are not supported by the macOS target. Use inline assert visible/notVisible steps instead."
|
|
569
|
-
);
|
|
2135
|
+
return void 0;
|
|
570
2136
|
}
|
|
571
|
-
function
|
|
572
|
-
|
|
2137
|
+
function extractElementId2(value) {
|
|
2138
|
+
if (!value || typeof value !== "object") {
|
|
2139
|
+
return null;
|
|
2140
|
+
}
|
|
2141
|
+
const record = value;
|
|
2142
|
+
const id = record[W3C_ELEMENT_KEY2] ?? record.ELEMENT;
|
|
2143
|
+
return typeof id === "string" ? id : null;
|
|
573
2144
|
}
|
|
574
|
-
function
|
|
575
|
-
|
|
576
|
-
|
|
2145
|
+
function isNoSuchElement2(error) {
|
|
2146
|
+
if (error instanceof WdaHttpError) {
|
|
2147
|
+
return error.status === 404 || (error.webdriverError ?? "").includes("no such element");
|
|
2148
|
+
}
|
|
2149
|
+
return false;
|
|
577
2150
|
}
|
|
578
|
-
function
|
|
579
|
-
|
|
2151
|
+
async function createWdaSession(transport, bundleId) {
|
|
2152
|
+
const body = await transport.requestFull("POST", "/session", {
|
|
2153
|
+
capabilities: { alwaysMatch: { bundleId }, firstMatch: [{}] }
|
|
2154
|
+
});
|
|
2155
|
+
const envelope = body ?? {};
|
|
2156
|
+
const value = envelope.value ?? {};
|
|
2157
|
+
const sessionId = typeof value.sessionId === "string" && value.sessionId || typeof envelope.sessionId === "string" && envelope.sessionId || "";
|
|
2158
|
+
if (sessionId.length > 0) {
|
|
2159
|
+
return sessionId;
|
|
2160
|
+
}
|
|
2161
|
+
throw new Error("WebDriverAgent did not return a session id");
|
|
2162
|
+
}
|
|
2163
|
+
async function waitForWdaReady(transport, options = { deadlineMs: 6e4 }) {
|
|
2164
|
+
const interval = options.intervalMs ?? 300;
|
|
2165
|
+
const deadline = Date.now() + options.deadlineMs;
|
|
2166
|
+
let lastError;
|
|
2167
|
+
for (; ; ) {
|
|
2168
|
+
try {
|
|
2169
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
2170
|
+
await transport.request("GET", "/status", void 0, Math.min(remainingMs, 5e3));
|
|
2171
|
+
return;
|
|
2172
|
+
} catch (error) {
|
|
2173
|
+
lastError = error;
|
|
2174
|
+
}
|
|
2175
|
+
if (Date.now() >= deadline) {
|
|
2176
|
+
const detail = lastError instanceof Error ? `: ${lastError.message}` : "";
|
|
2177
|
+
throw new Error(`WebDriverAgent did not become ready within ${options.deadlineMs}ms${detail}`);
|
|
2178
|
+
}
|
|
2179
|
+
await sleep2(Math.min(interval, Math.max(0, deadline - Date.now())));
|
|
2180
|
+
}
|
|
580
2181
|
}
|
|
581
|
-
function
|
|
582
|
-
|
|
583
|
-
|
|
2182
|
+
function sleep2(ms) {
|
|
2183
|
+
return new Promise((resolve) => {
|
|
2184
|
+
setTimeout(resolve, ms);
|
|
2185
|
+
});
|
|
584
2186
|
}
|
|
585
|
-
function
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
return
|
|
2187
|
+
function createWdaAgentClient(transport, sessionId) {
|
|
2188
|
+
const base = `/session/${sessionId}`;
|
|
2189
|
+
async function locate(query, path18) {
|
|
2190
|
+
return transport.request("POST", `${base}${path18}`, iosQueryToLocator(query));
|
|
589
2191
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
2192
|
+
return {
|
|
2193
|
+
async findElement(query) {
|
|
2194
|
+
try {
|
|
2195
|
+
return extractElementId2(await locate(query, "/element"));
|
|
2196
|
+
} catch (error) {
|
|
2197
|
+
if (isNoSuchElement2(error)) {
|
|
2198
|
+
return null;
|
|
2199
|
+
}
|
|
2200
|
+
throw error;
|
|
2201
|
+
}
|
|
2202
|
+
},
|
|
2203
|
+
async findElements(query) {
|
|
2204
|
+
const value = await locate(query, "/elements");
|
|
2205
|
+
if (!Array.isArray(value)) {
|
|
2206
|
+
return [];
|
|
2207
|
+
}
|
|
2208
|
+
return value.map((entry) => extractElementId2(entry)).filter((id) => id !== null);
|
|
2209
|
+
},
|
|
2210
|
+
async click(elementId) {
|
|
2211
|
+
await transport.request("POST", `${base}/element/${elementId}/click`, {});
|
|
2212
|
+
},
|
|
2213
|
+
async setValue(elementId, text) {
|
|
2214
|
+
await transport.request("POST", `${base}/element/${elementId}/value`, { text });
|
|
2215
|
+
},
|
|
2216
|
+
async getText(elementId) {
|
|
2217
|
+
const value = await transport.request("GET", `${base}/element/${elementId}/text`);
|
|
2218
|
+
return typeof value === "string" ? value : value == null ? null : String(value);
|
|
2219
|
+
},
|
|
2220
|
+
async sendKeys(keys) {
|
|
2221
|
+
await transport.request("POST", `${base}/wda/keys`, { value: keys });
|
|
2222
|
+
},
|
|
2223
|
+
async homescreen() {
|
|
2224
|
+
await transport.request("POST", "/wda/homescreen", {});
|
|
2225
|
+
},
|
|
2226
|
+
async close() {
|
|
2227
|
+
await transport.request("DELETE", base).catch(() => void 0);
|
|
594
2228
|
}
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
// src/browser/ios-helper.ts
|
|
2233
|
+
var import_meta3 = {};
|
|
2234
|
+
var WDA_RUNNER_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
|
|
2235
|
+
var WDA_RUNNER_APP_NAME = "WebDriverAgentRunner-Runner.app";
|
|
2236
|
+
var WDA_USE_PORT_ENV = "USE_PORT";
|
|
2237
|
+
var WDA_STARTUP_ATTEMPTS = 2;
|
|
2238
|
+
var defaultIosAgentConnector = async ({
|
|
2239
|
+
host,
|
|
2240
|
+
port,
|
|
2241
|
+
bundleId,
|
|
2242
|
+
requestTimeoutMs,
|
|
2243
|
+
readyDeadlineMs
|
|
2244
|
+
}) => {
|
|
2245
|
+
const transport = new WdaTransport({ baseUrl: `http://${host}:${port}`, requestTimeoutMs });
|
|
2246
|
+
await waitForWdaReady(transport, { deadlineMs: readyDeadlineMs });
|
|
2247
|
+
const sessionId = await createWdaSession(transport, bundleId);
|
|
2248
|
+
return createWdaAgentClient(transport, sessionId);
|
|
2249
|
+
};
|
|
2250
|
+
function resolveWdaProject(requireFn = (0, import_node_module2.createRequire)(import_meta3.url)) {
|
|
2251
|
+
let pkgJsonPath;
|
|
2252
|
+
try {
|
|
2253
|
+
pkgJsonPath = requireFn.resolve("appium-webdriveragent/package.json");
|
|
595
2254
|
} catch {
|
|
2255
|
+
throw new Error(
|
|
2256
|
+
"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"
|
|
2257
|
+
);
|
|
596
2258
|
}
|
|
597
|
-
|
|
598
|
-
|
|
2259
|
+
const pkgDir = import_node_path5.default.dirname(pkgJsonPath);
|
|
2260
|
+
const version = requireFn(pkgJsonPath).version;
|
|
2261
|
+
const projectPath = import_node_path5.default.join(pkgDir, "WebDriverAgent.xcodeproj");
|
|
2262
|
+
if (!import_node_fs5.default.existsSync(projectPath)) {
|
|
2263
|
+
throw new Error(`Expected WebDriverAgent project is missing: ${projectPath}. Reinstall dependencies.`);
|
|
599
2264
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
2265
|
+
return { projectPath, version };
|
|
2266
|
+
}
|
|
2267
|
+
function wdaCacheDir(wdaVersion, xcode, homeDir = import_node_os2.default.homedir()) {
|
|
2268
|
+
return import_node_path5.default.join(homeDir, ".prowl", "wda", `${wdaVersion}-xcode${xcode}`);
|
|
2269
|
+
}
|
|
2270
|
+
function runnerAppPath(derivedDataPath) {
|
|
2271
|
+
return import_node_path5.default.join(derivedDataPath, "Build", "Products", "Debug-iphonesimulator", WDA_RUNNER_APP_NAME);
|
|
2272
|
+
}
|
|
2273
|
+
async function resolveWdaRunner(options = {}) {
|
|
2274
|
+
const runner = options.runner ?? execFileXcrunRunner;
|
|
2275
|
+
const env = options.env ?? process.env;
|
|
2276
|
+
const homeDir = options.homeDir ?? import_node_os2.default.homedir();
|
|
2277
|
+
const log = options.logger ?? ((message) => process.stderr.write(`${message}
|
|
2278
|
+
`));
|
|
2279
|
+
const override = env.PROWL_WDA_RUNNER;
|
|
2280
|
+
if (override) {
|
|
2281
|
+
if (!import_node_fs5.default.existsSync(override)) {
|
|
2282
|
+
throw new Error(`PROWL_WDA_RUNNER points at a missing path: ${override}`);
|
|
2283
|
+
}
|
|
2284
|
+
return override;
|
|
2285
|
+
}
|
|
2286
|
+
const { projectPath, version } = resolveWdaProject(options.requireFn);
|
|
2287
|
+
const xcode = await xcodeVersion(runner);
|
|
2288
|
+
if (!xcode) {
|
|
2289
|
+
throw new Error(
|
|
2290
|
+
"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."
|
|
2291
|
+
);
|
|
2292
|
+
}
|
|
2293
|
+
const cacheDir = wdaCacheDir(version, xcode, homeDir);
|
|
2294
|
+
const cachedRunner = runnerAppPath(cacheDir);
|
|
2295
|
+
if (import_node_fs5.default.existsSync(cachedRunner)) {
|
|
2296
|
+
return cachedRunner;
|
|
2297
|
+
}
|
|
2298
|
+
log(
|
|
2299
|
+
`Prowl: building WebDriverAgent for the iOS target (first run only; this can take a few minutes)\u2026`
|
|
2300
|
+
);
|
|
2301
|
+
import_node_fs5.default.mkdirSync(cacheDir, { recursive: true });
|
|
2302
|
+
const result = await runner(
|
|
2303
|
+
[
|
|
2304
|
+
"xcodebuild",
|
|
2305
|
+
"build-for-testing",
|
|
2306
|
+
"-project",
|
|
2307
|
+
projectPath,
|
|
2308
|
+
"-scheme",
|
|
2309
|
+
"WebDriverAgentRunner",
|
|
2310
|
+
"-destination",
|
|
2311
|
+
"generic/platform=iOS Simulator",
|
|
2312
|
+
"-derivedDataPath",
|
|
2313
|
+
cacheDir,
|
|
2314
|
+
"CODE_SIGNING_ALLOWED=NO"
|
|
2315
|
+
],
|
|
2316
|
+
{ timeoutMs: 12e5 }
|
|
2317
|
+
);
|
|
2318
|
+
if (result.code !== 0) {
|
|
2319
|
+
throw new Error(
|
|
2320
|
+
`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)}`
|
|
605
2321
|
);
|
|
606
|
-
return output.trim() || null;
|
|
607
|
-
} catch {
|
|
608
|
-
return null;
|
|
609
2322
|
}
|
|
2323
|
+
if (!import_node_fs5.default.existsSync(cachedRunner)) {
|
|
2324
|
+
throw new Error(
|
|
2325
|
+
`WebDriverAgent build succeeded but the runner app was not found at ${cachedRunner}. This may indicate an Xcode layout change; set PROWL_WDA_RUNNER to a prebuilt runner.`
|
|
2326
|
+
);
|
|
2327
|
+
}
|
|
2328
|
+
return cachedRunner;
|
|
610
2329
|
}
|
|
611
|
-
function
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
if (bundleName) {
|
|
619
|
-
identities.add(bundleName);
|
|
620
|
-
}
|
|
621
|
-
const bundleId = readBundleIdentifier(app);
|
|
622
|
-
if (bundleId) {
|
|
623
|
-
identities.add(bundleId);
|
|
2330
|
+
async function resolveBundleId(app, runner, udid, coldStart, allowedApps) {
|
|
2331
|
+
if (!looksLikeIosAppPath(app)) {
|
|
2332
|
+
assertIosAppAllowed(allowedApps, app);
|
|
2333
|
+
if (coldStart) {
|
|
2334
|
+
throw new Error(
|
|
2335
|
+
`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.`
|
|
2336
|
+
);
|
|
624
2337
|
}
|
|
2338
|
+
return app;
|
|
625
2339
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
if (allowedApps.length === 0) {
|
|
630
|
-
return;
|
|
2340
|
+
const appPath = import_node_path5.default.resolve(app);
|
|
2341
|
+
if (!import_node_fs5.default.existsSync(appPath)) {
|
|
2342
|
+
throw new Error(`.app bundle not found: ${appPath}`);
|
|
631
2343
|
}
|
|
632
|
-
const
|
|
633
|
-
if (
|
|
634
|
-
|
|
2344
|
+
const bundleId = readIosBundleIdentifier(appPath);
|
|
2345
|
+
if (!bundleId) {
|
|
2346
|
+
throw new Error(
|
|
2347
|
+
`Could not read CFBundleIdentifier from "${appPath}" (root Info.plist). Ensure target.app points at a built iOS .app bundle.`
|
|
2348
|
+
);
|
|
2349
|
+
}
|
|
2350
|
+
assertIosAppAllowed(allowedApps, appPath);
|
|
2351
|
+
if (coldStart) {
|
|
2352
|
+
await uninstallApp(runner, udid, bundleId);
|
|
2353
|
+
}
|
|
2354
|
+
await installApp(runner, udid, appPath);
|
|
2355
|
+
return bundleId;
|
|
2356
|
+
}
|
|
2357
|
+
async function launchIosSession(options) {
|
|
2358
|
+
const runner = options.runner ?? execFileXcrunRunner;
|
|
2359
|
+
const portAllocator = options.portAllocator ?? findFreePort;
|
|
2360
|
+
const connector = options.agentConnector ?? defaultIosAgentConnector;
|
|
2361
|
+
const requestTimeoutMs = Math.max(options.timeoutMs ?? 1e4, DEFAULT_WDA_REQUEST_TIMEOUT_MS) + 5e3;
|
|
2362
|
+
const readyDeadlineMs = Math.max(options.timeoutMs ?? 1e4, 6e4);
|
|
2363
|
+
const devices = await listSimulators(runner);
|
|
2364
|
+
const udid = selectSimulatorUdid(devices, options.udid);
|
|
2365
|
+
const reservation = await reserveSimulatorUdid(udid, { lockRoot: options.simulatorLockRoot });
|
|
2366
|
+
let reservationReleased = false;
|
|
2367
|
+
const releaseReservation = async () => {
|
|
2368
|
+
if (reservationReleased) {
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
reservationReleased = true;
|
|
2372
|
+
await reservation.release();
|
|
2373
|
+
};
|
|
2374
|
+
try {
|
|
2375
|
+
const bundleId = await resolveBundleId(
|
|
2376
|
+
options.app,
|
|
2377
|
+
runner,
|
|
2378
|
+
udid,
|
|
2379
|
+
options.coldStart ?? false,
|
|
2380
|
+
options.allowedApps ?? []
|
|
2381
|
+
);
|
|
2382
|
+
const wdaRunnerApp = options.wdaRunnerApp ?? await resolveWdaRunner({ runner, logger: options.logger });
|
|
2383
|
+
await installApp(runner, udid, wdaRunnerApp);
|
|
2384
|
+
for (let attempt = 1; attempt <= WDA_STARTUP_ATTEMPTS; attempt += 1) {
|
|
2385
|
+
let client;
|
|
2386
|
+
let tornDown = false;
|
|
2387
|
+
let terminateWda = false;
|
|
2388
|
+
let terminateTarget = false;
|
|
2389
|
+
let stage = "port";
|
|
2390
|
+
const teardownAttempt = async () => {
|
|
2391
|
+
if (tornDown) {
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
tornDown = true;
|
|
2395
|
+
if (client) {
|
|
2396
|
+
await client.close().catch(() => void 0);
|
|
2397
|
+
}
|
|
2398
|
+
if (terminateWda) {
|
|
2399
|
+
await terminateApp(runner, udid, WDA_RUNNER_BUNDLE_ID);
|
|
2400
|
+
}
|
|
2401
|
+
if (terminateTarget) {
|
|
2402
|
+
await terminateApp(runner, udid, bundleId);
|
|
2403
|
+
}
|
|
2404
|
+
};
|
|
2405
|
+
try {
|
|
2406
|
+
const port = await portAllocator();
|
|
2407
|
+
stage = "wda-launch";
|
|
2408
|
+
terminateWda = true;
|
|
2409
|
+
await launchApp(runner, udid, WDA_RUNNER_BUNDLE_ID, { [WDA_USE_PORT_ENV]: String(port) });
|
|
2410
|
+
stage = "target-launch";
|
|
2411
|
+
terminateTarget = true;
|
|
2412
|
+
await launchApp(runner, udid, bundleId);
|
|
2413
|
+
stage = "connect";
|
|
2414
|
+
client = await connector({ host: "127.0.0.1", port, bundleId, requestTimeoutMs, readyDeadlineMs });
|
|
2415
|
+
const driver = createIosDriver(client, {
|
|
2416
|
+
appLabel: bundleId,
|
|
2417
|
+
captureScreenshot: (outPath) => captureScreenshot(runner, udid, outPath)
|
|
2418
|
+
});
|
|
2419
|
+
const teardown = async () => {
|
|
2420
|
+
await teardownAttempt();
|
|
2421
|
+
await releaseReservation();
|
|
2422
|
+
};
|
|
2423
|
+
return { client, driver, bundleId, udid, teardown };
|
|
2424
|
+
} catch (error) {
|
|
2425
|
+
await teardownAttempt();
|
|
2426
|
+
if (stage === "target-launch" || attempt >= WDA_STARTUP_ATTEMPTS) {
|
|
2427
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
throw new Error("WebDriverAgent startup failed without an error.");
|
|
2432
|
+
} catch (error) {
|
|
2433
|
+
await releaseReservation().catch(() => void 0);
|
|
2434
|
+
throw error;
|
|
635
2435
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
);
|
|
2436
|
+
}
|
|
2437
|
+
async function closeIosSession(session) {
|
|
2438
|
+
await session.teardown();
|
|
639
2439
|
}
|
|
640
2440
|
|
|
641
2441
|
// src/runner/index.ts
|
|
642
|
-
var
|
|
643
|
-
var
|
|
2442
|
+
var import_node_fs14 = __toESM(require("fs"), 1);
|
|
2443
|
+
var import_node_path14 = __toESM(require("path"), 1);
|
|
644
2444
|
|
|
645
2445
|
// src/config/loader.ts
|
|
646
|
-
var
|
|
647
|
-
var
|
|
2446
|
+
var import_node_fs6 = __toESM(require("fs"), 1);
|
|
2447
|
+
var import_node_path6 = __toESM(require("path"), 1);
|
|
648
2448
|
var import_yaml = __toESM(require("yaml"), 1);
|
|
649
2449
|
var import_dotenv = __toESM(require("dotenv"), 1);
|
|
650
2450
|
|
|
@@ -676,7 +2476,24 @@ var macosTargetSchema = import_zod.z.object({
|
|
|
676
2476
|
type: import_zod.z.literal("macos"),
|
|
677
2477
|
app: import_zod.z.string().min(1)
|
|
678
2478
|
}).strict();
|
|
679
|
-
var
|
|
2479
|
+
var androidTargetSchema = import_zod.z.object({
|
|
2480
|
+
type: import_zod.z.literal("android"),
|
|
2481
|
+
app: import_zod.z.string().min(1),
|
|
2482
|
+
deviceSerial: import_zod.z.string().min(1).optional(),
|
|
2483
|
+
coldStart: import_zod.z.boolean().optional()
|
|
2484
|
+
}).strict();
|
|
2485
|
+
var iosTargetSchema = import_zod.z.object({
|
|
2486
|
+
type: import_zod.z.literal("ios"),
|
|
2487
|
+
app: import_zod.z.string().min(1),
|
|
2488
|
+
udid: import_zod.z.string().min(1).optional(),
|
|
2489
|
+
coldStart: import_zod.z.boolean().optional()
|
|
2490
|
+
}).strict();
|
|
2491
|
+
var targetSchema = import_zod.z.union([
|
|
2492
|
+
macosTargetSchema,
|
|
2493
|
+
androidTargetSchema,
|
|
2494
|
+
iosTargetSchema,
|
|
2495
|
+
webTargetSchema
|
|
2496
|
+
]);
|
|
680
2497
|
var configSchema = import_zod.z.object({
|
|
681
2498
|
target: targetSchema,
|
|
682
2499
|
browser: import_zod.z.object({
|
|
@@ -1018,12 +2835,12 @@ function findConfigPath(startDir) {
|
|
|
1018
2835
|
let current = startDir;
|
|
1019
2836
|
while (current) {
|
|
1020
2837
|
for (const dir of [CONFIG_DIR, LEGACY_CONFIG_DIR]) {
|
|
1021
|
-
const candidate =
|
|
1022
|
-
if (
|
|
2838
|
+
const candidate = import_node_path6.default.join(current, dir, "config.yml");
|
|
2839
|
+
if (import_node_fs6.default.existsSync(candidate)) {
|
|
1023
2840
|
return candidate;
|
|
1024
2841
|
}
|
|
1025
2842
|
}
|
|
1026
|
-
const parent =
|
|
2843
|
+
const parent = import_node_path6.default.dirname(current);
|
|
1027
2844
|
if (parent === current) {
|
|
1028
2845
|
break;
|
|
1029
2846
|
}
|
|
@@ -1050,9 +2867,28 @@ function resolveViewport(value) {
|
|
|
1050
2867
|
return value;
|
|
1051
2868
|
}
|
|
1052
2869
|
function resolveTarget(target) {
|
|
1053
|
-
|
|
2870
|
+
const type = target?.type;
|
|
2871
|
+
if (type === "macos") {
|
|
1054
2872
|
return { type: "macos", app: target.app };
|
|
1055
2873
|
}
|
|
2874
|
+
if (type === "android") {
|
|
2875
|
+
const androidTarget = target;
|
|
2876
|
+
return {
|
|
2877
|
+
type: "android",
|
|
2878
|
+
app: androidTarget.app,
|
|
2879
|
+
...androidTarget.deviceSerial !== void 0 ? { deviceSerial: androidTarget.deviceSerial } : {},
|
|
2880
|
+
...androidTarget.coldStart !== void 0 ? { coldStart: androidTarget.coldStart } : {}
|
|
2881
|
+
};
|
|
2882
|
+
}
|
|
2883
|
+
if (type === "ios") {
|
|
2884
|
+
const iosTarget = target;
|
|
2885
|
+
return {
|
|
2886
|
+
type: "ios",
|
|
2887
|
+
app: iosTarget.app,
|
|
2888
|
+
...iosTarget.udid !== void 0 ? { udid: iosTarget.udid } : {},
|
|
2889
|
+
...iosTarget.coldStart !== void 0 ? { coldStart: iosTarget.coldStart } : {}
|
|
2890
|
+
};
|
|
2891
|
+
}
|
|
1056
2892
|
return {
|
|
1057
2893
|
type: "web",
|
|
1058
2894
|
url: target?.url ?? DEFAULT_WEB_URL
|
|
@@ -1111,19 +2947,19 @@ function ensureAllowedDomain(allowed, urlValue) {
|
|
|
1111
2947
|
return allowed;
|
|
1112
2948
|
}
|
|
1113
2949
|
function loadConfig(configPath) {
|
|
1114
|
-
const resolvedPath = configPath ?
|
|
2950
|
+
const resolvedPath = configPath ? import_node_path6.default.resolve(configPath) : findConfigPath(process.cwd());
|
|
1115
2951
|
if (!resolvedPath) {
|
|
1116
2952
|
throw new Error("Could not find .prowl/config.yml. Run `prowl init` first.");
|
|
1117
2953
|
}
|
|
1118
|
-
if (!
|
|
2954
|
+
if (!import_node_fs6.default.existsSync(resolvedPath)) {
|
|
1119
2955
|
throw new Error(`Config file not found at ${resolvedPath}`);
|
|
1120
2956
|
}
|
|
1121
|
-
const configDir =
|
|
1122
|
-
if (
|
|
2957
|
+
const configDir = import_node_path6.default.dirname(resolvedPath);
|
|
2958
|
+
if (import_node_path6.default.basename(configDir) === LEGACY_CONFIG_DIR) {
|
|
1123
2959
|
warnLegacyConfigDir();
|
|
1124
2960
|
}
|
|
1125
|
-
import_dotenv.default.config({ path:
|
|
1126
|
-
const raw =
|
|
2961
|
+
import_dotenv.default.config({ path: import_node_path6.default.join(configDir, ".env"), override: false });
|
|
2962
|
+
const raw = import_node_fs6.default.readFileSync(resolvedPath, "utf-8");
|
|
1127
2963
|
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
1128
2964
|
const validated = configSchema.parse(parsed);
|
|
1129
2965
|
const config = mergeConfig(validated);
|
|
@@ -1137,32 +2973,32 @@ function loadConfig(configPath) {
|
|
|
1137
2973
|
}
|
|
1138
2974
|
function loadHunt(huntName, configDir) {
|
|
1139
2975
|
assertValidHuntName(huntName);
|
|
1140
|
-
const huntPath =
|
|
1141
|
-
if (!
|
|
2976
|
+
const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
|
|
2977
|
+
if (!import_node_fs6.default.existsSync(huntPath)) {
|
|
1142
2978
|
throw new Error(`Hunt file not found: ${huntPath}`);
|
|
1143
2979
|
}
|
|
1144
|
-
const raw =
|
|
2980
|
+
const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
|
|
1145
2981
|
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
1146
2982
|
const validated = huntSchema.parse(parsed);
|
|
1147
2983
|
return validated;
|
|
1148
2984
|
}
|
|
1149
2985
|
function loadHuntTags(huntName, configDir) {
|
|
1150
2986
|
assertValidHuntName(huntName);
|
|
1151
|
-
const huntPath =
|
|
1152
|
-
if (!
|
|
2987
|
+
const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
|
|
2988
|
+
if (!import_node_fs6.default.existsSync(huntPath)) {
|
|
1153
2989
|
return [];
|
|
1154
2990
|
}
|
|
1155
|
-
const raw =
|
|
2991
|
+
const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
|
|
1156
2992
|
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
1157
2993
|
return Array.isArray(parsed.tags) ? parsed.tags : [];
|
|
1158
2994
|
}
|
|
1159
2995
|
function loadHuntMeta(huntName, configDir) {
|
|
1160
2996
|
assertValidHuntName(huntName);
|
|
1161
|
-
const huntPath =
|
|
1162
|
-
if (!
|
|
2997
|
+
const huntPath = import_node_path6.default.join(configDir, "hunts", `${huntName}.yml`);
|
|
2998
|
+
if (!import_node_fs6.default.existsSync(huntPath)) {
|
|
1163
2999
|
return { tags: [] };
|
|
1164
3000
|
}
|
|
1165
|
-
const raw =
|
|
3001
|
+
const raw = import_node_fs6.default.readFileSync(huntPath, "utf-8");
|
|
1166
3002
|
const parsed = import_yaml.default.parse(raw) ?? {};
|
|
1167
3003
|
return {
|
|
1168
3004
|
description: typeof parsed.description === "string" ? parsed.description : void 0,
|
|
@@ -1170,24 +3006,24 @@ function loadHuntMeta(huntName, configDir) {
|
|
|
1170
3006
|
};
|
|
1171
3007
|
}
|
|
1172
3008
|
function listHunts(configDir) {
|
|
1173
|
-
const huntsDir =
|
|
1174
|
-
if (!
|
|
3009
|
+
const huntsDir = import_node_path6.default.join(configDir, "hunts");
|
|
3010
|
+
if (!import_node_fs6.default.existsSync(huntsDir)) {
|
|
1175
3011
|
return [];
|
|
1176
3012
|
}
|
|
1177
|
-
const stats =
|
|
3013
|
+
const stats = import_node_fs6.default.statSync(huntsDir);
|
|
1178
3014
|
if (!stats.isDirectory()) {
|
|
1179
3015
|
throw new Error(`Hunts path is not a directory: ${huntsDir}`);
|
|
1180
3016
|
}
|
|
1181
3017
|
const results = [];
|
|
1182
3018
|
function scanDir(dir) {
|
|
1183
|
-
const entries =
|
|
3019
|
+
const entries = import_node_fs6.default.readdirSync(dir, { withFileTypes: true });
|
|
1184
3020
|
for (const entry of entries) {
|
|
1185
3021
|
if (entry.isFile() && entry.name.endsWith(".yml")) {
|
|
1186
|
-
const fullPath =
|
|
1187
|
-
const relative =
|
|
3022
|
+
const fullPath = import_node_path6.default.join(dir, entry.name);
|
|
3023
|
+
const relative = import_node_path6.default.relative(huntsDir, fullPath);
|
|
1188
3024
|
results.push(relative.replace(/\.yml$/, ""));
|
|
1189
3025
|
} else if (entry.isDirectory()) {
|
|
1190
|
-
scanDir(
|
|
3026
|
+
scanDir(import_node_path6.default.join(dir, entry.name));
|
|
1191
3027
|
}
|
|
1192
3028
|
}
|
|
1193
3029
|
}
|
|
@@ -1562,8 +3398,8 @@ function interpolateHunt(hunt, env, randomVars = generateRandomVars()) {
|
|
|
1562
3398
|
}
|
|
1563
3399
|
|
|
1564
3400
|
// src/browser/playwright-driver.ts
|
|
1565
|
-
var
|
|
1566
|
-
var
|
|
3401
|
+
var import_node_fs7 = __toESM(require("fs"), 1);
|
|
3402
|
+
var import_node_path7 = __toESM(require("path"), 1);
|
|
1567
3403
|
var import_playwright = require("playwright");
|
|
1568
3404
|
var ENGINES = { chromium: import_playwright.chromium, firefox: import_playwright.firefox, webkit: import_playwright.webkit };
|
|
1569
3405
|
async function launchBrowser(options) {
|
|
@@ -1585,14 +3421,14 @@ async function launchBrowser(options) {
|
|
|
1585
3421
|
contextOptions.viewport = options.viewport;
|
|
1586
3422
|
}
|
|
1587
3423
|
if (options.storageStatePath) {
|
|
1588
|
-
if (
|
|
3424
|
+
if (import_node_fs7.default.existsSync(options.storageStatePath)) {
|
|
1589
3425
|
contextOptions.storageState = options.storageStatePath;
|
|
1590
3426
|
} else {
|
|
1591
3427
|
console.warn(`Auth state file not found: ${options.storageStatePath}. Run "prowl login" to create it.`);
|
|
1592
3428
|
}
|
|
1593
3429
|
}
|
|
1594
3430
|
if (options.recordHar) {
|
|
1595
|
-
contextOptions.recordHar = { path:
|
|
3431
|
+
contextOptions.recordHar = { path: import_node_path7.default.join(options.runDir, "network.har") };
|
|
1596
3432
|
}
|
|
1597
3433
|
const context = await browser.newContext(contextOptions);
|
|
1598
3434
|
const page = await context.newPage();
|
|
@@ -1600,7 +3436,7 @@ async function launchBrowser(options) {
|
|
|
1600
3436
|
page.setDefaultNavigationTimeout(options.timeout);
|
|
1601
3437
|
let tracePath;
|
|
1602
3438
|
if (options.trace) {
|
|
1603
|
-
tracePath =
|
|
3439
|
+
tracePath = import_node_path7.default.join(options.runDir, "trace.zip");
|
|
1604
3440
|
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
|
|
1605
3441
|
}
|
|
1606
3442
|
return { browser, context, page, tracePath };
|
|
@@ -1775,8 +3611,8 @@ function createPlaywrightDriver(page) {
|
|
|
1775
3611
|
}
|
|
1776
3612
|
|
|
1777
3613
|
// src/runner/steps.ts
|
|
1778
|
-
var
|
|
1779
|
-
var
|
|
3614
|
+
var import_node_fs9 = __toESM(require("fs"), 1);
|
|
3615
|
+
var import_node_path9 = __toESM(require("path"), 1);
|
|
1780
3616
|
|
|
1781
3617
|
// src/runner/healing.ts
|
|
1782
3618
|
var INTERACTIVE_TAGS = ["button", "a", "input", "select", "textarea"];
|
|
@@ -1820,8 +3656,8 @@ async function healSelector(probe, selector, options) {
|
|
|
1820
3656
|
for (const candidate of buildHealCandidates(selector)) {
|
|
1821
3657
|
let count;
|
|
1822
3658
|
try {
|
|
1823
|
-
const
|
|
1824
|
-
count = await
|
|
3659
|
+
const locator2 = probe.locator(candidate.selector);
|
|
3660
|
+
count = await locator2.count();
|
|
1825
3661
|
} catch {
|
|
1826
3662
|
continue;
|
|
1827
3663
|
}
|
|
@@ -2266,7 +4102,7 @@ async function runInlineAssert(driver, policy, assertion) {
|
|
|
2266
4102
|
throw new Error("assert step is missing an assertion type");
|
|
2267
4103
|
}
|
|
2268
4104
|
function screenshotPath(screenshotsDir, fileName) {
|
|
2269
|
-
return
|
|
4105
|
+
return import_node_path9.default.join(screenshotsDir, fileName);
|
|
2270
4106
|
}
|
|
2271
4107
|
function stepPath(prefix, index) {
|
|
2272
4108
|
return prefix ? `${prefix}.${index}` : `${index}`;
|
|
@@ -2283,12 +4119,12 @@ function validateDownloadFilename(suggestedFilename) {
|
|
|
2283
4119
|
const safeFilename = suggestedFilename.trim();
|
|
2284
4120
|
const allowedFilenamePattern = /^[^<>:"/\\|?*]+$/;
|
|
2285
4121
|
const hasControlCharacter = Array.from(safeFilename).some((char) => char.charCodeAt(0) < 32);
|
|
2286
|
-
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !==
|
|
4122
|
+
if (safeFilename.length === 0 || safeFilename !== suggestedFilename || safeFilename !== import_node_path9.default.basename(safeFilename) || safeFilename.includes("..") || /[/\\]/.test(safeFilename) || hasControlCharacter || !allowedFilenamePattern.test(safeFilename)) {
|
|
2287
4123
|
throw new Error(`Invalid download filename: "${suggestedFilename}"`);
|
|
2288
4124
|
}
|
|
2289
4125
|
return safeFilename;
|
|
2290
4126
|
}
|
|
2291
|
-
async function
|
|
4127
|
+
async function captureScreenshot2(taker, filePath) {
|
|
2292
4128
|
try {
|
|
2293
4129
|
await taker.screenshot({ path: filePath, fullPage: true });
|
|
2294
4130
|
} catch (error) {
|
|
@@ -2463,7 +4299,7 @@ var STEP_HANDLERS = {
|
|
|
2463
4299
|
if (!("setInputFiles" in h.step)) unknownStep();
|
|
2464
4300
|
const resolvedInput = await h.policy.resolveActionSelector(h.step.setInputFiles.selector);
|
|
2465
4301
|
const rawFiles = h.step.setInputFiles.files;
|
|
2466
|
-
const resolveFile = (f) =>
|
|
4302
|
+
const resolveFile = (f) => import_node_path9.default.isAbsolute(f) ? f : import_node_path9.default.join(h.context.configDir, f);
|
|
2467
4303
|
const resolvedFiles = Array.isArray(rawFiles) ? rawFiles.map(resolveFile) : resolveFile(rawFiles);
|
|
2468
4304
|
await h.driver.setInputFiles(resolvedInput.selector, resolvedFiles);
|
|
2469
4305
|
h.policy.ensureLocationAllowed(h.driver);
|
|
@@ -2500,7 +4336,7 @@ var STEP_HANDLERS = {
|
|
|
2500
4336
|
redactedFillSteps: subRedacted,
|
|
2501
4337
|
randomVars
|
|
2502
4338
|
} = interpolateHunt(subHunt, process.env, h.context.randomVars);
|
|
2503
|
-
const subTargetType = h.driver.capabilities.has("navigate") ? "web" : "macos";
|
|
4339
|
+
const subTargetType = h.context.targetType ?? (h.driver.capabilities.has("navigate") ? "web" : "macos");
|
|
2504
4340
|
assertStepsSupportedByTarget(interpolatedSubHunt.steps, subTargetType);
|
|
2505
4341
|
assertHuntAssertionsSupportedByTarget(interpolatedSubHunt.assertions, subTargetType);
|
|
2506
4342
|
h.policy.assertWithinMaxSteps(interpolatedSubHunt.steps.length, huntName);
|
|
@@ -2825,15 +4661,15 @@ var STEP_HANDLERS = {
|
|
|
2825
4661
|
if (!responseFile) {
|
|
2826
4662
|
throw new Error("mock.response must include either body or file");
|
|
2827
4663
|
}
|
|
2828
|
-
const candidateFilePath =
|
|
2829
|
-
const resolvedConfigDir =
|
|
2830
|
-
const resolvedFilePath =
|
|
2831
|
-
const relativePath =
|
|
2832
|
-
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${
|
|
4664
|
+
const candidateFilePath = import_node_path9.default.isAbsolute(responseFile) ? responseFile : import_node_path9.default.join(h.context.configDir, responseFile);
|
|
4665
|
+
const resolvedConfigDir = import_node_path9.default.resolve(h.context.configDir);
|
|
4666
|
+
const resolvedFilePath = import_node_path9.default.resolve(candidateFilePath);
|
|
4667
|
+
const relativePath = import_node_path9.default.relative(resolvedConfigDir, resolvedFilePath);
|
|
4668
|
+
const isWithinConfigDir = relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${import_node_path9.default.sep}`) && !import_node_path9.default.isAbsolute(relativePath);
|
|
2833
4669
|
if (!isWithinConfigDir) {
|
|
2834
4670
|
throw new Error("mock.response.file must resolve within config directory");
|
|
2835
4671
|
}
|
|
2836
|
-
responseBody = await
|
|
4672
|
+
responseBody = await import_node_fs9.default.promises.readFile(resolvedFilePath, "utf-8");
|
|
2837
4673
|
}
|
|
2838
4674
|
const contentType = mock.response.contentType ?? "application/json";
|
|
2839
4675
|
const status = mock.response.status;
|
|
@@ -2896,8 +4732,8 @@ var STEP_HANDLERS = {
|
|
|
2896
4732
|
capabilities: ["evaluate"],
|
|
2897
4733
|
run: async (h) => {
|
|
2898
4734
|
if (!("runScript" in h.step)) unknownStep();
|
|
2899
|
-
const filePath =
|
|
2900
|
-
const fileContents =
|
|
4735
|
+
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);
|
|
4736
|
+
const fileContents = import_node_fs9.default.readFileSync(filePath, "utf-8");
|
|
2901
4737
|
await h.driver.evaluate(fileContents);
|
|
2902
4738
|
return {
|
|
2903
4739
|
kind: "result",
|
|
@@ -2918,19 +4754,19 @@ var STEP_HANDLERS = {
|
|
|
2918
4754
|
const name = h.step.assertScreenshot.name;
|
|
2919
4755
|
const threshold = h.step.assertScreenshot.threshold ?? 0.1;
|
|
2920
4756
|
const baselineDir = ensureBaselineDir2(h.context.configDir);
|
|
2921
|
-
const baselinePath =
|
|
2922
|
-
const currentScreenshotPath =
|
|
2923
|
-
|
|
4757
|
+
const baselinePath = import_node_path9.default.join(baselineDir, `${name}.png`);
|
|
4758
|
+
const currentScreenshotPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-current.png`);
|
|
4759
|
+
import_node_fs9.default.mkdirSync(import_node_path9.default.dirname(currentScreenshotPath), { recursive: true });
|
|
2924
4760
|
await h.driver.screenshot({ path: currentScreenshotPath, fullPage: true });
|
|
2925
|
-
h.screenshots.push(
|
|
2926
|
-
if (!
|
|
2927
|
-
|
|
4761
|
+
h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-current.png`));
|
|
4762
|
+
if (!import_node_fs9.default.existsSync(baselinePath)) {
|
|
4763
|
+
import_node_fs9.default.copyFileSync(currentScreenshotPath, baselinePath);
|
|
2928
4764
|
return {
|
|
2929
4765
|
kind: "result",
|
|
2930
4766
|
result: { type: "assertScreenshot", status: "pass", durationMs: Date.now() - h.stepStart, value: "baseline created" }
|
|
2931
4767
|
};
|
|
2932
4768
|
}
|
|
2933
|
-
const diffPath =
|
|
4769
|
+
const diffPath = import_node_path9.default.join(h.context.runDir, "screenshots", `${name}-diff.png`);
|
|
2934
4770
|
const comparison = await compareScreenshots2(baselinePath, currentScreenshotPath, diffPath, threshold);
|
|
2935
4771
|
if (comparison.match) {
|
|
2936
4772
|
return {
|
|
@@ -2943,7 +4779,7 @@ var STEP_HANDLERS = {
|
|
|
2943
4779
|
}
|
|
2944
4780
|
};
|
|
2945
4781
|
}
|
|
2946
|
-
h.screenshots.push(
|
|
4782
|
+
h.screenshots.push(import_node_path9.default.join("screenshots", `${name}-diff.png`));
|
|
2947
4783
|
throw new Error(
|
|
2948
4784
|
`Visual regression: ${(comparison.diffPercentage * 100).toFixed(2)}% diff exceeds threshold ${(threshold * 100).toFixed(0)}%`
|
|
2949
4785
|
);
|
|
@@ -2985,7 +4821,7 @@ var STEP_HANDLERS = {
|
|
|
2985
4821
|
`Download filename mismatch: expected "${opts.filename}", got "${suggestedFilename}"`
|
|
2986
4822
|
);
|
|
2987
4823
|
}
|
|
2988
|
-
const savePath =
|
|
4824
|
+
const savePath = import_node_path9.default.join(h.context.runDir, suggestedFilename);
|
|
2989
4825
|
await download.saveAs(savePath);
|
|
2990
4826
|
return {
|
|
2991
4827
|
kind: "result",
|
|
@@ -3014,8 +4850,8 @@ async function executeSteps(context) {
|
|
|
3014
4850
|
maxSteps: context.maxSteps,
|
|
3015
4851
|
selfHealing: context.selfHealing
|
|
3016
4852
|
});
|
|
3017
|
-
const screenshotsDir =
|
|
3018
|
-
|
|
4853
|
+
const screenshotsDir = import_node_path9.default.join(context.runDir, "screenshots");
|
|
4854
|
+
import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
|
|
3019
4855
|
const currentHuntName = context.huntStack?.[context.huntStack.length - 1];
|
|
3020
4856
|
policy.assertWithinMaxSteps(context.steps.length, currentHuntName);
|
|
3021
4857
|
const results = [];
|
|
@@ -3024,8 +4860,8 @@ async function executeSteps(context) {
|
|
|
3024
4860
|
context.runStartedAtMs = runStartedAtMs;
|
|
3025
4861
|
const addScreenshot = async (fileName) => {
|
|
3026
4862
|
const fullPath = screenshotPath(screenshotsDir, fileName);
|
|
3027
|
-
await
|
|
3028
|
-
const relative =
|
|
4863
|
+
await captureScreenshot2(driver, fullPath);
|
|
4864
|
+
const relative = import_node_path9.default.join("screenshots", fileName);
|
|
3029
4865
|
screenshots.push(relative);
|
|
3030
4866
|
return relative;
|
|
3031
4867
|
};
|
|
@@ -3113,12 +4949,12 @@ async function executeSteps(context) {
|
|
|
3113
4949
|
return { results, screenshots, failed: false };
|
|
3114
4950
|
}
|
|
3115
4951
|
async function captureFinalScreenshot(page, runDir) {
|
|
3116
|
-
const screenshotsDir =
|
|
3117
|
-
|
|
4952
|
+
const screenshotsDir = import_node_path9.default.join(runDir, "screenshots");
|
|
4953
|
+
import_node_fs9.default.mkdirSync(screenshotsDir, { recursive: true });
|
|
3118
4954
|
const fileName = "final.png";
|
|
3119
4955
|
const filePath = screenshotPath(screenshotsDir, fileName);
|
|
3120
|
-
await
|
|
3121
|
-
return
|
|
4956
|
+
await captureScreenshot2(page, filePath);
|
|
4957
|
+
return import_node_path9.default.join("screenshots", fileName);
|
|
3122
4958
|
}
|
|
3123
4959
|
|
|
3124
4960
|
// src/runner/assertions.ts
|
|
@@ -3278,18 +5114,18 @@ function captureTraceCorrelation(response, headerName, sink, redactionValues = [
|
|
|
3278
5114
|
}
|
|
3279
5115
|
|
|
3280
5116
|
// src/reporter/result.ts
|
|
3281
|
-
var
|
|
3282
|
-
var
|
|
5117
|
+
var import_node_fs10 = __toESM(require("fs"), 1);
|
|
5118
|
+
var import_node_path10 = __toESM(require("path"), 1);
|
|
3283
5119
|
function writeResult(runDir, result) {
|
|
3284
5120
|
const fileName = "result.json";
|
|
3285
|
-
const fullPath =
|
|
3286
|
-
|
|
5121
|
+
const fullPath = import_node_path10.default.join(runDir, fileName);
|
|
5122
|
+
import_node_fs10.default.writeFileSync(fullPath, JSON.stringify(result, null, 2));
|
|
3287
5123
|
return fileName;
|
|
3288
5124
|
}
|
|
3289
5125
|
|
|
3290
5126
|
// src/reporter/summary.ts
|
|
3291
|
-
var
|
|
3292
|
-
var
|
|
5127
|
+
var import_node_fs11 = __toESM(require("fs"), 1);
|
|
5128
|
+
var import_node_path11 = __toESM(require("path"), 1);
|
|
3293
5129
|
function escapeMd(text) {
|
|
3294
5130
|
return text.replace(/([|`*_{}[\]()#+\-!\\])/g, "\\$1");
|
|
3295
5131
|
}
|
|
@@ -3367,15 +5203,15 @@ function writeSummary(runDir, result) {
|
|
|
3367
5203
|
}
|
|
3368
5204
|
}
|
|
3369
5205
|
const fileName = "summary.md";
|
|
3370
|
-
const fullPath =
|
|
3371
|
-
|
|
5206
|
+
const fullPath = import_node_path11.default.join(runDir, fileName);
|
|
5207
|
+
import_node_fs11.default.writeFileSync(fullPath, `${lines.join("\n")}
|
|
3372
5208
|
`);
|
|
3373
5209
|
return fileName;
|
|
3374
5210
|
}
|
|
3375
5211
|
|
|
3376
5212
|
// src/reporter/junit.ts
|
|
3377
|
-
var
|
|
3378
|
-
var
|
|
5213
|
+
var import_node_fs12 = __toESM(require("fs"), 1);
|
|
5214
|
+
var import_node_path12 = __toESM(require("path"), 1);
|
|
3379
5215
|
function escapeXml(text) {
|
|
3380
5216
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3381
5217
|
}
|
|
@@ -3419,8 +5255,8 @@ function writeJunit(runDir, result) {
|
|
|
3419
5255
|
lines.push(" </testsuite>");
|
|
3420
5256
|
lines.push("</testsuites>");
|
|
3421
5257
|
const fileName = "junit.xml";
|
|
3422
|
-
const fullPath =
|
|
3423
|
-
|
|
5258
|
+
const fullPath = import_node_path12.default.join(runDir, fileName);
|
|
5259
|
+
import_node_fs12.default.writeFileSync(fullPath, `${lines.join("\n")}
|
|
3424
5260
|
`);
|
|
3425
5261
|
return fileName;
|
|
3426
5262
|
}
|
|
@@ -3454,15 +5290,15 @@ function timestamp(prefix) {
|
|
|
3454
5290
|
}
|
|
3455
5291
|
|
|
3456
5292
|
// src/runner/history.ts
|
|
3457
|
-
var
|
|
3458
|
-
var
|
|
5293
|
+
var import_node_fs13 = __toESM(require("fs"), 1);
|
|
5294
|
+
var import_node_path13 = __toESM(require("path"), 1);
|
|
3459
5295
|
var HISTORY_FILE = "history.json";
|
|
3460
5296
|
var LOCK_FILE_SUFFIX = ".lock";
|
|
3461
5297
|
var LOCK_RETRY_MS = 10;
|
|
3462
5298
|
var LOCK_TIMEOUT_MS = 5e3;
|
|
3463
5299
|
var SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
3464
5300
|
function historyPath(configDir) {
|
|
3465
|
-
return
|
|
5301
|
+
return import_node_path13.default.join(configDir, HISTORY_FILE);
|
|
3466
5302
|
}
|
|
3467
5303
|
function isHistoryEntry(value) {
|
|
3468
5304
|
if (!value || typeof value !== "object") {
|
|
@@ -3473,11 +5309,11 @@ function isHistoryEntry(value) {
|
|
|
3473
5309
|
}
|
|
3474
5310
|
function readHistory(configDir) {
|
|
3475
5311
|
const filePath = historyPath(configDir);
|
|
3476
|
-
if (!
|
|
5312
|
+
if (!import_node_fs13.default.existsSync(filePath)) {
|
|
3477
5313
|
return { entries: [] };
|
|
3478
5314
|
}
|
|
3479
5315
|
try {
|
|
3480
|
-
const raw =
|
|
5316
|
+
const raw = import_node_fs13.default.readFileSync(filePath, "utf-8");
|
|
3481
5317
|
const parsed = JSON.parse(raw);
|
|
3482
5318
|
if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
|
|
3483
5319
|
const validatedEntries = parsed.entries.filter(isHistoryEntry);
|
|
@@ -3516,12 +5352,12 @@ function sleepSync(ms) {
|
|
|
3516
5352
|
function withHistoryLock(configDir, fn) {
|
|
3517
5353
|
const filePath = historyPath(configDir);
|
|
3518
5354
|
const lockPath = `${filePath}${LOCK_FILE_SUFFIX}`;
|
|
3519
|
-
|
|
5355
|
+
import_node_fs13.default.mkdirSync(import_node_path13.default.dirname(filePath), { recursive: true });
|
|
3520
5356
|
const startedAt = Date.now();
|
|
3521
5357
|
while (Date.now() - startedAt < LOCK_TIMEOUT_MS) {
|
|
3522
5358
|
let fd;
|
|
3523
5359
|
try {
|
|
3524
|
-
fd =
|
|
5360
|
+
fd = import_node_fs13.default.openSync(lockPath, "wx");
|
|
3525
5361
|
} catch (error) {
|
|
3526
5362
|
if (error.code === "EEXIST") {
|
|
3527
5363
|
sleepSync(LOCK_RETRY_MS);
|
|
@@ -3533,10 +5369,10 @@ function withHistoryLock(configDir, fn) {
|
|
|
3533
5369
|
return fn();
|
|
3534
5370
|
} finally {
|
|
3535
5371
|
try {
|
|
3536
|
-
|
|
5372
|
+
import_node_fs13.default.closeSync(fd);
|
|
3537
5373
|
} catch {
|
|
3538
5374
|
}
|
|
3539
|
-
|
|
5375
|
+
import_node_fs13.default.rmSync(lockPath, { force: true });
|
|
3540
5376
|
}
|
|
3541
5377
|
}
|
|
3542
5378
|
throw new Error(
|
|
@@ -3549,9 +5385,9 @@ function appendEntry(configDir, entry, maxRuns) {
|
|
|
3549
5385
|
const current = readHistory(configDir);
|
|
3550
5386
|
const next = pruneEntries([...current.entries, entry], maxRuns);
|
|
3551
5387
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
3552
|
-
|
|
5388
|
+
import_node_fs13.default.writeFileSync(tempPath, `${JSON.stringify({ entries: next }, null, 2)}
|
|
3553
5389
|
`);
|
|
3554
|
-
|
|
5390
|
+
import_node_fs13.default.renameSync(tempPath, filePath);
|
|
3555
5391
|
});
|
|
3556
5392
|
}
|
|
3557
5393
|
|
|
@@ -3564,11 +5400,11 @@ function parseViewportFlag(value) {
|
|
|
3564
5400
|
return value;
|
|
3565
5401
|
}
|
|
3566
5402
|
function resolvePath(configDir, inputPath) {
|
|
3567
|
-
if (
|
|
5403
|
+
if (import_node_path14.default.isAbsolute(inputPath)) {
|
|
3568
5404
|
return inputPath;
|
|
3569
5405
|
}
|
|
3570
|
-
const projectRoot =
|
|
3571
|
-
return
|
|
5406
|
+
const projectRoot = import_node_path14.default.dirname(configDir);
|
|
5407
|
+
return import_node_path14.default.join(projectRoot, inputPath);
|
|
3572
5408
|
}
|
|
3573
5409
|
function buildRunResult(options) {
|
|
3574
5410
|
return {
|
|
@@ -3587,12 +5423,12 @@ function buildRunResult(options) {
|
|
|
3587
5423
|
}
|
|
3588
5424
|
function writeConsoleLog(runDir, entries) {
|
|
3589
5425
|
const fileName = "console.log";
|
|
3590
|
-
const filePath =
|
|
5426
|
+
const filePath = import_node_path14.default.join(runDir, fileName);
|
|
3591
5427
|
const lines = entries.map((entry) => {
|
|
3592
5428
|
const location = entry.location ? ` (${entry.location})` : "";
|
|
3593
5429
|
return `[${entry.type}] ${entry.text}${location}`;
|
|
3594
5430
|
});
|
|
3595
|
-
|
|
5431
|
+
import_node_fs14.default.writeFileSync(filePath, `${lines.join("\n")}
|
|
3596
5432
|
`);
|
|
3597
5433
|
return fileName;
|
|
3598
5434
|
}
|
|
@@ -3600,8 +5436,8 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
3600
5436
|
const headless = options.headed ? false : config.browser.headless;
|
|
3601
5437
|
const slowMo = options.slowMo ?? config.browser.slowMo;
|
|
3602
5438
|
const maxSteps = config.guardrails.maxSteps;
|
|
3603
|
-
const runDir =
|
|
3604
|
-
|
|
5439
|
+
const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
|
|
5440
|
+
import_node_fs14.default.mkdirSync(runDir, { recursive: true });
|
|
3605
5441
|
const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : void 0;
|
|
3606
5442
|
const engine = options.browser ?? config.browser.engine;
|
|
3607
5443
|
const channel = options.channel ?? config.browser.channel;
|
|
@@ -3718,7 +5554,7 @@ async function executeHuntAttempt(options, config, configDir, interpolatedHunt,
|
|
|
3718
5554
|
}
|
|
3719
5555
|
return { result, runDir, steps: interpolatedHunt.steps };
|
|
3720
5556
|
}
|
|
3721
|
-
function
|
|
5557
|
+
function delay3(ms) {
|
|
3722
5558
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3723
5559
|
}
|
|
3724
5560
|
async function runHunt(options) {
|
|
@@ -3726,6 +5562,12 @@ async function runHunt(options) {
|
|
|
3726
5562
|
if (config.target.type === "macos") {
|
|
3727
5563
|
return runMacHunt(options, config, configDir, config.target);
|
|
3728
5564
|
}
|
|
5565
|
+
if (config.target.type === "android") {
|
|
5566
|
+
return runAndroidHunt(options, config, configDir, config.target);
|
|
5567
|
+
}
|
|
5568
|
+
if (config.target.type === "ios") {
|
|
5569
|
+
return runIosHunt(options, config, configDir, config.target);
|
|
5570
|
+
}
|
|
3729
5571
|
const hunt = loadHunt(options.huntName, configDir);
|
|
3730
5572
|
const {
|
|
3731
5573
|
hunt: interpolatedHunt,
|
|
@@ -3747,7 +5589,7 @@ async function runHunt(options) {
|
|
|
3747
5589
|
let lastResult;
|
|
3748
5590
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3749
5591
|
if (attempt > 0 && retryDelay > 0) {
|
|
3750
|
-
await
|
|
5592
|
+
await delay3(retryDelay);
|
|
3751
5593
|
}
|
|
3752
5594
|
lastResult = await executeHuntAttempt(
|
|
3753
5595
|
options,
|
|
@@ -3776,19 +5618,17 @@ async function runHunt(options) {
|
|
|
3776
5618
|
}
|
|
3777
5619
|
return lastResult;
|
|
3778
5620
|
}
|
|
3779
|
-
async function
|
|
5621
|
+
async function executeNativeHuntAttempt(options, config, configDir, interpolatedHunt, redactedFillSteps, randomVars, allowedApps, native) {
|
|
3780
5622
|
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
|
-
});
|
|
5623
|
+
const runDir = import_node_path14.default.join(configDir, "runs", timestamp());
|
|
5624
|
+
import_node_fs14.default.mkdirSync(runDir, { recursive: true });
|
|
5625
|
+
const session = await native.launchSession();
|
|
3788
5626
|
let result;
|
|
3789
5627
|
try {
|
|
3790
|
-
const
|
|
3791
|
-
const
|
|
5628
|
+
const driver = native.sessionDriver(session);
|
|
5629
|
+
const appIdentity = native.sessionAppIdentity(session);
|
|
5630
|
+
const targetLabel = `${native.targetType}:${appIdentity}`;
|
|
5631
|
+
const effectiveAllowedApps = [.../* @__PURE__ */ new Set([...allowedApps, native.targetApp, appIdentity])];
|
|
3792
5632
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3793
5633
|
const startTime = Date.now();
|
|
3794
5634
|
let stepResults = [];
|
|
@@ -3796,7 +5636,8 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
|
|
|
3796
5636
|
let stepFailed = false;
|
|
3797
5637
|
try {
|
|
3798
5638
|
const stepExecution = await executeSteps({
|
|
3799
|
-
driver
|
|
5639
|
+
driver,
|
|
5640
|
+
targetType: native.targetType,
|
|
3800
5641
|
steps: interpolatedHunt.steps,
|
|
3801
5642
|
targetUrl: targetLabel,
|
|
3802
5643
|
runDir,
|
|
@@ -3823,7 +5664,7 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
|
|
|
3823
5664
|
}
|
|
3824
5665
|
let finalScreenshot;
|
|
3825
5666
|
try {
|
|
3826
|
-
finalScreenshot = await captureFinalScreenshot(
|
|
5667
|
+
finalScreenshot = await captureFinalScreenshot(driver, runDir);
|
|
3827
5668
|
} catch {
|
|
3828
5669
|
finalScreenshot = void 0;
|
|
3829
5670
|
}
|
|
@@ -3844,16 +5685,116 @@ async function executeMacHuntAttempt(options, config, configDir, target, interpo
|
|
|
3844
5685
|
});
|
|
3845
5686
|
result = writeReports(runDir, runResult, { junit: options.junit ?? config.artifacts.junit });
|
|
3846
5687
|
} finally {
|
|
3847
|
-
await
|
|
5688
|
+
await native.closeSession(session);
|
|
3848
5689
|
}
|
|
3849
5690
|
return { result, runDir, steps: interpolatedHunt.steps };
|
|
3850
5691
|
}
|
|
5692
|
+
async function executeMacHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
5693
|
+
return executeNativeHuntAttempt(
|
|
5694
|
+
options,
|
|
5695
|
+
config,
|
|
5696
|
+
configDir,
|
|
5697
|
+
interpolatedHunt,
|
|
5698
|
+
redactedFillSteps,
|
|
5699
|
+
randomVars,
|
|
5700
|
+
allowedApps,
|
|
5701
|
+
{
|
|
5702
|
+
targetType: "macos",
|
|
5703
|
+
targetApp: target.app,
|
|
5704
|
+
launchSession: () => launchMacSession({
|
|
5705
|
+
app: target.app,
|
|
5706
|
+
timeoutMs: config.browser.timeout,
|
|
5707
|
+
clientFactory: options.macClientFactory
|
|
5708
|
+
}),
|
|
5709
|
+
closeSession: closeMacSession,
|
|
5710
|
+
sessionDriver: (session) => session.driver,
|
|
5711
|
+
sessionAppIdentity: (session) => session.bundleId
|
|
5712
|
+
}
|
|
5713
|
+
);
|
|
5714
|
+
}
|
|
3851
5715
|
async function runMacHunt(options, config, configDir, target) {
|
|
5716
|
+
return runNativeHunt(options, config, configDir, target, {
|
|
5717
|
+
targetType: "macos",
|
|
5718
|
+
assertAppAllowed: (allowedApps, nativeTarget) => assertTargetAppAllowed(allowedApps, nativeTarget.app),
|
|
5719
|
+
attempt: executeMacHuntAttempt
|
|
5720
|
+
});
|
|
5721
|
+
}
|
|
5722
|
+
async function executeAndroidHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
5723
|
+
const launch = options.androidSessionFactory ?? launchAndroidSession;
|
|
5724
|
+
return executeNativeHuntAttempt(
|
|
5725
|
+
options,
|
|
5726
|
+
config,
|
|
5727
|
+
configDir,
|
|
5728
|
+
interpolatedHunt,
|
|
5729
|
+
redactedFillSteps,
|
|
5730
|
+
randomVars,
|
|
5731
|
+
allowedApps,
|
|
5732
|
+
{
|
|
5733
|
+
targetType: "android",
|
|
5734
|
+
targetApp: target.app,
|
|
5735
|
+
launchSession: () => launch({
|
|
5736
|
+
app: target.app,
|
|
5737
|
+
deviceSerial: target.deviceSerial,
|
|
5738
|
+
coldStart: target.coldStart,
|
|
5739
|
+
timeoutMs: config.browser.timeout,
|
|
5740
|
+
allowedApps
|
|
5741
|
+
}),
|
|
5742
|
+
closeSession: closeAndroidSession,
|
|
5743
|
+
sessionDriver: (session) => session.driver,
|
|
5744
|
+
sessionAppIdentity: (session) => session.package
|
|
5745
|
+
}
|
|
5746
|
+
);
|
|
5747
|
+
}
|
|
5748
|
+
async function runAndroidHunt(options, config, configDir, target) {
|
|
5749
|
+
return runNativeHunt(options, config, configDir, target, {
|
|
5750
|
+
targetType: "android",
|
|
5751
|
+
assertAppAllowed: (allowedApps, nativeTarget) => {
|
|
5752
|
+
if (!nativeTarget.app.toLowerCase().endsWith(".apk")) {
|
|
5753
|
+
assertAndroidAppAllowed(allowedApps, nativeTarget.app);
|
|
5754
|
+
}
|
|
5755
|
+
},
|
|
5756
|
+
attempt: executeAndroidHuntAttempt
|
|
5757
|
+
});
|
|
5758
|
+
}
|
|
5759
|
+
async function executeIosHuntAttempt(options, config, configDir, target, interpolatedHunt, redactedFillSteps, randomVars, allowedApps) {
|
|
5760
|
+
const launch = options.iosSessionFactory ?? launchIosSession;
|
|
5761
|
+
return executeNativeHuntAttempt(
|
|
5762
|
+
options,
|
|
5763
|
+
config,
|
|
5764
|
+
configDir,
|
|
5765
|
+
interpolatedHunt,
|
|
5766
|
+
redactedFillSteps,
|
|
5767
|
+
randomVars,
|
|
5768
|
+
allowedApps,
|
|
5769
|
+
{
|
|
5770
|
+
targetType: "ios",
|
|
5771
|
+
targetApp: target.app,
|
|
5772
|
+
launchSession: () => launch({
|
|
5773
|
+
app: target.app,
|
|
5774
|
+
udid: target.udid,
|
|
5775
|
+
coldStart: target.coldStart,
|
|
5776
|
+
timeoutMs: config.browser.timeout,
|
|
5777
|
+
allowedApps
|
|
5778
|
+
}),
|
|
5779
|
+
closeSession: closeIosSession,
|
|
5780
|
+
sessionDriver: (session) => session.driver,
|
|
5781
|
+
sessionAppIdentity: (session) => session.bundleId
|
|
5782
|
+
}
|
|
5783
|
+
);
|
|
5784
|
+
}
|
|
5785
|
+
async function runIosHunt(options, config, configDir, target) {
|
|
5786
|
+
return runNativeHunt(options, config, configDir, target, {
|
|
5787
|
+
targetType: "ios",
|
|
5788
|
+
assertAppAllowed: (allowedApps, nativeTarget) => assertIosAppAllowed(allowedApps, nativeTarget.app),
|
|
5789
|
+
attempt: executeIosHuntAttempt
|
|
5790
|
+
});
|
|
5791
|
+
}
|
|
5792
|
+
async function runNativeHunt(options, config, configDir, target, native) {
|
|
3852
5793
|
const hunt = loadHunt(options.huntName, configDir);
|
|
3853
5794
|
const { hunt: interpolatedHunt, redactedFillSteps, randomVars } = interpolateHunt(hunt, process.env);
|
|
3854
|
-
assertStepsSupportedByTarget(interpolatedHunt.steps,
|
|
3855
|
-
assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions,
|
|
3856
|
-
|
|
5795
|
+
assertStepsSupportedByTarget(interpolatedHunt.steps, native.targetType);
|
|
5796
|
+
assertHuntAssertionsSupportedByTarget(interpolatedHunt.assertions, native.targetType);
|
|
5797
|
+
native.assertAppAllowed(config.guardrails.allowedApps, target);
|
|
3857
5798
|
const maxSteps = config.guardrails.maxSteps;
|
|
3858
5799
|
if (interpolatedHunt.steps.length > maxSteps) {
|
|
3859
5800
|
throw new Error(`Hunt has ${interpolatedHunt.steps.length} steps. Max allowed is ${maxSteps}.`);
|
|
@@ -3863,9 +5804,9 @@ async function runMacHunt(options, config, configDir, target) {
|
|
|
3863
5804
|
let lastResult;
|
|
3864
5805
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
3865
5806
|
if (attempt > 0 && retryDelay > 0) {
|
|
3866
|
-
await
|
|
5807
|
+
await delay3(retryDelay);
|
|
3867
5808
|
}
|
|
3868
|
-
lastResult = await
|
|
5809
|
+
lastResult = await native.attempt(
|
|
3869
5810
|
options,
|
|
3870
5811
|
config,
|
|
3871
5812
|
configDir,
|
|
@@ -3893,7 +5834,7 @@ async function runMacHunt(options, config, configDir, target) {
|
|
|
3893
5834
|
}
|
|
3894
5835
|
function recordHistory(configDir, outcome, maxRuns) {
|
|
3895
5836
|
try {
|
|
3896
|
-
const relativeRunDir =
|
|
5837
|
+
const relativeRunDir = import_node_path14.default.relative(configDir, outcome.runDir);
|
|
3897
5838
|
appendEntry(
|
|
3898
5839
|
configDir,
|
|
3899
5840
|
{
|
|
@@ -3910,11 +5851,11 @@ function recordHistory(configDir, outcome, maxRuns) {
|
|
|
3910
5851
|
}
|
|
3911
5852
|
|
|
3912
5853
|
// src/runner/suite.ts
|
|
3913
|
-
var
|
|
5854
|
+
var import_node_path17 = __toESM(require("path"), 1);
|
|
3914
5855
|
|
|
3915
5856
|
// src/reporter/ci-summary.ts
|
|
3916
|
-
var
|
|
3917
|
-
var
|
|
5857
|
+
var import_node_fs15 = __toESM(require("fs"), 1);
|
|
5858
|
+
var import_node_path15 = __toESM(require("path"), 1);
|
|
3918
5859
|
var import_chalk = __toESM(require("chalk"), 1);
|
|
3919
5860
|
function countCiResults(results) {
|
|
3920
5861
|
return {
|
|
@@ -3944,9 +5885,9 @@ function writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky = []
|
|
|
3944
5885
|
...flaky.length > 0 ? { flaky } : {},
|
|
3945
5886
|
...clusters.length > 0 ? { clusters } : {}
|
|
3946
5887
|
};
|
|
3947
|
-
|
|
3948
|
-
const filePath =
|
|
3949
|
-
|
|
5888
|
+
import_node_fs15.default.mkdirSync(ciRunDir, { recursive: true });
|
|
5889
|
+
const filePath = import_node_path15.default.join(ciRunDir, "ci-result.json");
|
|
5890
|
+
import_node_fs15.default.writeFileSync(filePath, JSON.stringify(ciResult, null, 2) + "\n");
|
|
3950
5891
|
return filePath;
|
|
3951
5892
|
}
|
|
3952
5893
|
|
|
@@ -4080,8 +6021,8 @@ function clusterFailures(failures) {
|
|
|
4080
6021
|
}
|
|
4081
6022
|
|
|
4082
6023
|
// src/backlog/index.ts
|
|
4083
|
-
var
|
|
4084
|
-
var
|
|
6024
|
+
var import_node_fs16 = __toESM(require("fs"), 1);
|
|
6025
|
+
var import_node_path16 = __toESM(require("path"), 1);
|
|
4085
6026
|
|
|
4086
6027
|
// src/backlog/parse.ts
|
|
4087
6028
|
var MARKER_FP = /<!--\s*prowl:fp=([0-9a-f]+)/;
|
|
@@ -4171,7 +6112,7 @@ ${after}`;
|
|
|
4171
6112
|
// src/backlog/index.ts
|
|
4172
6113
|
function readFileOrEmpty(filePath) {
|
|
4173
6114
|
try {
|
|
4174
|
-
return
|
|
6115
|
+
return import_node_fs16.default.readFileSync(filePath, "utf-8");
|
|
4175
6116
|
} catch (error) {
|
|
4176
6117
|
const err = error;
|
|
4177
6118
|
if (err.code === "ENOENT") return "";
|
|
@@ -4187,7 +6128,7 @@ function buildFailure(hunt) {
|
|
|
4187
6128
|
if (!hunt.runDir) return failure;
|
|
4188
6129
|
let run;
|
|
4189
6130
|
try {
|
|
4190
|
-
const resultJson = readFileOrEmpty(
|
|
6131
|
+
const resultJson = readFileOrEmpty(import_node_path16.default.join(hunt.runDir, "result.json"));
|
|
4191
6132
|
if (!resultJson) return failure;
|
|
4192
6133
|
run = JSON.parse(resultJson);
|
|
4193
6134
|
} catch (error) {
|
|
@@ -4216,8 +6157,8 @@ function extractFailures(suiteResult) {
|
|
|
4216
6157
|
}
|
|
4217
6158
|
function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
4218
6159
|
const projectRoot = options.projectRoot ?? process.cwd();
|
|
4219
|
-
const backlogPath = options.backlogPath ??
|
|
4220
|
-
const resolvedPath = options.resolvedPath ??
|
|
6160
|
+
const backlogPath = options.backlogPath ?? import_node_path16.default.join(projectRoot, "docs", "backlog.md");
|
|
6161
|
+
const resolvedPath = options.resolvedPath ?? import_node_path16.default.join(projectRoot, "docs", "resolved.md");
|
|
4221
6162
|
const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
4222
6163
|
const summary = { created: [], regressions: [], skipped: [], backlogPath };
|
|
4223
6164
|
const failures = extractFailures(suiteResult);
|
|
@@ -4249,8 +6190,8 @@ function updateBacklogFromSuite(suiteResult, options = {}) {
|
|
|
4249
6190
|
}
|
|
4250
6191
|
}
|
|
4251
6192
|
if (ticketsToAdd.length > 0) {
|
|
4252
|
-
|
|
4253
|
-
|
|
6193
|
+
import_node_fs16.default.mkdirSync(import_node_path16.default.dirname(backlogPath), { recursive: true });
|
|
6194
|
+
import_node_fs16.default.writeFileSync(backlogPath, insertTickets(backlogContent, ticketsToAdd));
|
|
4254
6195
|
}
|
|
4255
6196
|
return summary;
|
|
4256
6197
|
}
|
|
@@ -4411,7 +6352,7 @@ async function runSuite(options = {}) {
|
|
|
4411
6352
|
const clusters = clusterFailures(
|
|
4412
6353
|
extractFailures({ result: { hunts: results }, resultPath: null })
|
|
4413
6354
|
).filter((cluster) => cluster.count > 1);
|
|
4414
|
-
const ciRunDir =
|
|
6355
|
+
const ciRunDir = import_node_path17.default.join(configDir, "runs", timestamp("ci"));
|
|
4415
6356
|
const resultPath = writeCiResult(ciRunDir, results, startedAt, totalDurationMs, flaky, clusters);
|
|
4416
6357
|
const { passed, failed, skipped } = countCiResults(results);
|
|
4417
6358
|
return {
|
|
@@ -4546,6 +6487,134 @@ async function analyzePage(page) {
|
|
|
4546
6487
|
};
|
|
4547
6488
|
}
|
|
4548
6489
|
|
|
6490
|
+
// src/analyzer/mac.ts
|
|
6491
|
+
var INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
|
|
6492
|
+
"AXButton",
|
|
6493
|
+
"AXTextField",
|
|
6494
|
+
"AXSecureTextField",
|
|
6495
|
+
"AXTextArea",
|
|
6496
|
+
"AXCheckBox",
|
|
6497
|
+
"AXRadioButton",
|
|
6498
|
+
"AXPopUpButton",
|
|
6499
|
+
"AXMenuButton",
|
|
6500
|
+
"AXLink",
|
|
6501
|
+
"AXMenuItem",
|
|
6502
|
+
"AXComboBox",
|
|
6503
|
+
"AXSlider",
|
|
6504
|
+
"AXDisclosureTriangle"
|
|
6505
|
+
]);
|
|
6506
|
+
var DEFAULT_ANALYZE_TREE_DEPTH = 20;
|
|
6507
|
+
function str(value) {
|
|
6508
|
+
if (typeof value !== "string") {
|
|
6509
|
+
return void 0;
|
|
6510
|
+
}
|
|
6511
|
+
const trimmed = value.trim();
|
|
6512
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
6513
|
+
}
|
|
6514
|
+
function toNode(raw) {
|
|
6515
|
+
const node = raw ?? {};
|
|
6516
|
+
const children = Array.isArray(node.children) ? node.children.map(toNode) : void 0;
|
|
6517
|
+
return {
|
|
6518
|
+
role: str(node.role),
|
|
6519
|
+
title: str(node.title),
|
|
6520
|
+
description: str(node.description),
|
|
6521
|
+
value: str(node.value),
|
|
6522
|
+
identifier: str(node.identifier),
|
|
6523
|
+
enabled: typeof node.enabled === "boolean" ? node.enabled : void 0,
|
|
6524
|
+
...children ? { children } : {}
|
|
6525
|
+
};
|
|
6526
|
+
}
|
|
6527
|
+
function quote(value) {
|
|
6528
|
+
return `"${value}"`;
|
|
6529
|
+
}
|
|
6530
|
+
function rankMacSelectors(node) {
|
|
6531
|
+
const selectors = [];
|
|
6532
|
+
const identifier = node.identifier;
|
|
6533
|
+
const exactLabel = node.title ?? node.description;
|
|
6534
|
+
const name = node.title ?? node.description ?? node.value;
|
|
6535
|
+
if (identifier) {
|
|
6536
|
+
selectors.push(`id=${identifier}`);
|
|
6537
|
+
}
|
|
6538
|
+
if (exactLabel) {
|
|
6539
|
+
selectors.push(`label=${quote(exactLabel)}`);
|
|
6540
|
+
}
|
|
6541
|
+
if (node.role && name) {
|
|
6542
|
+
selectors.push(`role=${node.role}[name=${quote(name)}]`);
|
|
6543
|
+
}
|
|
6544
|
+
if (name) {
|
|
6545
|
+
selectors.push(`text=${quote(name)}`);
|
|
6546
|
+
}
|
|
6547
|
+
if (selectors.length === 0 && node.role) {
|
|
6548
|
+
selectors.push(`role=${node.role}`);
|
|
6549
|
+
}
|
|
6550
|
+
return selectors;
|
|
6551
|
+
}
|
|
6552
|
+
function toElement(node, source) {
|
|
6553
|
+
return {
|
|
6554
|
+
role: node.role ?? "?",
|
|
6555
|
+
...node.title ? { title: node.title } : {},
|
|
6556
|
+
...node.description ? { description: node.description } : {},
|
|
6557
|
+
...node.value ? { value: node.value } : {},
|
|
6558
|
+
...node.identifier ? { identifier: node.identifier } : {},
|
|
6559
|
+
...node.enabled !== void 0 ? { enabled: node.enabled } : {},
|
|
6560
|
+
source,
|
|
6561
|
+
selectors: rankMacSelectors(node)
|
|
6562
|
+
};
|
|
6563
|
+
}
|
|
6564
|
+
function collectInteractive(root) {
|
|
6565
|
+
const out = [];
|
|
6566
|
+
const visit = (node) => {
|
|
6567
|
+
if (node.role && INTERACTIVE_ROLES.has(node.role)) {
|
|
6568
|
+
out.push(toElement(node, "window"));
|
|
6569
|
+
}
|
|
6570
|
+
for (const child of node.children ?? []) {
|
|
6571
|
+
visit(child);
|
|
6572
|
+
}
|
|
6573
|
+
};
|
|
6574
|
+
visit(root);
|
|
6575
|
+
return out;
|
|
6576
|
+
}
|
|
6577
|
+
function toWindow(node) {
|
|
6578
|
+
const [best] = rankMacSelectors(node);
|
|
6579
|
+
return {
|
|
6580
|
+
...node.title ? { title: node.title } : {},
|
|
6581
|
+
...node.identifier ? { identifier: node.identifier } : {},
|
|
6582
|
+
selector: best ?? "role=AXWindow"
|
|
6583
|
+
};
|
|
6584
|
+
}
|
|
6585
|
+
async function analyzeMacApp(client, options) {
|
|
6586
|
+
const depth = options.treeDepth ?? DEFAULT_ANALYZE_TREE_DEPTH;
|
|
6587
|
+
const treeResult = await client.request("tree", { depth });
|
|
6588
|
+
const elements = collectInteractive(toNode(treeResult.tree));
|
|
6589
|
+
const windowsResult = await client.request("windows");
|
|
6590
|
+
const rawWindows = Array.isArray(windowsResult.windows) ? windowsResult.windows : [];
|
|
6591
|
+
const windows = rawWindows.map((raw) => toWindow(toNode(raw)));
|
|
6592
|
+
const menuItems = await readStatusMenu(client, options.menuTimeoutSeconds);
|
|
6593
|
+
return { app: options.app, elements, windows, menuItems };
|
|
6594
|
+
}
|
|
6595
|
+
async function readStatusMenu(client, menuTimeoutSeconds) {
|
|
6596
|
+
let statusItems;
|
|
6597
|
+
try {
|
|
6598
|
+
const status = await client.request("statusItems");
|
|
6599
|
+
statusItems = Array.isArray(status.items) ? status.items : [];
|
|
6600
|
+
} catch {
|
|
6601
|
+
return [];
|
|
6602
|
+
}
|
|
6603
|
+
if (statusItems.length === 0) {
|
|
6604
|
+
return [];
|
|
6605
|
+
}
|
|
6606
|
+
const params = menuTimeoutSeconds !== void 0 ? { timeout: menuTimeoutSeconds } : {};
|
|
6607
|
+
try {
|
|
6608
|
+
const menu = await client.request("openMenu", params);
|
|
6609
|
+
const rawItems = Array.isArray(menu.items) ? menu.items : [];
|
|
6610
|
+
return rawItems.map((raw) => toNode(raw)).filter((node) => node.role !== "AXMenuItem" || node.title || node.identifier || node.description).map((node) => toElement(node, "menu"));
|
|
6611
|
+
} catch {
|
|
6612
|
+
return [];
|
|
6613
|
+
} finally {
|
|
6614
|
+
await client.request("closeMenu").catch(() => void 0);
|
|
6615
|
+
}
|
|
6616
|
+
}
|
|
6617
|
+
|
|
4549
6618
|
// src/generator/index.ts
|
|
4550
6619
|
var import_yaml2 = __toESM(require("yaml"), 1);
|
|
4551
6620
|
|
|
@@ -4766,41 +6835,103 @@ async function generateHunt(options) {
|
|
|
4766
6835
|
}
|
|
4767
6836
|
// Annotate the CommonJS export names for ESM import in node:
|
|
4768
6837
|
0 && (module.exports = {
|
|
6838
|
+
ANDROID_KEYCODES,
|
|
6839
|
+
DEFAULT_AGENT_REQUEST_TIMEOUT_MS,
|
|
6840
|
+
DEFAULT_ANALYZE_TREE_DEPTH,
|
|
4769
6841
|
DEFAULT_FLAKY_THRESHOLD,
|
|
4770
6842
|
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
6843
|
+
DEFAULT_SIMULATOR_LOCK_ROOT,
|
|
6844
|
+
DEFAULT_WDA_REQUEST_TIMEOUT_MS,
|
|
6845
|
+
INTERACTIVE_ROLES,
|
|
6846
|
+
IOS_PRESS_KEYS,
|
|
4771
6847
|
SpawnMacHelperClient,
|
|
6848
|
+
UIA2_REMOTE_PORT,
|
|
6849
|
+
Uia2HttpError,
|
|
6850
|
+
Uia2Transport,
|
|
6851
|
+
WDA_RUNNER_APP_NAME,
|
|
6852
|
+
WDA_RUNNER_BUNDLE_ID,
|
|
4772
6853
|
WEB_ONLY_STEP_TYPES,
|
|
6854
|
+
WdaHttpError,
|
|
6855
|
+
WdaTransport,
|
|
6856
|
+
analyzeMacApp,
|
|
4773
6857
|
analyzePage,
|
|
6858
|
+
androidAppAllowedIdentities,
|
|
6859
|
+
androidQueryToLocator,
|
|
6860
|
+
assertAndroidAppAllowed,
|
|
6861
|
+
assertIosAppAllowed,
|
|
4774
6862
|
assertStepsSupportedByTarget,
|
|
4775
6863
|
assertTargetAppAllowed,
|
|
6864
|
+
bootedDevices,
|
|
6865
|
+
bootedSimulators,
|
|
4776
6866
|
buildHealCandidates,
|
|
6867
|
+
closeAndroidSession,
|
|
6868
|
+
closeIosSession,
|
|
4777
6869
|
closeMacSession,
|
|
4778
6870
|
clusterFailures,
|
|
4779
6871
|
computeFlakeScore,
|
|
4780
6872
|
configSchema,
|
|
6873
|
+
createAndroidDriver,
|
|
6874
|
+
createIosDriver,
|
|
4781
6875
|
createMacDriver,
|
|
6876
|
+
createUia2AgentClient,
|
|
6877
|
+
createUia2Session,
|
|
6878
|
+
createWdaAgentClient,
|
|
6879
|
+
createWdaSession,
|
|
6880
|
+
defaultAgentConnector,
|
|
6881
|
+
defaultIosAgentConnector,
|
|
6882
|
+
escapePredicateArg,
|
|
6883
|
+
escapeUiSelectorArg,
|
|
6884
|
+
extractElementId,
|
|
4782
6885
|
extractFailures,
|
|
4783
6886
|
extractSelectorIntent,
|
|
6887
|
+
findFreePort,
|
|
4784
6888
|
generateHunt,
|
|
4785
6889
|
healSelector,
|
|
4786
6890
|
huntSchema,
|
|
4787
6891
|
interpolateHunt,
|
|
6892
|
+
iosAppAllowedIdentities,
|
|
6893
|
+
iosQueryToLocator,
|
|
6894
|
+
launchAndroidSession,
|
|
6895
|
+
launchIosSession,
|
|
4788
6896
|
launchMacSession,
|
|
6897
|
+
listDevices,
|
|
4789
6898
|
listHunts,
|
|
6899
|
+
listSimulators,
|
|
4790
6900
|
loadConfig,
|
|
4791
6901
|
loadHunt,
|
|
4792
6902
|
loadHuntMeta,
|
|
4793
6903
|
loadHuntTags,
|
|
4794
6904
|
macdriverBuildInstructions,
|
|
6905
|
+
normalizeXcuiClassName,
|
|
6906
|
+
parseAaptPackage,
|
|
6907
|
+
parseAdbDevices,
|
|
6908
|
+
parseAndroidSelector,
|
|
6909
|
+
parseForwardPort,
|
|
6910
|
+
parseIosSelector,
|
|
4795
6911
|
parseMacSelector,
|
|
6912
|
+
parseSimctlDevices,
|
|
6913
|
+
parseXcodeVersion,
|
|
4796
6914
|
rankFlaky,
|
|
6915
|
+
rankMacSelectors,
|
|
4797
6916
|
readHistory,
|
|
4798
6917
|
readHuntHistory,
|
|
6918
|
+
readIosBundleIdentifier,
|
|
6919
|
+
reserveSimulatorUdid,
|
|
6920
|
+
resolveAgentApks,
|
|
4799
6921
|
resolveHelperBinary,
|
|
6922
|
+
resolveWdaProject,
|
|
6923
|
+
resolveWdaRunner,
|
|
4800
6924
|
runHunt,
|
|
4801
6925
|
runSuite,
|
|
6926
|
+
selectDeviceSerial,
|
|
6927
|
+
selectSimulatorUdid,
|
|
4802
6928
|
stepSchema,
|
|
6929
|
+
unwrapAndroidTextSelector,
|
|
6930
|
+
unwrapIosTextSelector,
|
|
4803
6931
|
updateBacklogFromSuite,
|
|
6932
|
+
waitForAgentReady,
|
|
6933
|
+
waitForWdaReady,
|
|
6934
|
+
wdaCacheDir,
|
|
4804
6935
|
webOnlyReason
|
|
4805
6936
|
});
|
|
4806
6937
|
//# sourceMappingURL=lib.cjs.map
|