@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.
Files changed (30) hide show
  1. package/README.md +114 -7
  2. package/build/index.js +39 -15
  3. package/build/src/cli/device-selector.js +13 -6
  4. package/build/src/cli/headless-target.js +24 -0
  5. package/build/src/cli/ios-device-command.js +121 -0
  6. package/build/src/cli/ios-device-list.js +32 -0
  7. package/build/src/cli/network-capture-device.js +21 -0
  8. package/build/src/commands/help.js +14 -3
  9. package/build/src/core/execution-engine.js +3 -0
  10. package/build/src/desktop-shared/ios-real-device.js +82 -0
  11. package/build/src/device/factory.js +4 -0
  12. package/build/src/device/ios/actions.js +48 -12
  13. package/build/src/device/ios/appium-server.js +7 -18
  14. package/build/src/device/ios/connection.js +71 -12
  15. package/build/src/device/ios/node-cli-env.js +55 -0
  16. package/build/src/device/ios/real-device/device-state.js +40 -0
  17. package/build/src/device/ios/real-device/devicectl.js +154 -0
  18. package/build/src/device/ios/real-device/readiness.js +439 -0
  19. package/build/src/device/ios/real-device/session.js +176 -0
  20. package/build/src/device/ios/real-device/signing.js +466 -0
  21. package/build/src/device/ios/real-device/wda-provisioning.js +229 -0
  22. package/build/src/network/certificate-authority.js +21 -1
  23. package/build/src/network/certificate-install.js +38 -0
  24. package/build/src/network/local-proxy.js +21 -1
  25. package/build/src/network/run-network-capture.js +16 -5
  26. package/package.json +1 -1
  27. package/skill/droid-cua/SKILL.md +2 -2
  28. package/skill/droid-cua/references/authoring-tests.md +1 -1
  29. package/skill/droid-cua/references/onboarding-and-troubleshooting.md +31 -0
  30. package/skill/droid-cua/references/running-and-healing.md +11 -2
@@ -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
+ }
@@ -0,0 +1,466 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { X509Certificate, createHash } from "node:crypto";
3
+ import { mkdtemp, readdir, rm } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { promisify } from "node:util";
7
+ const execFileAsync = promisify(execFile);
8
+ const PROVISIONING_BUILD_TIMEOUT_MS = 10 * 60 * 1000;
9
+ const TEAM_FETCH_TIMEOUT_MS = 90 * 1000;
10
+ const XCODE_TEAM_DOMAINS = ["com.apple.dt.Xcode", "com.apple.dt.xcodebuild"];
11
+ const TAIL_LINES = 40;
12
+ export const PROFILE_DIRS = [
13
+ path.join(os.homedir(), "Library/Developer/Xcode/UserData/Provisioning Profiles"),
14
+ path.join(os.homedir(), "Library/MobileDevice/Provisioning Profiles"),
15
+ ];
16
+ const defaultRunner = (cmd, args) => execFileAsync(cmd, args, { maxBuffer: 64 * 1024 * 1024 });
17
+ let runner = defaultRunner;
18
+ let spawner = spawn;
19
+ export function __setSigningRunner(run = null) {
20
+ runner = run || defaultRunner;
21
+ }
22
+ export function __setSigningSpawn(fn = null) {
23
+ spawner = fn || spawn;
24
+ }
25
+ function unescapeXml(text) {
26
+ return text
27
+ .replace(/&lt;/g, "<")
28
+ .replace(/&gt;/g, ">")
29
+ .replace(/&quot;/g, '"')
30
+ .replace(/&apos;/g, "'")
31
+ .replace(/&amp;/g, "&");
32
+ }
33
+ export function parsePlist(xml) {
34
+ const tokens = [...xml.matchAll(/<(\/?)(dict|array|key|string|integer|real|date|data|true|false)\s*(\/?)>([^<]*)/g)].map((m) => ({
35
+ close: m[1] === "/",
36
+ tag: m[2],
37
+ empty: m[3] === "/",
38
+ text: unescapeXml(m[4]),
39
+ }));
40
+ let i = 0;
41
+ function value() {
42
+ const token = tokens[i++];
43
+ if (token.tag === "true" || token.tag === "false")
44
+ return token.tag === "true";
45
+ if (token.tag === "dict") {
46
+ const dict = {};
47
+ if (token.empty)
48
+ return dict;
49
+ while (!tokens[i].close) {
50
+ const key = tokens[i].text;
51
+ i += 2;
52
+ dict[key] = value();
53
+ }
54
+ i++;
55
+ return dict;
56
+ }
57
+ if (token.tag === "array") {
58
+ const list = [];
59
+ if (token.empty)
60
+ return list;
61
+ while (!tokens[i].close)
62
+ list.push(value());
63
+ i++;
64
+ return list;
65
+ }
66
+ if (token.empty)
67
+ return token.tag === "data" ? Buffer.alloc(0) : "";
68
+ i++;
69
+ if (token.tag === "integer" || token.tag === "real")
70
+ return Number(token.text);
71
+ if (token.tag === "date")
72
+ return new Date(token.text.trim());
73
+ if (token.tag === "data")
74
+ return Buffer.from(token.text.replace(/\s+/g, ""), "base64");
75
+ return token.text;
76
+ }
77
+ return tokens.length ? value() : null;
78
+ }
79
+ export function parseFindIdentityOutput(stdout) {
80
+ const identities = new Map();
81
+ for (const line of stdout.split("\n")) {
82
+ const match = line.match(/^\s*\d+\)\s+([0-9A-F]{40})\s+"(.+)"\s*$/);
83
+ if (!match)
84
+ continue;
85
+ const [, sha1, name] = match;
86
+ if (!/^(Apple Development|iPhone Developer):/.test(name))
87
+ continue;
88
+ if (!identities.has(sha1))
89
+ identities.set(sha1, { name, sha1 });
90
+ }
91
+ return [...identities.values()];
92
+ }
93
+ export function teamIdFromSubject(subject) {
94
+ return subject.match(/(?:^|[\n,/])\s*OU\s*=\s*([A-Z0-9]{10})\b/)?.[1] ?? null;
95
+ }
96
+ function certificatesFromPemOutput(stdout) {
97
+ return [...stdout.matchAll(/SHA-1 hash: ([0-9A-F]{40})\s*\n(-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----)/g)].map((m) => ({
98
+ sha1: m[1],
99
+ certificate: new X509Certificate(m[2]),
100
+ }));
101
+ }
102
+ export async function listSigningIdentities() {
103
+ const { stdout } = await runner("security", ["find-identity", "-v", "-p", "codesigning"]);
104
+ const identities = [];
105
+ for (const identity of parseFindIdentityOutput(stdout)) {
106
+ const certs = await runner("security", ["find-certificate", "-a", "-c", identity.name, "-Z", "-p"])
107
+ .then((r) => certificatesFromPemOutput(r.stdout))
108
+ .catch(() => []);
109
+ const cert = certs.find((c) => c.sha1 === identity.sha1)?.certificate;
110
+ identities.push({
111
+ ...identity,
112
+ teamId: cert ? teamIdFromSubject(cert.subject) : null,
113
+ expiresAt: cert ? new Date(cert.validTo).toISOString() : null,
114
+ });
115
+ }
116
+ return identities;
117
+ }
118
+ async function readDefaultsKey(domain, key) {
119
+ try {
120
+ const { stdout } = await runner("/bin/sh", ["-c", `defaults export ${domain} - | plutil -extract ${key} xml1 -o - -`]);
121
+ return parsePlist(stdout);
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ export async function listXcodeTeams() {
128
+ const teams = new Map();
129
+ for (const domain of XCODE_TEAM_DOMAINS) {
130
+ const byAccount = (await readDefaultsKey(domain, "IDEProvisioningTeamByIdentifier")) || {};
131
+ for (const accountTeams of Object.values(byAccount)) {
132
+ for (const team of Array.isArray(accountTeams) ? accountTeams : []) {
133
+ if (!team.teamID || teams.has(team.teamID))
134
+ continue;
135
+ teams.set(team.teamID, {
136
+ teamId: team.teamID,
137
+ teamName: team.teamName ?? null,
138
+ teamType: team.teamType ?? null,
139
+ isFree: team.isFreeProvisioningTeam === true,
140
+ });
141
+ }
142
+ }
143
+ }
144
+ return [...teams.values()];
145
+ }
146
+ export async function listXcodeAccounts() {
147
+ const lists = (await readDefaultsKey("com.apple.dt.Xcode", "DVTDeveloperAccountManagerAppleIDLists")) || {};
148
+ return Object.values(lists)
149
+ .flatMap((accounts) => (Array.isArray(accounts) ? accounts : []))
150
+ .map((account) => account?.identifier)
151
+ .filter(Boolean);
152
+ }
153
+ export function teamFetchBuildArgs({ wdaProjectDir, derivedDataPath }) {
154
+ return [
155
+ "build-for-testing",
156
+ "-project",
157
+ path.join(wdaProjectDir, "WebDriverAgent.xcodeproj"),
158
+ "-scheme",
159
+ "WebDriverAgentRunner",
160
+ "-destination",
161
+ "generic/platform=iOS",
162
+ "-derivedDataPath",
163
+ derivedDataPath,
164
+ "-allowProvisioningUpdates",
165
+ ];
166
+ }
167
+ export async function fetchXcodeTeams({ wdaProjectDir, timeoutMs = TEAM_FETCH_TIMEOUT_MS }) {
168
+ const derivedDataPath = await mkdtemp(path.join(os.tmpdir(), "droid-cua-team-fetch-"));
169
+ try {
170
+ await new Promise((resolve) => {
171
+ const child = spawner("xcodebuild", teamFetchBuildArgs({ wdaProjectDir, derivedDataPath }), { stdio: "ignore" });
172
+ const timer = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
173
+ const done = () => {
174
+ clearTimeout(timer);
175
+ resolve();
176
+ };
177
+ child.on("error", done);
178
+ child.on("close", done);
179
+ });
180
+ }
181
+ finally {
182
+ await rm(derivedDataPath, { recursive: true, force: true });
183
+ }
184
+ }
185
+ export async function listTeams() {
186
+ const [identities, xcodeTeams] = await Promise.all([listSigningIdentities(), listXcodeTeams()]);
187
+ const teams = new Map(xcodeTeams.map((t) => [t.teamId, { ...t, inXcode: true, identities: [] }]));
188
+ for (const identity of identities) {
189
+ if (!identity.teamId)
190
+ continue;
191
+ if (!teams.has(identity.teamId)) {
192
+ teams.set(identity.teamId, { teamId: identity.teamId, teamName: null, teamType: null, isFree: null, inXcode: false, identities: [] });
193
+ }
194
+ teams.get(identity.teamId).identities.push(identity);
195
+ }
196
+ return [...teams.values()];
197
+ }
198
+ export async function listPaidTeams() {
199
+ return (await listTeams()).filter((team) => team.isFree === false);
200
+ }
201
+ export function profileFromPlist(plist, filePath) {
202
+ const entitlements = plist.Entitlements || {};
203
+ return {
204
+ path: filePath,
205
+ uuid: plist.UUID ?? null,
206
+ name: plist.Name ?? null,
207
+ teamId: plist.TeamIdentifier?.[0] ?? null,
208
+ appIdPattern: entitlements["application-identifier"] ?? null,
209
+ expiresAt: plist.ExpirationDate instanceof Date ? plist.ExpirationDate : null,
210
+ getTaskAllow: entitlements["get-task-allow"] === true,
211
+ provisionsAllDevices: plist.ProvisionsAllDevices === true,
212
+ devices: plist.ProvisionedDevices || [],
213
+ certificateSha1s: (plist.DeveloperCertificates || []).map((der) => createHash("sha1").update(der).digest("hex").toUpperCase()),
214
+ };
215
+ }
216
+ export async function loadProfiles({ dirs = PROFILE_DIRS } = {}) {
217
+ const profiles = [];
218
+ for (const dir of dirs) {
219
+ const files = await readdir(dir).catch(() => []);
220
+ for (const file of files.filter((f) => f.endsWith(".mobileprovision")).sort()) {
221
+ const filePath = path.join(dir, file);
222
+ try {
223
+ const { stdout } = await runner("security", ["cms", "-D", "-i", filePath]);
224
+ profiles.push(profileFromPlist(parsePlist(stdout), filePath));
225
+ }
226
+ catch {
227
+ continue;
228
+ }
229
+ }
230
+ }
231
+ return profiles;
232
+ }
233
+ export function wdaRunnerBundleId(bundleIdBase) {
234
+ return `${bundleIdBase}.xctrunner`;
235
+ }
236
+ export function appIdCovers(appIdPattern, applicationIdentifier) {
237
+ if (!appIdPattern)
238
+ return false;
239
+ if (appIdPattern.endsWith("*"))
240
+ return applicationIdentifier.startsWith(appIdPattern.slice(0, -1));
241
+ return appIdPattern === applicationIdentifier;
242
+ }
243
+ export function profileProblems(profile, { teamId, bundleIdBase, udid, certificateSha1, now = new Date() }) {
244
+ const problems = [];
245
+ if (profile.teamId !== teamId)
246
+ problems.push("team");
247
+ if (!appIdCovers(profile.appIdPattern, `${teamId}.${wdaRunnerBundleId(bundleIdBase)}`))
248
+ problems.push("app-id");
249
+ if (!profile.provisionsAllDevices && !profile.devices.includes(udid))
250
+ problems.push("device");
251
+ if (certificateSha1 && !profile.certificateSha1s.includes(certificateSha1))
252
+ problems.push("certificate");
253
+ if (!profile.expiresAt || profile.expiresAt <= now)
254
+ problems.push("expired");
255
+ if (!profile.getTaskAllow)
256
+ problems.push("not-development");
257
+ return problems;
258
+ }
259
+ const PROBLEM_TEXT = {
260
+ "app-id": "doesn't cover the WDA bundle id",
261
+ device: "doesn't include this iPhone",
262
+ certificate: "doesn't contain the signing certificate",
263
+ expired: "has expired",
264
+ "not-development": "isn't a development profile",
265
+ };
266
+ function profileSummary(profile) {
267
+ return {
268
+ path: profile.path,
269
+ uuid: profile.uuid,
270
+ name: profile.name,
271
+ teamId: profile.teamId,
272
+ expiresAt: profile.expiresAt.toISOString(),
273
+ appIdPattern: profile.appIdPattern,
274
+ };
275
+ }
276
+ export function selectProfile(profiles, criteria) {
277
+ const teamProfiles = profiles.filter((p) => p.teamId === criteria.teamId);
278
+ if (!teamProfiles.length) {
279
+ return { profile: null, reason: `No provisioning profile for team ${criteria.teamId} on this Mac.` };
280
+ }
281
+ const checked = teamProfiles.map((profile) => ({ profile, problems: profileProblems(profile, criteria) }));
282
+ const usable = checked.filter((c) => !c.problems.length).map((c) => c.profile);
283
+ if (usable.length) {
284
+ const best = usable.sort((a, b) => b.expiresAt - a.expiresAt)[0];
285
+ return { profile: profileSummary(best), reason: null };
286
+ }
287
+ const closest = checked.sort((a, b) => a.problems.length - b.problems.length)[0];
288
+ const why = closest.problems.map((p) => PROBLEM_TEXT[p]).join(", ");
289
+ return {
290
+ profile: null,
291
+ reason: `No usable provisioning profile for team ${criteria.teamId}: the closest, "${closest.profile.name}", ${why}.`,
292
+ };
293
+ }
294
+ export async function findProfile({ teamId, bundleIdBase, udid, certificateSha1, profileDirs = PROFILE_DIRS, now = new Date() }) {
295
+ const profiles = await loadProfiles({ dirs: profileDirs });
296
+ return selectProfile(profiles, { teamId, bundleIdBase, udid, certificateSha1, now });
297
+ }
298
+ export function chooseIdentity(identities, profiles, { teamId, bundleIdBase, udid, now = new Date() }) {
299
+ const teamIdentities = identities
300
+ .filter((i) => i.teamId === teamId)
301
+ .sort((a, b) => (b.expiresAt || "").localeCompare(a.expiresAt || ""));
302
+ if (!teamIdentities.length) {
303
+ return { identity: null, profile: null, reason: `No Apple Development certificate for team ${teamId} on this Mac.` };
304
+ }
305
+ const candidates = teamIdentities.map((identity) => ({
306
+ identity,
307
+ ...selectProfile(profiles, { teamId, bundleIdBase, udid, certificateSha1: identity.sha1, now }),
308
+ }));
309
+ const withProfile = candidates.filter((c) => c.profile);
310
+ const count = teamIdentities.length;
311
+ if (!withProfile.length) {
312
+ const { identity, reason } = candidates[0];
313
+ return { identity, profile: null, reason };
314
+ }
315
+ const chosen = withProfile[0];
316
+ let reason;
317
+ if (count === 1) {
318
+ reason = `Using "${chosen.identity.name}", the only Apple Development certificate for team ${teamId}.`;
319
+ }
320
+ else if (withProfile.length === 1) {
321
+ reason = `Using "${chosen.identity.name}": of ${count} certificates for team ${teamId}, it is the one a valid provisioning profile contains.`;
322
+ }
323
+ else {
324
+ reason = `Using "${chosen.identity.name}": ${withProfile.length} certificates for team ${teamId} are in a valid provisioning profile; picked the newest.`;
325
+ }
326
+ return { identity: chosen.identity, profile: chosen.profile, reason };
327
+ }
328
+ export async function chooseSigning({ teamId, bundleIdBase, udid, profileDirs = PROFILE_DIRS, now = new Date() }) {
329
+ const [identities, profiles] = await Promise.all([listSigningIdentities(), loadProfiles({ dirs: profileDirs })]);
330
+ return chooseIdentity(identities, profiles, { teamId, bundleIdBase, udid, now });
331
+ }
332
+ export function provisioningBuildArgs({ teamId, bundleIdBase, udid, wdaProjectDir, derivedDataPath }) {
333
+ return [
334
+ "build-for-testing",
335
+ "-project",
336
+ path.join(wdaProjectDir, "WebDriverAgent.xcodeproj"),
337
+ "-scheme",
338
+ "WebDriverAgentRunner",
339
+ "-destination",
340
+ `id=${udid}`,
341
+ "-derivedDataPath",
342
+ derivedDataPath,
343
+ "-allowProvisioningUpdates",
344
+ "-allowProvisioningDeviceRegistration",
345
+ `DEVELOPMENT_TEAM=${teamId}`,
346
+ `PRODUCT_BUNDLE_IDENTIFIER=${bundleIdBase}`,
347
+ "CODE_SIGN_STYLE=Automatic",
348
+ ];
349
+ }
350
+ const TEAM_ADMIN_ASK = "Ask your Apple team admin for the Developer role with Access to Certificates, Identifiers & Profiles";
351
+ const CREATE_CERTIFICATE = "Create one in Xcode > Settings > Apple Accounts > select your account > select the team > Manage Certificates > + > Apple Development, or on the Apple Developer website (developer.apple.com > Certificates, Identifiers & Profiles > Certificates). If you don't have permission to create certificates, ask your Apple Developer team admin for one.";
352
+ export function classifyProvisioningBuildFailure(output, { teamId, udid }) {
353
+ if (/agreed? to the Xcode (and Apple SDKs )?license/i.test(output)) {
354
+ return { kind: "xcode-license", message: "Open Xcode once and accept its license, then retry." };
355
+ }
356
+ if (/Unable to find a destination matching/i.test(output)) {
357
+ return {
358
+ kind: "device-unavailable",
359
+ message: "Xcode can't reach the iPhone. Keep it connected by USB, unlocked and trusted, then retry.",
360
+ };
361
+ }
362
+ if (/Revoke certificate:|private key is not installed in your keychain/i.test(output)) {
363
+ return {
364
+ kind: "certificate-key-missing",
365
+ message: "There's no usable Apple Development certificate for your team on this Mac.",
366
+ details: `${CREATE_CERTIFICATE} Then retry.`,
367
+ };
368
+ }
369
+ if (/register(ing)? (this |the )?device|device registration|no devices from which to generate|not registered/i.test(output)) {
370
+ return {
371
+ kind: "device-registration",
372
+ message: `Apple didn't let your account register this iPhone (${udid}) with team ${teamId}. ${TEAM_ADMIN_ASK}, or ask them to add the iPhone under Devices.`,
373
+ };
374
+ }
375
+ if (/No Accounts?\b|No account for team|Add a new account in|valid credentials|session has expired|sign in with your Apple ID/i.test(output)) {
376
+ return {
377
+ kind: "no-account",
378
+ message: `Xcode has no signed-in Apple ID for team ${teamId}. Open Xcode > Settings > Apple Accounts, sign in with your work Apple ID, then retry.`,
379
+ };
380
+ }
381
+ if (/does(n't| not) have (the )?(required )?permission|do(n't| not) have (the )?(required )?(permission|access|privileges)|not allowed to (perform|create)|insufficient (permissions|privileges)|team admin/i.test(output)) {
382
+ return {
383
+ kind: "team-access",
384
+ message: `Your Apple ID can't create signing certificates or profiles for team ${teamId}. ${TEAM_ADMIN_ASK}.`,
385
+ };
386
+ }
387
+ if (/No "(iOS|Apple) Development" signing certificate/i.test(output)) {
388
+ return {
389
+ kind: "certificate-missing",
390
+ message: "Xcode was still creating your signing certificate when it signed. Running the build again usually fixes this.",
391
+ details: `If it keeps failing, make sure there's an Apple Development certificate for your team on this Mac. ${CREATE_CERTIFICATE}`,
392
+ };
393
+ }
394
+ const firstError = output.match(/\berror:\s*(.+)/i)?.[1].trim();
395
+ return {
396
+ kind: "unknown",
397
+ message: "The one-time signing build failed. See the build output for details.",
398
+ ...(firstError ? { details: `Xcode reported: ${firstError}` } : {}),
399
+ };
400
+ }
401
+ export function runProvisioningBuild({ teamId, bundleIdBase, udid, wdaProjectDir, derivedDataPath, onOutput = () => { }, timeoutMs = PROVISIONING_BUILD_TIMEOUT_MS, }) {
402
+ const started = Date.now();
403
+ const args = provisioningBuildArgs({ teamId, bundleIdBase, udid, wdaProjectDir, derivedDataPath });
404
+ const tail = [];
405
+ const errorLines = [];
406
+ let pending = "";
407
+ const addLine = (line) => {
408
+ tail.push(line);
409
+ if (tail.length > TAIL_LINES)
410
+ tail.shift();
411
+ if (/\berror\b/i.test(line) && errorLines.length < 50)
412
+ errorLines.push(line);
413
+ };
414
+ return new Promise((resolve) => {
415
+ let settled = false;
416
+ let timedOut = false;
417
+ const child = spawner("xcodebuild", args, { stdio: ["ignore", "pipe", "pipe"] });
418
+ const finish = (result) => {
419
+ if (settled)
420
+ return;
421
+ settled = true;
422
+ clearTimeout(timer);
423
+ if (pending)
424
+ addLine(pending);
425
+ const logTail = tail.join("\n");
426
+ const ok = result.ok;
427
+ resolve({
428
+ ok,
429
+ durationMs: Date.now() - started,
430
+ logTail,
431
+ error: ok ? null : result.error || classifyProvisioningBuildFailure(`${errorLines.join("\n")}\n${logTail}`, { teamId, udid }),
432
+ });
433
+ };
434
+ const timer = setTimeout(() => {
435
+ timedOut = true;
436
+ child.kill("SIGTERM");
437
+ }, timeoutMs);
438
+ const onData = (chunk) => {
439
+ const text = chunk.toString();
440
+ onOutput(text);
441
+ const lines = (pending + text).split("\n");
442
+ pending = lines.pop();
443
+ lines.forEach(addLine);
444
+ };
445
+ child.stdout?.on("data", onData);
446
+ child.stderr?.on("data", onData);
447
+ child.on("error", (error) => {
448
+ finish({
449
+ ok: false,
450
+ error: error.code === "ENOENT"
451
+ ? { kind: "xcode-missing", message: "xcodebuild was not found. Install Xcode and open it once, then retry." }
452
+ : { kind: "unknown", message: `Could not start xcodebuild: ${error.message}` },
453
+ });
454
+ });
455
+ child.on("close", (code) => {
456
+ if (timedOut) {
457
+ finish({
458
+ ok: false,
459
+ error: { kind: "timeout", message: `The one-time signing build didn't finish within ${Math.round(timeoutMs / 60000)} minutes.` },
460
+ });
461
+ return;
462
+ }
463
+ finish({ ok: code === 0 });
464
+ });
465
+ });
466
+ }