@loadmill/droid-cua 2.43.0 → 2.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -8
- package/build/index.js +46 -17
- package/build/src/cli/device-selector.js +13 -6
- package/build/src/cli/headless-execution-config.js +9 -0
- package/build/src/cli/headless-report.js +40 -11
- package/build/src/cli/headless-runner.js +28 -14
- package/build/src/cli/headless-target.js +24 -0
- package/build/src/cli/ios-device-command.js +121 -0
- package/build/src/cli/ios-device-list.js +32 -0
- package/build/src/cli/network-capture-device.js +21 -0
- package/build/src/commands/help.js +15 -3
- package/build/src/commands/stop.js +1 -0
- package/build/src/core/capture-sources.js +4 -0
- package/build/src/core/droid-cua-run-usage.js +9 -2
- package/build/src/core/execution-context.js +8 -0
- package/build/src/core/execution-engine.js +3 -0
- package/build/src/core/report/render-html-report.js +299 -94
- package/build/src/core/report/run-log-model.js +84 -1
- package/build/src/core/report/run-report-model.js +4 -1
- package/build/src/core/report/run-report-sections.js +10 -0
- package/build/src/core/runtime-config.js +11 -1
- package/build/src/core/step-watchdog.js +61 -0
- package/build/src/desktop-shared/ios-real-device.js +82 -0
- package/build/src/device/factory.js +4 -0
- package/build/src/device/ios/actions.js +48 -12
- package/build/src/device/ios/appium-server.js +7 -18
- package/build/src/device/ios/connection.js +71 -12
- package/build/src/device/ios/node-cli-env.js +55 -0
- package/build/src/device/ios/real-device/device-state.js +40 -0
- package/build/src/device/ios/real-device/devicectl.js +154 -0
- package/build/src/device/ios/real-device/readiness.js +439 -0
- package/build/src/device/ios/real-device/session.js +176 -0
- package/build/src/device/ios/real-device/signing.js +466 -0
- package/build/src/device/ios/real-device/wda-provisioning.js +229 -0
- package/build/src/modes/execution-mode.js +157 -3
- package/build/src/network/certificate-authority.js +21 -1
- package/build/src/network/certificate-install.js +38 -0
- package/build/src/network/local-proxy.js +21 -1
- package/build/src/network/run-network-capture.js +16 -5
- package/build/src/prompts/step-watchdog.js +22 -0
- package/build/src/utils/desktop-debug.js +2 -2
- package/package.json +1 -1
- package/skill/droid-cua/SKILL.md +2 -2
- package/skill/droid-cua/references/authoring-tests.md +1 -1
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +33 -2
- package/skill/droid-cua/references/running-and-healing.md +27 -2
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { DevicectlError, getDdiServices, getLockState, listApps, listDevices } from "./devicectl.js";
|
|
5
|
+
import { didIosVersionChange, readDeviceState, updateDeviceState } from "./device-state.js";
|
|
6
|
+
import { chooseSigning, fetchXcodeTeams, listPaidTeams, listXcodeAccounts, wdaRunnerBundleId } from "./signing.js";
|
|
7
|
+
import { APPIUM_WDA_DIR, ensureSigning, getPinnedWdaVersion, provisionWda, wdaBundleIdBase } from "./wda-provisioning.js";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const TEAM_FETCH_INTERVAL_MS = 60 * 1000;
|
|
10
|
+
let lastTeamFetchAt = null;
|
|
11
|
+
let teamFetchInFlight = null;
|
|
12
|
+
export function __resetTeamFetchThrottle() {
|
|
13
|
+
lastTeamFetchAt = null;
|
|
14
|
+
teamFetchInFlight = null;
|
|
15
|
+
}
|
|
16
|
+
export const MESSAGES = {
|
|
17
|
+
installXcode: "Install Xcode from the App Store and open it once.",
|
|
18
|
+
signIntoXcode: "Sign into Xcode with your work Apple ID (Xcode > Settings > Apple Accounts).",
|
|
19
|
+
askTeamAdmin: "Ask your admin to add you with the Developer role and Access to Certificates, Identifiers & Profiles.",
|
|
20
|
+
connectUsb: "Connect your iPhone with a USB cable.",
|
|
21
|
+
trust: "Unlock the iPhone and tap Trust.",
|
|
22
|
+
developerMode: "Turn on Developer Mode: on the iPhone open Settings > Privacy & Security > Developer Mode, turn it on, let the iPhone restart, then tap Turn On and enter your passcode.",
|
|
23
|
+
unlock: "Unlock your iPhone.",
|
|
24
|
+
allowAutomation: "Enter your passcode on the iPhone to allow automation",
|
|
25
|
+
lockedMidRun: "iPhone locked — unlock to continue",
|
|
26
|
+
};
|
|
27
|
+
const DEVICECTL_MESSAGES = {
|
|
28
|
+
"developer-mode-disabled": `Developer Mode is off. ${MESSAGES.developerMode}`,
|
|
29
|
+
"untrusted-developer": "The iPhone doesn't trust the helper app's developer yet. On the iPhone open Settings > General > VPN & Device Management and trust it.",
|
|
30
|
+
"app-not-installed": "The helper app (WebDriverAgent) isn't installed on the iPhone.",
|
|
31
|
+
"device-locked": `The iPhone is locked. ${MESSAGES.unlock}`,
|
|
32
|
+
"device-not-found": `The iPhone isn't connected. ${MESSAGES.connectUsb}`,
|
|
33
|
+
"not-paired": `The iPhone doesn't trust this Mac yet. ${MESSAGES.trust}`,
|
|
34
|
+
};
|
|
35
|
+
export function messageForError(error) {
|
|
36
|
+
if (error instanceof DevicectlError && DEVICECTL_MESSAGES[error.kind])
|
|
37
|
+
return DEVICECTL_MESSAGES[error.kind];
|
|
38
|
+
if (error instanceof DevicectlError && error.kind === "timeout") {
|
|
39
|
+
return `${error.message}. Check the USB cable and unlock the iPhone.`;
|
|
40
|
+
}
|
|
41
|
+
return error?.message || String(error);
|
|
42
|
+
}
|
|
43
|
+
export function xcodeAdviceForIos(iosVersion) {
|
|
44
|
+
const major = Number.parseInt(String(iosVersion ?? ""), 10);
|
|
45
|
+
if (major >= 26)
|
|
46
|
+
return `Install Xcode ${major} and open it once.`;
|
|
47
|
+
return `Install an Xcode that supports iOS ${iosVersion} and open it once.`;
|
|
48
|
+
}
|
|
49
|
+
function pass(summary, details) {
|
|
50
|
+
return { status: "pass", summary, details };
|
|
51
|
+
}
|
|
52
|
+
function fail(summary, details, cause) {
|
|
53
|
+
return { status: "fail", summary, details, ...(cause ? { cause } : {}) };
|
|
54
|
+
}
|
|
55
|
+
function warn(summary, details) {
|
|
56
|
+
return { status: "warn", summary, details };
|
|
57
|
+
}
|
|
58
|
+
function teamLabel(team) {
|
|
59
|
+
return team.teamName ? `${team.teamName} (${team.teamId})` : team.teamId;
|
|
60
|
+
}
|
|
61
|
+
async function xcodeMissing(ctx) {
|
|
62
|
+
const apps = await ctx.deps.listXcodeApps();
|
|
63
|
+
if (!apps.length) {
|
|
64
|
+
return fail("Xcode isn't in your Applications folder.", "Install Xcode from the App Store, or move Xcode into /Applications, then open it once.", "xcode-not-in-applications");
|
|
65
|
+
}
|
|
66
|
+
const developerPath = await ctx.deps.xcodeSelectPath();
|
|
67
|
+
if (developerPath?.includes("/CommandLineTools")) {
|
|
68
|
+
const app = apps.includes("Xcode.app") ? "Xcode.app" : apps[0];
|
|
69
|
+
return fail("The Command Line Tools are selected instead of Xcode.", `Run: sudo xcode-select -s /Applications/${app}`, "command-line-tools-selected");
|
|
70
|
+
}
|
|
71
|
+
return fail("Xcode isn't installed or isn't the selected developer tool (Command Line Tools alone aren't enough).", `${MESSAGES.installXcode} If Xcode is already installed, run: sudo xcode-select -s /Applications/Xcode.app`);
|
|
72
|
+
}
|
|
73
|
+
function versionParts(version) {
|
|
74
|
+
return String(version ?? "").split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
75
|
+
}
|
|
76
|
+
export function matchingIosRuntime(sdkVersion, runtimes) {
|
|
77
|
+
const [sdkMajor, sdkMinor] = versionParts(sdkVersion);
|
|
78
|
+
return runtimes.find((runtime) => {
|
|
79
|
+
const [major, minor] = versionParts(runtime.version);
|
|
80
|
+
return major === sdkMajor && minor >= sdkMinor;
|
|
81
|
+
}) ?? null;
|
|
82
|
+
}
|
|
83
|
+
async function listIosRuntimes() {
|
|
84
|
+
const { stdout } = await execFileAsync("xcrun", ["simctl", "list", "runtimes", "-j"], { timeout: 30_000 });
|
|
85
|
+
return (JSON.parse(stdout).runtimes ?? []).filter((runtime) => runtime.platform === "iOS" && runtime.isAvailable);
|
|
86
|
+
}
|
|
87
|
+
async function fetchTeamsThrottled(ctx) {
|
|
88
|
+
if (teamFetchInFlight) {
|
|
89
|
+
await teamFetchInFlight;
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
const now = ctx.deps.now();
|
|
93
|
+
if (lastTeamFetchAt !== null && now - lastTeamFetchAt < TEAM_FETCH_INTERVAL_MS)
|
|
94
|
+
return false;
|
|
95
|
+
lastTeamFetchAt = now;
|
|
96
|
+
teamFetchInFlight = Promise.resolve(ctx.deps.fetchXcodeTeams()).finally(() => {
|
|
97
|
+
teamFetchInFlight = null;
|
|
98
|
+
});
|
|
99
|
+
await teamFetchInFlight;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
export const READINESS_CHECKS = [
|
|
103
|
+
{
|
|
104
|
+
id: "xcode_devicectl",
|
|
105
|
+
title: "Xcode installed",
|
|
106
|
+
kind: "human-detectable",
|
|
107
|
+
blocking: true,
|
|
108
|
+
dependsOn: [],
|
|
109
|
+
async detect(ctx) {
|
|
110
|
+
try {
|
|
111
|
+
await ctx.deps.findDevicectl();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return await xcodeMissing(ctx);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
await ctx.deps.checkXcodeLicense();
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return fail("The Xcode license hasn't been accepted.", "Run: sudo xcodebuild -license accept", "xcode-license");
|
|
121
|
+
}
|
|
122
|
+
return pass("Xcode is installed.");
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: "ios_platform",
|
|
127
|
+
title: "iOS platform installed",
|
|
128
|
+
kind: "human-detectable",
|
|
129
|
+
blocking: true,
|
|
130
|
+
dependsOn: ["xcode_devicectl"],
|
|
131
|
+
async detect(ctx) {
|
|
132
|
+
const sdkVersion = await ctx.deps.iosSdkVersion();
|
|
133
|
+
const runtime = matchingIosRuntime(sdkVersion, await ctx.deps.listIosRuntimes());
|
|
134
|
+
if (runtime)
|
|
135
|
+
return pass(`The iOS ${runtime.version} platform is installed.`);
|
|
136
|
+
return fail("The iOS platform isn't installed in Xcode.", `Xcode needs the iOS ${sdkVersion} platform to build the helper app. Install it from Xcode > Settings > Components, or run: xcodebuild -downloadPlatform iOS. It's a large download (about 10 GB).`, "ios-platform-missing");
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: "apple_team",
|
|
141
|
+
title: "Apple team access",
|
|
142
|
+
kind: "human-detectable",
|
|
143
|
+
blocking: true,
|
|
144
|
+
dependsOn: ["ios_platform"],
|
|
145
|
+
async detect(ctx) {
|
|
146
|
+
let teams = await ctx.deps.listPaidTeams();
|
|
147
|
+
if (!teams.length) {
|
|
148
|
+
if (!(await ctx.deps.listXcodeAccounts()).length) {
|
|
149
|
+
return fail("No Apple account is signed into Xcode.", MESSAGES.signIntoXcode);
|
|
150
|
+
}
|
|
151
|
+
if (await fetchTeamsThrottled(ctx))
|
|
152
|
+
teams = await ctx.deps.listPaidTeams();
|
|
153
|
+
}
|
|
154
|
+
if (!teams.length) {
|
|
155
|
+
return fail("Signed into Xcode, but none of your accounts is in a paid Apple Developer team.", MESSAGES.askTeamAdmin);
|
|
156
|
+
}
|
|
157
|
+
const list = teams.map(teamLabel).join(", ");
|
|
158
|
+
if (ctx.teamId) {
|
|
159
|
+
const team = teams.find((t) => t.teamId === ctx.teamId);
|
|
160
|
+
if (!team) {
|
|
161
|
+
return fail(`Team ${ctx.teamId} isn't one of your paid Apple teams (${list}).`, `Choose one of those teams, or sign into Xcode with an Apple ID that belongs to team ${ctx.teamId}.`);
|
|
162
|
+
}
|
|
163
|
+
return pass(`Using team ${teamLabel(team)}.`);
|
|
164
|
+
}
|
|
165
|
+
if (teams.length > 1) {
|
|
166
|
+
return fail(`You belong to several paid Apple teams: ${list}.`, "Choose the team droid should sign the helper app with.");
|
|
167
|
+
}
|
|
168
|
+
ctx.teamId = teams[0].teamId;
|
|
169
|
+
return pass(`Using team ${teamLabel(teams[0])}.`);
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "iphone_connected",
|
|
174
|
+
title: "iPhone connected",
|
|
175
|
+
kind: "human-detectable",
|
|
176
|
+
blocking: true,
|
|
177
|
+
dependsOn: ["xcode_devicectl"],
|
|
178
|
+
async detect(ctx) {
|
|
179
|
+
const devices = await ctx.deps.listDevices();
|
|
180
|
+
const device = ctx.udid
|
|
181
|
+
? devices.find((d) => d.udid === ctx.udid)
|
|
182
|
+
: devices.find((d) => d.transport === "wired");
|
|
183
|
+
if (!device || device.transport !== "wired") {
|
|
184
|
+
const summary = ctx.udid ? `iPhone ${device?.name ?? ctx.udid} isn't connected by USB.` : "No iPhone is connected by USB.";
|
|
185
|
+
return fail(summary, MESSAGES.connectUsb);
|
|
186
|
+
}
|
|
187
|
+
ctx.udid = device.udid;
|
|
188
|
+
ctx.device = device;
|
|
189
|
+
ctx.previousIosVersion = (await ctx.deps.readDeviceState(device.udid, { dir: ctx.stateDir }))?.lastIosVersion ?? null;
|
|
190
|
+
ctx.iosVersionChanged = await ctx.deps.didIosVersionChange(device.udid, device.iosVersion, { dir: ctx.stateDir });
|
|
191
|
+
const changed = ctx.iosVersionChanged ? ` It moved from iOS ${ctx.previousIosVersion}; re-checking everything.` : "";
|
|
192
|
+
return pass(`${device.name} (${device.model}, iOS ${device.iosVersion}) is connected.${changed}`);
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
id: "iphone_trusted",
|
|
197
|
+
title: "iPhone trusts this Mac",
|
|
198
|
+
kind: "human-detectable",
|
|
199
|
+
blocking: true,
|
|
200
|
+
dependsOn: ["iphone_connected"],
|
|
201
|
+
async detect(ctx) {
|
|
202
|
+
if (ctx.device.pairingState === "paired")
|
|
203
|
+
return pass("The iPhone trusts this Mac.");
|
|
204
|
+
return fail("The iPhone doesn't trust this Mac yet.", MESSAGES.trust);
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: "iphone_developer_mode",
|
|
209
|
+
title: "Developer Mode",
|
|
210
|
+
kind: "human-detectable",
|
|
211
|
+
blocking: true,
|
|
212
|
+
dependsOn: ["iphone_trusted"],
|
|
213
|
+
async detect(ctx) {
|
|
214
|
+
ctx.lockState = await ctx.deps.getLockState(ctx.udid);
|
|
215
|
+
const fresh = (await ctx.deps.listDevices()).find((d) => d.udid === ctx.udid);
|
|
216
|
+
if (fresh)
|
|
217
|
+
ctx.device = fresh;
|
|
218
|
+
if (ctx.device.developerModeStatus === "enabled")
|
|
219
|
+
return pass("Developer Mode is on.");
|
|
220
|
+
return fail("Developer Mode is off.", MESSAGES.developerMode);
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
id: "iphone_unlocked",
|
|
225
|
+
title: "iPhone unlocked",
|
|
226
|
+
kind: "human-detectable",
|
|
227
|
+
blocking: true,
|
|
228
|
+
dependsOn: ["iphone_developer_mode"],
|
|
229
|
+
async detect(ctx) {
|
|
230
|
+
const lock = ctx.lockState;
|
|
231
|
+
if (!lock.unlockedSinceBoot)
|
|
232
|
+
return fail("The iPhone hasn't been unlocked since it restarted.", MESSAGES.unlock);
|
|
233
|
+
if (lock.passcodeRequired)
|
|
234
|
+
return fail("The iPhone is locked.", MESSAGES.unlock);
|
|
235
|
+
return pass("The iPhone is unlocked.");
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: "iphone_device_components",
|
|
240
|
+
title: "Device components",
|
|
241
|
+
kind: "human-detectable",
|
|
242
|
+
blocking: true,
|
|
243
|
+
dependsOn: ["iphone_unlocked"],
|
|
244
|
+
async detect(ctx) {
|
|
245
|
+
const ddi = await ctx.deps.getDdiServices(ctx.udid);
|
|
246
|
+
const build = ddi?.buildUpdate ? ` (from Xcode build ${ddi.buildUpdate})` : "";
|
|
247
|
+
if (ddi?.isUsable === true && ddi.contentIsCompatible !== false) {
|
|
248
|
+
return pass(`This Mac's device components${build} support the iPhone.`);
|
|
249
|
+
}
|
|
250
|
+
const ios = ctx.device.iosVersion;
|
|
251
|
+
return fail(`Your iPhone is on iOS ${ios}. ${xcodeAdviceForIos(ios)}`, `The device components on this Mac${build} can't drive this iPhone.`);
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: "wda_signing",
|
|
256
|
+
title: "Signing ready",
|
|
257
|
+
kind: "auto",
|
|
258
|
+
blocking: true,
|
|
259
|
+
dependsOn: ["apple_team", "iphone_unlocked"],
|
|
260
|
+
fixLabel: "Set up signing",
|
|
261
|
+
async detect(ctx) {
|
|
262
|
+
ctx.bundleIdBase = wdaBundleIdBase(ctx.teamId);
|
|
263
|
+
const found = await ctx.deps.chooseSigning({ teamId: ctx.teamId, bundleIdBase: ctx.bundleIdBase, udid: ctx.udid });
|
|
264
|
+
if (found.profile) {
|
|
265
|
+
ctx.signing = found;
|
|
266
|
+
return pass(found.reason);
|
|
267
|
+
}
|
|
268
|
+
return fail("Signing for the helper app isn't set up yet.", `${found.reason} droid runs a one-time Xcode build (about a minute) to set it up.`);
|
|
269
|
+
},
|
|
270
|
+
async fix(ctx) {
|
|
271
|
+
await ctx.deps.ensureSigning({ udid: ctx.udid, teamId: ctx.teamId, bundleIdBase: ctx.bundleIdBase, onProgress: ctx.onProgress });
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
id: "wda_installed",
|
|
276
|
+
title: "Helper app (WebDriverAgent)",
|
|
277
|
+
kind: "auto",
|
|
278
|
+
blocking: true,
|
|
279
|
+
dependsOn: ["wda_signing", "iphone_device_components"],
|
|
280
|
+
fixLabel: "Install helper app",
|
|
281
|
+
async detect(ctx) {
|
|
282
|
+
ctx.wdaVersion = await ctx.deps.getPinnedWdaVersion();
|
|
283
|
+
const runnerBundleId = wdaRunnerBundleId(ctx.bundleIdBase);
|
|
284
|
+
const apps = await ctx.deps.listApps(ctx.udid, { includeAll: true });
|
|
285
|
+
if (!apps.some((app) => app.bundleIdentifier === runnerBundleId)) {
|
|
286
|
+
return fail("The helper app (WebDriverAgent) isn't on the iPhone.", "droid installs it automatically in a few seconds.");
|
|
287
|
+
}
|
|
288
|
+
const recorded = (await ctx.deps.readDeviceState(ctx.udid, { dir: ctx.stateDir }))?.wda;
|
|
289
|
+
const current = recorded?.version === ctx.wdaVersion
|
|
290
|
+
&& recorded?.identitySha1 === ctx.signing.identity.sha1
|
|
291
|
+
&& recorded?.bundleIdBase === ctx.bundleIdBase
|
|
292
|
+
&& recorded?.iosVersion === ctx.device.iosVersion;
|
|
293
|
+
if (!current) {
|
|
294
|
+
return fail("The helper app on the iPhone is out of date.", "Its version, signing or the iPhone's iOS changed since droid installed it. droid reinstalls it automatically in a few seconds.");
|
|
295
|
+
}
|
|
296
|
+
return pass(`WebDriverAgent ${ctx.wdaVersion} is installed.`);
|
|
297
|
+
},
|
|
298
|
+
async fix(ctx) {
|
|
299
|
+
const result = await ctx.deps.provisionWda({
|
|
300
|
+
udid: ctx.udid,
|
|
301
|
+
teamId: ctx.teamId,
|
|
302
|
+
bundleIdBase: ctx.bundleIdBase,
|
|
303
|
+
version: ctx.wdaVersion,
|
|
304
|
+
onProgress: ctx.onProgress,
|
|
305
|
+
});
|
|
306
|
+
await ctx.deps.updateDeviceState(ctx.udid, {
|
|
307
|
+
wda: {
|
|
308
|
+
version: result.version,
|
|
309
|
+
identitySha1: result.identity.sha1,
|
|
310
|
+
bundleIdBase: result.bundleIdBase,
|
|
311
|
+
sha256: result.sha256,
|
|
312
|
+
profileUuid: result.profile?.uuid ?? null,
|
|
313
|
+
iosVersion: ctx.device.iosVersion,
|
|
314
|
+
installedAt: new Date().toISOString(),
|
|
315
|
+
},
|
|
316
|
+
}, { dir: ctx.stateDir });
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
id: "iphone_auto_lock",
|
|
321
|
+
title: "Auto-Lock",
|
|
322
|
+
kind: "human-undetectable",
|
|
323
|
+
blocking: false,
|
|
324
|
+
dependsOn: [],
|
|
325
|
+
async detect() {
|
|
326
|
+
return warn("Set Auto-Lock to Never on the iPhone while testing.", "Settings > Display & Brightness > Auto-Lock > Never. droid can't read this setting; if the iPhone locks mid-run, droid pauses until you unlock it.");
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
id: "iphone_dedicated",
|
|
331
|
+
title: "Dedicated test iPhone",
|
|
332
|
+
kind: "human-undetectable",
|
|
333
|
+
blocking: false,
|
|
334
|
+
dependsOn: [],
|
|
335
|
+
async detect() {
|
|
336
|
+
return warn("Use a test-only iPhone if you can.", "droid tests the apps and data already on the phone and never resets it.");
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
];
|
|
340
|
+
const defaultDeps = {
|
|
341
|
+
findDevicectl: () => execFileAsync("xcrun", ["--find", "devicectl"], { timeout: 15_000 }),
|
|
342
|
+
checkXcodeLicense: () => execFileAsync("xcodebuild", ["-license", "check"], { timeout: 15_000 }),
|
|
343
|
+
iosSdkVersion: () => execFileAsync("xcrun", ["--sdk", "iphoneos", "--show-sdk-version"], { timeout: 15_000 }).then(({ stdout }) => stdout.trim()),
|
|
344
|
+
listIosRuntimes,
|
|
345
|
+
listXcodeApps: async () => (await readdir("/Applications").catch(() => [])).filter((name) => /^Xcode.*\.app$/i.test(name)).sort(),
|
|
346
|
+
xcodeSelectPath: () => execFileAsync("xcode-select", ["-p"], { timeout: 15_000 }).then(({ stdout }) => stdout.trim(), () => null),
|
|
347
|
+
listPaidTeams,
|
|
348
|
+
listXcodeAccounts,
|
|
349
|
+
fetchXcodeTeams: () => fetchXcodeTeams({ wdaProjectDir: APPIUM_WDA_DIR }),
|
|
350
|
+
now: () => Date.now(),
|
|
351
|
+
listDevices,
|
|
352
|
+
getLockState,
|
|
353
|
+
getDdiServices,
|
|
354
|
+
listApps,
|
|
355
|
+
chooseSigning,
|
|
356
|
+
ensureSigning,
|
|
357
|
+
provisionWda,
|
|
358
|
+
getPinnedWdaVersion,
|
|
359
|
+
readDeviceState,
|
|
360
|
+
updateDeviceState,
|
|
361
|
+
didIosVersionChange,
|
|
362
|
+
};
|
|
363
|
+
async function detect(check, ctx) {
|
|
364
|
+
try {
|
|
365
|
+
return await check.detect(ctx);
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
return fail(messageForError(error));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async function fixAndDetect(check, ctx) {
|
|
372
|
+
try {
|
|
373
|
+
ctx.onProgress({ step: check.id, message: `${check.fixLabel}...` });
|
|
374
|
+
await check.fix(ctx);
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
return fail(messageForError(error), error?.details);
|
|
378
|
+
}
|
|
379
|
+
return detect(check, ctx);
|
|
380
|
+
}
|
|
381
|
+
function toResult(check, outcome, extra = {}) {
|
|
382
|
+
return {
|
|
383
|
+
id: check.id,
|
|
384
|
+
status: outcome.status,
|
|
385
|
+
title: check.title,
|
|
386
|
+
summary: outcome.summary,
|
|
387
|
+
...(outcome.details ? { details: outcome.details } : {}),
|
|
388
|
+
...(outcome.cause ? { cause: outcome.cause } : {}),
|
|
389
|
+
blocking: check.blocking,
|
|
390
|
+
checkedAt: new Date().toISOString(),
|
|
391
|
+
...(outcome.status === "fail" && check.fix && !extra.blockedBy
|
|
392
|
+
? { remediation: [{ kind: "run_fix", label: check.fixLabel, fixId: check.id }] }
|
|
393
|
+
: {}),
|
|
394
|
+
kind: check.kind,
|
|
395
|
+
...extra,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
export function firstBlockingFailure(results) {
|
|
399
|
+
return results.find((r) => r.blocking && r.status === "fail" && !r.blockedBy) ?? null;
|
|
400
|
+
}
|
|
401
|
+
export function describeFailure(result) {
|
|
402
|
+
return [`${result.title}: ${result.summary}`, result.details].filter(Boolean).join(" ");
|
|
403
|
+
}
|
|
404
|
+
export async function runChecks({ udid = null, teamId = null, autoFix = false, onProgress = () => { }, stateDir, deps = {} } = {}) {
|
|
405
|
+
const ctx = { udid, teamId, stateDir, onProgress, deps: { ...defaultDeps, ...deps } };
|
|
406
|
+
const results = [];
|
|
407
|
+
for (const check of READINESS_CHECKS) {
|
|
408
|
+
const blocker = check.dependsOn
|
|
409
|
+
.map((id) => results.find((r) => r.id === id))
|
|
410
|
+
.find((r) => r.status !== "pass");
|
|
411
|
+
if (blocker) {
|
|
412
|
+
const root = blocker.blockedBy ?? blocker.id;
|
|
413
|
+
const rootTitle = results.find((r) => r.id === root).title;
|
|
414
|
+
results.push(toResult(check, { status: "fail", summary: `Not checked yet: waiting for "${rootTitle}".` }, { blockedBy: root }));
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
let outcome = await detect(check, ctx);
|
|
418
|
+
if (outcome.status === "fail" && autoFix && check.kind === "auto" && check.fix) {
|
|
419
|
+
outcome = await fixAndDetect(check, ctx);
|
|
420
|
+
}
|
|
421
|
+
results.push(toResult(check, outcome));
|
|
422
|
+
}
|
|
423
|
+
const ready = results.every((r) => !r.blocking || r.status === "pass");
|
|
424
|
+
if (ready) {
|
|
425
|
+
await ctx.deps.updateDeviceState(ctx.udid, { lastIosVersion: ctx.device.iosVersion }, { dir: stateDir });
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
ready,
|
|
429
|
+
results,
|
|
430
|
+
context: {
|
|
431
|
+
udid: ctx.udid,
|
|
432
|
+
teamId: ctx.teamId,
|
|
433
|
+
bundleIdBase: ctx.bundleIdBase ?? null,
|
|
434
|
+
device: ctx.device ?? null,
|
|
435
|
+
iosVersionChanged: ctx.iosVersionChanged === true,
|
|
436
|
+
previousIosVersion: ctx.previousIosVersion ?? null,
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import * as appium from "../appium-client.js";
|
|
3
|
+
import { getLockState } from "./devicectl.js";
|
|
4
|
+
import { updateDeviceState } from "./device-state.js";
|
|
5
|
+
import { MESSAGES, describeFailure, firstBlockingFailure, runChecks } from "./readiness.js";
|
|
6
|
+
export const IOS_DEVICE_PREFIX = "ios-device:";
|
|
7
|
+
export const WDA_LAUNCH_TIMEOUT_MS = 15_000;
|
|
8
|
+
export const WDA_RELAUNCH_TIMEOUT_MS = 60_000;
|
|
9
|
+
export const UNLOCK_WAIT_MS = 5 * 60_000;
|
|
10
|
+
export const LOCK_CHECK_INTERVAL_MS = 20_000;
|
|
11
|
+
const UNLOCK_POLL_MS = 2_000;
|
|
12
|
+
let statusListener = null;
|
|
13
|
+
export function setRealDeviceStatusListener(fn) {
|
|
14
|
+
statusListener = fn || null;
|
|
15
|
+
}
|
|
16
|
+
function announce(log, status, message) {
|
|
17
|
+
if (statusListener) {
|
|
18
|
+
statusListener({ status, message });
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
log(message);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function findFreePort() {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const server = net.createServer();
|
|
27
|
+
server.unref();
|
|
28
|
+
server.on("error", reject);
|
|
29
|
+
server.listen(0, "127.0.0.1", () => {
|
|
30
|
+
const { port } = server.address();
|
|
31
|
+
server.close(() => resolve(port));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const defaultDeps = {
|
|
36
|
+
runChecks,
|
|
37
|
+
createSession: appium.createSession,
|
|
38
|
+
deleteSession: appium.deleteSession,
|
|
39
|
+
getLockState,
|
|
40
|
+
findFreePort,
|
|
41
|
+
updateDeviceState,
|
|
42
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
43
|
+
now: () => Date.now(),
|
|
44
|
+
};
|
|
45
|
+
export function parseIosDeviceToken(name) {
|
|
46
|
+
if (typeof name !== "string")
|
|
47
|
+
return null;
|
|
48
|
+
const trimmed = name.trim();
|
|
49
|
+
if (!trimmed.toLowerCase().startsWith(IOS_DEVICE_PREFIX))
|
|
50
|
+
return null;
|
|
51
|
+
const udid = trimmed.slice(IOS_DEVICE_PREFIX.length).trim();
|
|
52
|
+
return { udid: udid && udid.toLowerCase() !== "auto" ? udid : null };
|
|
53
|
+
}
|
|
54
|
+
export function realDeviceCapabilities({ udid, bundleIdBase, deviceName, wdaLocalPort, wdaLaunchTimeout = WDA_LAUNCH_TIMEOUT_MS }) {
|
|
55
|
+
return {
|
|
56
|
+
platformName: "iOS",
|
|
57
|
+
"appium:automationName": "XCUITest",
|
|
58
|
+
"appium:deviceName": deviceName || "iPhone",
|
|
59
|
+
"appium:udid": udid,
|
|
60
|
+
"appium:usePreinstalledWDA": true,
|
|
61
|
+
"appium:updatedWDABundleId": bundleIdBase,
|
|
62
|
+
"appium:wdaLaunchTimeout": wdaLaunchTimeout,
|
|
63
|
+
"appium:wdaLocalPort": wdaLocalPort,
|
|
64
|
+
"appium:noReset": true,
|
|
65
|
+
"appium:shouldTerminateApp": false,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function isBrokenHelperError(error) {
|
|
69
|
+
const text = String(error?.message ?? error ?? "");
|
|
70
|
+
return /Code=41|Not authorized for performing UI testing|local\.pid\.0|attempt to insert nil object/i.test(text);
|
|
71
|
+
}
|
|
72
|
+
function messageForSessionError(error) {
|
|
73
|
+
const text = String(error?.message ?? error ?? "");
|
|
74
|
+
if (/Developer Mode is disabled|CoreDeviceError error 10005/i.test(text))
|
|
75
|
+
return `Developer Mode is off. ${MESSAGES.developerMode}`;
|
|
76
|
+
if (/is not installed/i.test(text))
|
|
77
|
+
return "The helper app (WebDriverAgent) isn't on the iPhone. Run droid again to reinstall it.";
|
|
78
|
+
return `The helper app didn't start on the iPhone: ${text}`;
|
|
79
|
+
}
|
|
80
|
+
export async function createRealDeviceSession({ udid, bundleIdBase, deviceName, log = console.log, deps = {} }) {
|
|
81
|
+
const { createSession, findFreePort: pickPort } = { ...defaultDeps, ...deps };
|
|
82
|
+
const wdaLocalPort = await pickPort();
|
|
83
|
+
try {
|
|
84
|
+
return await createSession(realDeviceCapabilities({ udid, bundleIdBase, deviceName, wdaLocalPort }));
|
|
85
|
+
}
|
|
86
|
+
catch (firstError) {
|
|
87
|
+
log(`The helper app didn't answer (${String(firstError?.message ?? firstError).slice(0, 160)}).`);
|
|
88
|
+
announce(log, "allow-automation", `${MESSAGES.allowAutomation}. Relaunching once...`);
|
|
89
|
+
try {
|
|
90
|
+
return await createSession(realDeviceCapabilities({ udid, bundleIdBase, deviceName, wdaLocalPort, wdaLaunchTimeout: WDA_RELAUNCH_TIMEOUT_MS }));
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
throw new Error(messageForSessionError(error));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export async function waitForUnlock({ udid, log = console.log, timeoutMs = UNLOCK_WAIT_MS, intervalMs = UNLOCK_POLL_MS, deps = {} }) {
|
|
98
|
+
const { getLockState: readLock, sleep, now } = { ...defaultDeps, ...deps };
|
|
99
|
+
announce(log, "locked", MESSAGES.lockedMidRun);
|
|
100
|
+
const deadline = now() + timeoutMs;
|
|
101
|
+
while (now() < deadline) {
|
|
102
|
+
await sleep(intervalMs);
|
|
103
|
+
const lock = await readLock(udid).catch(() => null);
|
|
104
|
+
if (lock && !lock.passcodeRequired) {
|
|
105
|
+
announce(log, "unlocked", "iPhone unlocked, continuing.");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
throw new Error(`The iPhone stayed locked for ${Math.round(timeoutMs / 60_000)} minutes. Unlock it and run again.`);
|
|
110
|
+
}
|
|
111
|
+
export async function startRealDeviceSession({ udid, teamId, log = console.log, deps = {} }) {
|
|
112
|
+
const d = { ...defaultDeps, ...deps };
|
|
113
|
+
log(udid ? `Checking iPhone ${udid}...` : "Looking for an iPhone connected by USB...");
|
|
114
|
+
const readiness = await d.runChecks({ udid, teamId, autoFix: true, onProgress: (p) => log(p.message) });
|
|
115
|
+
if (!readiness.ready) {
|
|
116
|
+
const failure = firstBlockingFailure(readiness.results);
|
|
117
|
+
if (failure.id === "iphone_device_components") {
|
|
118
|
+
statusListener?.({ status: "device-components", message: failure.summary });
|
|
119
|
+
}
|
|
120
|
+
throw new Error(describeFailure(failure));
|
|
121
|
+
}
|
|
122
|
+
const { device, bundleIdBase } = readiness.context;
|
|
123
|
+
log(`Creating Appium session on ${device.name}...`);
|
|
124
|
+
const session = await createRealDeviceSession({ udid: device.udid, bundleIdBase, deviceName: device.name, log, deps: d });
|
|
125
|
+
await d.updateDeviceState(device.udid, { lastSuccessAt: new Date().toISOString(), teamId: readiness.context.teamId });
|
|
126
|
+
return {
|
|
127
|
+
sessionId: session.sessionId,
|
|
128
|
+
udid: device.udid,
|
|
129
|
+
realDevice: true,
|
|
130
|
+
bundleIdBase,
|
|
131
|
+
deviceName: device.name,
|
|
132
|
+
model: device.model,
|
|
133
|
+
teamId: readiness.context.teamId,
|
|
134
|
+
lockCheckedAt: d.now(),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async function restartSession(session, log, d) {
|
|
138
|
+
await d.deleteSession(session.sessionId).catch(() => { });
|
|
139
|
+
const fresh = await createRealDeviceSession({
|
|
140
|
+
udid: session.udid,
|
|
141
|
+
bundleIdBase: session.bundleIdBase,
|
|
142
|
+
deviceName: session.deviceName,
|
|
143
|
+
log,
|
|
144
|
+
deps: d,
|
|
145
|
+
});
|
|
146
|
+
return fresh.sessionId;
|
|
147
|
+
}
|
|
148
|
+
export async function recoverRealDeviceSession(session, error, { log = console.log, deps = {} } = {}) {
|
|
149
|
+
const d = { ...defaultDeps, ...deps };
|
|
150
|
+
const lock = await d.getLockState(session.udid).catch(() => null);
|
|
151
|
+
if (lock?.passcodeRequired) {
|
|
152
|
+
await waitForUnlock({ udid: session.udid, log, deps: d });
|
|
153
|
+
}
|
|
154
|
+
else if (isBrokenHelperError(error)) {
|
|
155
|
+
log("The helper app on the iPhone stopped responding. Starting a new session...");
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return restartSession(session, log, d);
|
|
161
|
+
}
|
|
162
|
+
export async function recoverIfLocked(session, { log = console.log, deps = {} } = {}) {
|
|
163
|
+
const d = { ...defaultDeps, ...deps };
|
|
164
|
+
if (Number.isFinite(session.lockCheckedAt) && d.now() - session.lockCheckedAt < LOCK_CHECK_INTERVAL_MS) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
session.lockCheckedAt = d.now();
|
|
168
|
+
const lock = await d.getLockState(session.udid).catch(() => null);
|
|
169
|
+
if (!lock?.passcodeRequired) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
await waitForUnlock({ udid: session.udid, log, deps: d });
|
|
173
|
+
const sessionId = await restartSession(session, log, d);
|
|
174
|
+
session.lockCheckedAt = d.now();
|
|
175
|
+
return sessionId;
|
|
176
|
+
}
|