@loadmill/droid-cua 2.43.0 → 2.44.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 +114 -7
- package/build/index.js +39 -15
- package/build/src/cli/device-selector.js +13 -6
- 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 +14 -3
- package/build/src/core/execution-engine.js +3 -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/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/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 +31 -0
- package/skill/droid-cua/references/running-and-healing.md +11 -2
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
8
|
+
const INSTALL_TIMEOUT_MS = 180_000;
|
|
9
|
+
let runner = execFileAsync;
|
|
10
|
+
export class DevicectlError extends Error {
|
|
11
|
+
constructor({ kind, message, code = null, domain = null }) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "DevicectlError";
|
|
14
|
+
this.kind = kind;
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.domain = domain;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function __setDevicectlRunner(run = null) {
|
|
20
|
+
runner = run || execFileAsync;
|
|
21
|
+
}
|
|
22
|
+
function errorChain(error) {
|
|
23
|
+
const chain = [];
|
|
24
|
+
let current = error;
|
|
25
|
+
while (current && typeof current === "object" && chain.length < 10) {
|
|
26
|
+
chain.push(current);
|
|
27
|
+
const underlying = current.userInfo?.NSUnderlyingError;
|
|
28
|
+
current = underlying?.error ?? underlying;
|
|
29
|
+
}
|
|
30
|
+
return chain;
|
|
31
|
+
}
|
|
32
|
+
function describe(error) {
|
|
33
|
+
return error.userInfo?.NSLocalizedDescription?.string || "";
|
|
34
|
+
}
|
|
35
|
+
function classify(chain) {
|
|
36
|
+
const text = chain.map(describe).join("\n");
|
|
37
|
+
const hasCode = (domain, code) => chain.some((e) => e.domain === domain && e.code === code);
|
|
38
|
+
if (hasCode("com.apple.dt.CoreDeviceError", 10005) || /Developer Mode is disabled/i.test(text)) {
|
|
39
|
+
return "developer-mode-disabled";
|
|
40
|
+
}
|
|
41
|
+
if (/not been explicitly trusted/i.test(text))
|
|
42
|
+
return "untrusted-developer";
|
|
43
|
+
if (/is not installed/i.test(text))
|
|
44
|
+
return "app-not-installed";
|
|
45
|
+
if (/device (is|was) locked|could not be, unlocked/i.test(text))
|
|
46
|
+
return "device-locked";
|
|
47
|
+
if (/device was not found/i.test(text))
|
|
48
|
+
return "device-not-found";
|
|
49
|
+
if (/not paired|unpaired/i.test(text))
|
|
50
|
+
return "not-paired";
|
|
51
|
+
return "unknown";
|
|
52
|
+
}
|
|
53
|
+
export function devicectlErrorFromJson(error) {
|
|
54
|
+
const chain = errorChain(error);
|
|
55
|
+
const descriptions = [...new Set(chain.map(describe).filter(Boolean))];
|
|
56
|
+
return new DevicectlError({
|
|
57
|
+
kind: classify(chain),
|
|
58
|
+
message: descriptions.join(" ") || `devicectl failed (${error.domain} ${error.code})`,
|
|
59
|
+
code: error.code ?? null,
|
|
60
|
+
domain: error.domain ?? null,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async function readJson(file) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function runDevicectl(args, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
72
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "droid-cua-devicectl-"));
|
|
73
|
+
const jsonPath = path.join(dir, "output.json");
|
|
74
|
+
try {
|
|
75
|
+
let runError = null;
|
|
76
|
+
try {
|
|
77
|
+
await runner("xcrun", ["devicectl", ...args, "--json-output", jsonPath], { timeout: timeoutMs });
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
runError = err;
|
|
81
|
+
}
|
|
82
|
+
if (runError?.killed || runError?.code === "ETIMEDOUT") {
|
|
83
|
+
throw new DevicectlError({
|
|
84
|
+
kind: "timeout",
|
|
85
|
+
message: `devicectl ${args.slice(0, 3).join(" ")} timed out after ${Math.round(timeoutMs / 1000)}s`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const output = await readJson(jsonPath);
|
|
89
|
+
if (output?.info?.outcome === "failed" || output?.error) {
|
|
90
|
+
throw devicectlErrorFromJson(output.error || {});
|
|
91
|
+
}
|
|
92
|
+
if (!output || runError) {
|
|
93
|
+
const detail = runError?.stderr?.trim() || runError?.message || "no JSON output";
|
|
94
|
+
throw new DevicectlError({ kind: "unknown", message: `devicectl ${args.slice(0, 3).join(" ")} failed: ${detail}` });
|
|
95
|
+
}
|
|
96
|
+
return output.result ?? {};
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
await rm(dir, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export async function listDevices() {
|
|
103
|
+
const result = await runDevicectl(["list", "devices"]);
|
|
104
|
+
return (result.devices || []).map((device) => ({
|
|
105
|
+
udid: device.hardwareProperties?.udid,
|
|
106
|
+
name: device.deviceProperties?.name,
|
|
107
|
+
model: device.hardwareProperties?.marketingName,
|
|
108
|
+
iosVersion: device.deviceProperties?.osVersionNumber,
|
|
109
|
+
pairingState: device.connectionProperties?.pairingState,
|
|
110
|
+
tunnelState: device.connectionProperties?.tunnelState,
|
|
111
|
+
transport: device.connectionProperties?.transportType,
|
|
112
|
+
developerModeStatus: device.deviceProperties?.developerModeStatus,
|
|
113
|
+
ddiServicesAvailable: device.deviceProperties?.ddiServicesAvailable === true,
|
|
114
|
+
identifier: device.identifier,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
export async function getLockState(udid) {
|
|
118
|
+
const result = await runDevicectl(["device", "info", "lockState", "--device", udid]);
|
|
119
|
+
return {
|
|
120
|
+
passcodeRequired: result.passcodeRequired === true,
|
|
121
|
+
unlockedSinceBoot: result.unlockedSinceBoot === true,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
export async function getDdiServices(udid) {
|
|
125
|
+
const result = await runDevicectl(["device", "info", "ddiServices", "--device", udid]);
|
|
126
|
+
return result.ddiMetadata ?? null;
|
|
127
|
+
}
|
|
128
|
+
export async function listApps(udid, { includeAll = false } = {}) {
|
|
129
|
+
const args = ["device", "info", "apps", "--device", udid];
|
|
130
|
+
if (includeAll)
|
|
131
|
+
args.push("--include-all-apps");
|
|
132
|
+
const result = await runDevicectl(args);
|
|
133
|
+
return (result.apps || []).map((app) => ({
|
|
134
|
+
bundleIdentifier: app.bundleIdentifier,
|
|
135
|
+
name: app.name,
|
|
136
|
+
version: app.version,
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
export async function listProcesses(udid) {
|
|
140
|
+
const result = await runDevicectl(["device", "info", "processes", "--device", udid]);
|
|
141
|
+
return (result.runningProcesses || []).map((proc) => ({
|
|
142
|
+
pid: proc.processIdentifier,
|
|
143
|
+
executable: proc.executable,
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
export async function installApp(udid, appPath) {
|
|
147
|
+
const result = await runDevicectl(["device", "install", "app", "--device", udid, appPath], {
|
|
148
|
+
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
149
|
+
});
|
|
150
|
+
return (result.installedApplications || []).map((app) => app.bundleID ?? app.bundleIdentifier);
|
|
151
|
+
}
|
|
152
|
+
export async function terminateProcess(udid, pid) {
|
|
153
|
+
await runDevicectl(["device", "process", "terminate", "--device", udid, "--pid", String(pid)]);
|
|
154
|
+
}
|
|
@@ -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
|
+
}
|